From db654af66b333975c09a96b34ee3c80e115821e7 Mon Sep 17 00:00:00 2001
From: Artur Shiriev
Date: Fri, 9 Oct 2026 19:11:28 +0300
Subject: [PATCH] List services never deployed to production, walking their
whole default branch
---
README.md | 18 ++++++++++--------
release_scope/_candidates.py | 10 +++++++---
release_scope/_static/index.html | 11 ++++++-----
release_scope/_use_case.py | 18 +++++++++++++-----
skills/release-scope/SKILL.md | 2 ++
tests/test_use_case.py | 12 ++++++++----
6 files changed, 46 insertions(+), 25 deletions(-)
diff --git a/README.md b/README.md
index 7359d4e..7669663 100644
--- a/README.md
+++ b/README.md
@@ -21,9 +21,9 @@
Jira issues, failed jobs.
For every service it reads the latest successful production deployment, walks the default branch down to that
-commit, and writes a static site with one JSON report: a row per merge request or direct commit, newest first, with the tags that
-point into it, the environments running it, the Jira keys its MR mentions, and the failed jobs of its main-branch
-and tag pipelines. With a Jira token, it also reads the summary and status of every key in one batched search,
+commit, or down to its first commit when the service was never deployed to production, and writes a static site with
+one JSON report: a row per merge request or direct commit, newest first, with the tags that point into it, the
+environments running it, the Jira keys its MR mentions, and the failed jobs of its main-branch and tag pipelines. With a Jira token, it also reads the summary and status of every key in one batched search,
and the GitLab merge requests and commits linked to each issue. GitLab's Jira integration adds those links to the
issue's Web links whenever a commit or MR mentions it; a link counts only if it starts with
`RELEASE_SCOPE_GITLAB__ENDPOINT`. The projects they point to are the issue's related services.
@@ -87,7 +87,8 @@ The report is versioned by `schema_version`; the models live in
Top-level `jira` is `null` without a Jira token; otherwise it holds `issues` by key (summary, status, status category,
issue type, linked GitLab changes), the `missing` keys Jira did not return, and an `error` if a Jira request failed.
Each service lists its `candidates`: the tags a release could ship, newest first, each with its pipeline, the number of
-rows it ships, the in-scope Jira keys of those rows, and the compare link from production. One row, trimmed:
+rows it ships, the in-scope Jira keys of those rows, and the compare link from production, or the tag's commit history
+when the service has no production deployment. One row, trimmed:
```json
{
@@ -107,14 +108,14 @@ Besides `report.json`, `collect` writes `index.html` and its script into the out
installed package and change only with it, so the page always matches the report schema. The page loads
`report.json` from next to itself; it needs a web server, not a `file://` URL.
-**Services** lists every service with a production deployment, and every service that failed to collect, as one
-line: what production runs, the picked tag, how many merge requests or commits and Jira tasks it ships, failed jobs
+**Services** lists every service with a production deployment or with rows, and every service that failed to collect,
+as one line: what production runs, the picked tag, how many merge requests or commits and Jira tasks it ships, failed jobs
with the ones allowed to fail counted apart, and a mark when the range was cut at `RELEASE_SCOPE_MAX_COMMITS`.
Opening a line shows the service's environments, warnings, merge requests that are not merged yet, and its rows with
tags and their pipelines, merge requests or commits, Jira keys with their status, environments, and failed jobs; rows
out of scope are dimmed. Links open in a new tab. Each tag has a **pick** button:
picking it highlights the rows it ships and closes the line again. A `--jira` report starts with each service's
-release tag picked. Services without a production deployment are left out of the page.
+release tag picked. Services with neither a production deployment nor rows are left out of the page.
The address keeps the picks after `#`, as `team/api=2.4.0&team/web=5.12.0`, so sharing or reloading the page keeps
them. An address with picks replaces the release tags of a `--jira` report; picks that are not in the report, such as
@@ -128,7 +129,8 @@ text box to copy from by hand, since browsers allow the copy button only over HT
summary and a status badge: grey to do, blue in progress, green done. Copy them one per line or as a JQL
`key in (...)` clause, each in its own box.
- **Tag pipelines**: the pipeline of each picked tag, as a Markdown list.
-- **Compare**: a GitLab compare link per service from production to the picked tag, as a Markdown list.
+- **Compare**: a GitLab compare link per service from production to the picked tag, as a Markdown list. A service
+ never deployed to production gets the commit history of the tag instead.
- **Release post**: one Markdown text with a line per picked tag, holding its compare link and pipeline, followed by
the Jira tasks with their summaries.
diff --git a/release_scope/_candidates.py b/release_scope/_candidates.py
index 42fad2b..09d91ff 100644
--- a/release_scope/_candidates.py
+++ b/release_scope/_candidates.py
@@ -3,8 +3,10 @@
from release_scope._report import Candidate, EnvironmentState, Service
-def build_candidates(service: Service, production: EnvironmentState) -> list[Candidate]:
- base: str = quote(production.ref if production.tag else production.sha)
+def build_candidates(service: Service, production: EnvironmentState | None) -> list[Candidate]:
+ base: str | None = None
+ if production is not None:
+ base = quote(production.ref if production.tag else production.sha)
candidates: list[Candidate] = []
for index, row in enumerate(service.rows):
shipped = service.rows[index:]
@@ -12,7 +14,9 @@ def build_candidates(service: Service, production: EnvironmentState) -> list[Can
candidates.extend(
Candidate(
tag=tag,
- compare_url=f"{service.project_url}/-/compare/{base}...{quote(tag.name)}",
+ compare_url=f"{service.project_url}/-/compare/{base}...{quote(tag.name)}"
+ if base
+ else f"{service.project_url}/-/commits/{quote(tag.name)}",
rows=len(shipped),
jira_keys=list(keys.values()),
)
diff --git a/release_scope/_static/index.html b/release_scope/_static/index.html
index 7cfd044..e29f17a 100644
--- a/release_scope/_static/index.html
+++ b/release_scope/_static/index.html
@@ -93,7 +93,7 @@ Open a service and pick the tag to release: it ships every merge request and commit from its row down to production. The address keeps the picks, so share it to share them.
-
+
@@ -298,11 +298,11 @@ Release post
get collectedAt() {
return this.report.collected_at.slice(0, 16).replace("T", " ");
},
- get deployed() {
- return this.report.services.filter((service) => service.error || this.production(service));
+ get listed() {
+ return this.report.services.filter((service) => service.error || this.production(service) || service.rows.length);
},
get pickable() {
- return this.deployed.filter((service) => service.candidates.length);
+ return this.listed.filter((service) => service.candidates.length);
},
get problems() {
const jira = this.report.jira;
@@ -432,10 +432,11 @@ Release post
},
productionRef(service) {
const production = this.production(service);
- if (!production) return "";
+ if (!production) return "not in production";
return production.tag ? production.ref : production.sha.slice(0, 8);
},
compareLabel({ service, candidate }) {
+ if (!this.production(service)) return `commits to ${candidate.tag.name}`;
return `${this.productionRef(service)}...${candidate.tag.name}`;
},
pipelineIcon(pipeline) {
diff --git a/release_scope/_use_case.py b/release_scope/_use_case.py
index ff53597..e32e80b 100644
--- a/release_scope/_use_case.py
+++ b/release_scope/_use_case.py
@@ -251,10 +251,14 @@ def _collect_service(self, project: Project, cache: Cache, *, links: list[Linked
service.warnings.append("Project has no default branch.")
return service
if production is None:
- service.warnings.append(f"No successful deployment to '{self.settings.production_environment}'.")
- return service
+ service.warnings.append(
+ f"No successful deployment to '{self.settings.production_environment}'; "
+ f"rows run from the first commit of {project.default_branch}."
+ )
- walk: typing.Final = self._walk(project, project.default_branch, production.sha, service, cache)
+ walk: typing.Final = self._walk(
+ project, project.default_branch, production.sha if production else None, service, cache
+ )
drafts, linked, in_scope = walk.drafts, [False] * len(walk.drafts), [True] * len(walk.drafts)
if links is not None:
drafts, linked, in_scope = self._scope_rows(project, project.default_branch, service, walk, links, cache)
@@ -272,9 +276,13 @@ def _collect_service(self, project: Project, cache: Cache, *, links: list[Linked
service.candidates.extend(build_candidates(service, production))
return service
- def _walk(self, project: Project, default_branch: str, baseline: str, service: Service, cache: Cache) -> _Walk:
+ def _walk(
+ self, project: Project, default_branch: str, baseline: str | None, service: Service, cache: Cache
+ ) -> _Walk:
commits, truncated = self.api.list_first_parent_commits(
- project.id, f"{baseline}..{default_branch}", max_items=self.settings.max_commits
+ project.id,
+ f"{baseline}..{default_branch}" if baseline else default_branch,
+ max_items=self.settings.max_commits,
)
walk: typing.Final = _Walk(truncated=truncated)
service.truncated = truncated
diff --git a/skills/release-scope/SKILL.md b/skills/release-scope/SKILL.md
index 677d997..4a64f14 100644
--- a/skills/release-scope/SKILL.md
+++ b/skills/release-scope/SKILL.md
@@ -81,6 +81,8 @@ Read `$out/site/report.json` and answer the user's question from it, briefly.
`environments` already running it, `jira_keys`, and `main_pipeline` with `failed_jobs`. Tag pipelines carry their
own `failed_jobs`.
- A service with no rows is up to date with production.
+- A service never deployed to production has a warning saying so; its rows run down to the first commit of the
+ default branch and its `compare_url` lists the tag's commits.
- `truncated: true`: the walk stopped at `RELEASE_SCOPE_MAX_COMMITS`, so the oldest candidates miss rows and keys.
- `candidates` (newest first): the tags a release could ship, each with `tag` and its `pipeline`, `rows` (how many
rows run from that tag down to production), `jira_keys` (the in-scope keys of those rows, without duplicates), and
diff --git a/tests/test_use_case.py b/tests/test_use_case.py
index f9d90b5..ac54f1b 100644
--- a/tests/test_use_case.py
+++ b/tests/test_use_case.py
@@ -476,14 +476,18 @@ def test_project_listed_twice_is_collected_once(gitlab: respx.Router) -> None:
assert [item.project for item in report.services] == ["team/svc"]
-@pytest.mark.httpx2(assert_all_called=False)
-def test_service_without_production_deployment_has_no_rows(gitlab: respx.Router) -> None:
+def test_service_without_production_deployment_lists_the_whole_default_branch(gitlab: respx.Router) -> None:
gitlab["deploy:production"].respond(json=[])
service: typing.Final = _only_service(_collect())
- assert service.rows == []
- assert service.warnings == ["No successful deployment to 'production'."]
+ assert gitlab["commits"].calls[0].request.url.params["ref_name"] == "main"
+ assert [row.commits[0].sha for row in service.rows] == ["head", "c3", "c2", "c1", "c0b"]
+ assert [(item.tag.name, item.rows, item.compare_url) for item in service.candidates] == [
+ ("1.2.0", 4, f"{ENDPOINT}/team/svc/-/commits/1.2.0"),
+ ("1.1.0", 2, f"{ENDPOINT}/team/svc/-/commits/1.1.0"),
+ ]
+ assert service.warnings == ["No successful deployment to 'production'; rows run from the first commit of main."]
@pytest.mark.httpx2(assert_all_called=False)