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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,8 @@ uvx release-scope collect --group team/backend --output report.json --cache cach
uvx release-scope render report.json --output report.md
```

The page opens with a table of the services that have pending changes or problems, with the ref each environment runs;
The page opens with a table of the services that have pending changes or problems, with the ref each environment runs
and a GitLab compare link from production to the newest pending tag (to the release tag in a `--jira` report);
services already up to date collapse into one expandable table. Each service with changes then has a collapsible table
of its rows: the tag linked to its pipeline, the merge requests or direct commit, Jira keys with summary and status,
the other services its Jira issues link to, where the change is deployed, and the failed jobs of its main-branch
Expand Down
1 change: 1 addition & 0 deletions release_scope/_gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class Project(pydantic.BaseModel):

class Deployable(pydantic.BaseModel):
web_url: str | None = None
tag: bool = False


class Deployment(pydantic.BaseModel):
Expand Down
18 changes: 16 additions & 2 deletions release_scope/_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import datetime
import html
import typing
from urllib.parse import quote

from release_scope._report import (
EnvironmentState,
Expand Down Expand Up @@ -131,11 +132,23 @@ def _not_done(service: Service, issues: dict[str, JiraIssue]) -> str:
return f"{count} not done" if count else ""


def _summary_row(service: Service, environments: list[str], jira: JiraState | None) -> list[str]:
def _compare(service: Service, production: str) -> str:
start: typing.Final = _environment(service, production)
release: typing.Final = service.release
tag: typing.Final = release.tag if release else next((tag for row in service.rows for tag in row.tags), None)
if start is None or tag is None:
return ""
base, label = (start.ref, _cell(start.ref)) if start.tag else (start.sha, f"`{start.sha[:8]}`")
return _link(f"{label}...{_cell(tag.name)}", f"{service.project_url}/-/compare/{quote(base)}...{quote(tag.name)}")


def _summary_row(service: Service, report: Report, environments: list[str]) -> list[str]:
jira: typing.Final = report.jira
return [
_link(_cell(service.project), service.project_url),
*(_environment_ref(_environment(service, name)) for name in environments),
_pending(service),
_compare(service, report.production_environment),
*([_not_done(service, jira.issues)] if jira else []),
_failure_counts(service),
]
Expand Down Expand Up @@ -301,10 +314,11 @@ def render_markdown(report: Report) -> str:
"Service",
*(_cell(name) for name in environments),
"Pending",
"Compare",
*(["Jira"] if jira else []),
"Failed jobs",
]
lines.extend([*_table(header, (_summary_row(service, environments, jira) for service in attention)), ""])
lines.extend([*_table(header, (_summary_row(service, report, environments) for service in attention)), ""])
else:
lines.extend(["All services are up to date.", ""])
if up_to_date:
Expand Down
1 change: 1 addition & 0 deletions release_scope/_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ class EnvironmentState(pydantic.BaseModel):
sha: str
deployed_at: str
deployment_url: str | None
tag: bool = False


class Release(pydantic.BaseModel):
Expand Down
1 change: 1 addition & 0 deletions release_scope/_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def _environment_state(name: str, deployment: Deployment) -> EnvironmentState:
sha=deployment.sha,
deployed_at=deployment.created_at,
deployment_url=deployment.deployable.web_url if deployment.deployable else None,
tag=deployment.deployable.tag if deployment.deployable else False,
)


Expand Down
2 changes: 1 addition & 1 deletion tests/payloads.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ def job(name: str, *, allow_failure: bool = False, **fields: typing.Any) -> dict
"ref": "1.0.0",
"sha": "prod",
"created_at": "2026-09-01T00:00:00Z",
"deployable": {"web_url": f"{ENDPOINT}/team/svc/-/jobs/70"},
"deployable": {"web_url": f"{ENDPOINT}/team/svc/-/jobs/70", "tag": True},
}
PREVIEW_DEPLOYMENT: typing.Final = {
"id": 8,
Expand Down
67 changes: 52 additions & 15 deletions tests/test_render.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,9 @@
)


def _environment(name: str, ref: str, url: str | None = None) -> EnvironmentState:
def _environment(name: str, ref: str, url: str | None = None, *, tag: bool = False) -> EnvironmentState:
return EnvironmentState(
name=name, ref=ref, sha=f"sha-{ref}", deployed_at="2026-09-20T00:00:00Z", deployment_url=url
name=name, ref=ref, sha=f"sha-{ref}", deployed_at="2026-09-20T00:00:00Z", deployment_url=url, tag=tag
)


Expand Down Expand Up @@ -115,7 +115,7 @@ def _linked(project: str, iid: int) -> LinkedChange:
project="acme/api",
project_url="https://g.test/acme/api",
default_branch="main",
environments=[_environment("prod", "2.3.0", "https://g.test/d1"), _environment("preview", "2.4.0")],
environments=[_environment("prod", "2.3.0", "https://g.test/d1", tag=True), _environment("preview", "2.4.0")],
rows=[
Row(
kind="commit",
Expand Down Expand Up @@ -191,13 +191,14 @@ def test_page_lists_attention_first_and_collapses_up_to_date_services() -> None:
"",
"Legend: ✅ success · ❌ failed · 🔄 running · ⏭ canceled or skipped · ⚠️ warning or allowed failure",
"",
"| Service | prod | preview | Pending | Jira | Failed jobs |",
"|---|---|---|---|---|---|",
"| [acme/broken](https://g.test/acme/broken) | — | — | ❌ failed to collect | | |",
"| [acme/utils](https://g.test/acme/utils) | — | — | ⚠️ see below | | |",
"| Service | prod | preview | Pending | Compare | Jira | Failed jobs |",
"|---|---|---|---|---|---|---|",
"| [acme/broken](https://g.test/acme/broken) | — | — | ❌ failed to collect | | | |",
"| [acme/utils](https://g.test/acme/utils) | — | — | ⚠️ see below | | | |",
(
"| [acme/api](https://g.test/acme/api) | [2.3.0](https://g.test/d1) | 2.4.0 "
"| 3 changes · untagged head | 1 not done | ❌ 2 · ⚠️ 1 allowed |"
"| 3 changes · untagged head | [2.3.0...2.4.0](https://g.test/acme/api/-/compare/2.3.0...2.4.0) "
"| 1 not done | ❌ 2 · ⚠️ 1 allowed |"
),
"",
"<details>",
Expand Down Expand Up @@ -256,7 +257,7 @@ def test_page_without_changes_or_problems_says_so() -> None:
page: typing.Final = render_markdown(_report(_BILLING))

assert "All services are up to date." in page.splitlines()
assert "| Service | prod | Pending | Failed jobs |" not in page
assert "| Service | prod | Pending | Compare | Failed jobs |" not in page
assert "<summary>1 service up to date</summary>" in page


Expand Down Expand Up @@ -289,7 +290,7 @@ def test_summary_counts_allowed_failures_apart_from_blocking_ones() -> None:
}
)

assert "| 1 change · untagged head | ⚠️ 1 allowed |" in render_markdown(_report(allowed_only))
assert "| 1 change · untagged head | | ⚠️ 1 allowed |" in render_markdown(_report(allowed_only))


def test_link_targets_cannot_break_out_of_markdown() -> None:
Expand Down Expand Up @@ -317,6 +318,29 @@ def test_service_with_rows_but_no_production_environment_still_renders() -> None

assert "<summary>3 changes</summary>" in page
assert "preview 2.4.0 · 3 merge requests, 1 direct commit" in page
assert "| 3 changes · untagged head | | ❌ 2 · ⚠️ 1 allowed |" in page


def test_compare_starts_from_the_production_commit_when_it_was_not_deployed_from_a_tag() -> None:
production: typing.Final = EnvironmentState(
name="prod", ref="main", sha="0a1b2c3d4e5f", deployed_at="2026-09-20T00:00:00Z", deployment_url=None
)
from_branch: typing.Final = _API.model_copy(update={"environments": [production]})

assert "| [`0a1b2c3d`...2.4.0](https://g.test/acme/api/-/compare/0a1b2c3d4e5f...2.4.0) |" in render_markdown(
_report(from_branch)
)


def test_compare_quotes_the_tag_but_keeps_its_slashes() -> None:
odd_tag: typing.Final = TagRef(name="release/2.4#1", url="https://g.test/t/x", pipeline=None)
tagged: typing.Final = _API.model_copy(
update={"rows": [_API.rows[0].model_copy(update={"tags": [odd_tag]}), *_API.rows[1:]]}
)

assert "| [2.3.0...release/2.4#1](https://g.test/acme/api/-/compare/2.3.0...release/2.4%231) |" in render_markdown(
_report(tagged)
)


def test_pipe_in_a_link_target_does_not_split_the_cell() -> None:
Expand All @@ -336,7 +360,7 @@ def test_section_lists_production_first() -> None:
def test_page_without_jira_lists_bare_keys_and_no_jira_column() -> None:
page: typing.Final = render_markdown(_report(_API))

assert "| Service | prod | preview | Pending | Failed jobs |" in page
assert "| Service | prod | preview | Pending | Compare | Failed jobs |" in page
assert "| Tag | Change | Jira | Deployed to | Failed jobs |" in page
assert "| [SHOP-140](https://j.test/browse/SHOP-140)<br>OPS-1 |" in page

Expand Down Expand Up @@ -380,14 +404,25 @@ def test_scoped_page_names_the_issues_and_the_release_tag() -> None:
"- SHOP-404 · not found in Jira",
]
assert lines[7].endswith(" · 🎯 linked to the issues")
assert "| 2 changes · release [2.4.0](https://g.test/p/5120) ✅ | 1 not done | ❌ 2 · ⚠️ 1 allowed |" in lines[11]
assert (
"| 2 changes · release [2.4.0](https://g.test/p/5120) ✅ "
"| [2.3.0...2.4.0](https://g.test/acme/api/-/compare/2.3.0...2.4.0) | 1 not done | ❌ 2 · ⚠️ 1 allowed |"
) in lines[11]
assert any(line.startswith("| [2.4.0](https://g.test/p/5120) ❌ | 🎯 [!311]") for line in lines)


def test_scoped_service_without_a_tag_needs_one() -> None:
untagged: typing.Final = _SCOPED_API.model_copy(update={"release": Release(state="pending")})

assert "| 2 changes · needs a new tag |" in render_markdown(_scoped(untagged))
assert "| 2 changes · needs a new tag | |" in render_markdown(_scoped(untagged))


def test_scoped_compare_goes_to_the_release_tag() -> None:
later: typing.Final = _SCOPED_API.model_copy(
update={"release": Release(state="pending", tag=_TAG.model_copy(update={"name": "2.4.1"}))}
)

assert "(https://g.test/acme/api/-/compare/2.3.0...2.4.1) |" in render_markdown(_scoped(later))


def test_scoped_service_with_unmerged_work_asks_for_attention() -> None:
Expand All @@ -405,9 +440,11 @@ def test_scoped_service_with_unmerged_work_asks_for_attention() -> None:

page: typing.Final = render_markdown(_scoped(waiting, lost))

assert "| [acme/billing](https://g.test/acme/billing) | [1.8.1](https://g.test/d2) | ⏳ not merged | | |" in page
assert (
"| [acme/lost](https://g.test/acme/billing) | [1.8.1](https://g.test/d2) | ⚠️ linked change not found | | |"
"| [acme/billing](https://g.test/acme/billing) | [1.8.1](https://g.test/d2) | ⏳ not merged | | | |" in page
)
assert (
"| [acme/lost](https://g.test/acme/billing) | [1.8.1](https://g.test/d2) | ⚠️ linked change not found | | | |"
in page
)
assert "⏳ Not merged: [!7](https://g.test/mr/7) WIP · @jdoe" in page.splitlines()
Expand Down
1 change: 1 addition & 0 deletions tests/test_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ def test_rows_run_from_newest_commit_down_to_production() -> None:
assert [item.name for item in service.environments] == ["production", "preview"]
assert service.environments[0].deployment_url == f"{ENDPOINT}/team/svc/-/jobs/70"
assert service.environments[1].deployment_url is None
assert [item.tag for item in service.environments] == [True, False]
assert service.warnings == []


Expand Down
Loading