Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
{
Expand All @@ -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
Expand All @@ -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.

Expand Down
10 changes: 7 additions & 3 deletions release_scope/_candidates.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@
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:]
keys = {key.key: key for item in shipped if item.in_scope for key in item.jira_keys}
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()),
)
Expand Down
11 changes: 6 additions & 5 deletions release_scope/_static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ <h1 x-text="report.jira_scope.length ? 'Release scope: ' + report.jira_scope.joi
<h2>Services</h2>
<p x-show="pickable.length" class="muted">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.</p>
<p x-show="ignored" class="warn" x-text="ignored"></p>
<template x-for="service in deployed" :key="service.project">
<template x-for="service in listed" :key="service.project">
<details class="service" name="services">
<summary>
<span class="name" x-text="service.project"></span>
Expand Down Expand Up @@ -298,11 +298,11 @@ <h3>Release post</h3>
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;
Expand Down Expand Up @@ -432,10 +432,11 @@ <h3>Release post</h3>
},
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) {
Expand Down
18 changes: 13 additions & 5 deletions release_scope/_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions skills/release-scope/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 8 additions & 4 deletions tests/test_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading