From 7f248c600d26b87f25da9eaec23a8af8ce69084b Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 6 Oct 2026 13:44:33 +0300 Subject: [PATCH 01/10] Remove the wiki page output: render and publish --- release_scope/__main__.py | 50 +---- release_scope/_gitlab.py | 19 +- release_scope/_publish.py | 51 ----- release_scope/_render.py | 339 ---------------------------- release_scope/ioc.py | 2 - tests/test_cli.py | 156 ------------- tests/test_render.py | 456 -------------------------------------- 7 files changed, 2 insertions(+), 1071 deletions(-) delete mode 100644 release_scope/_publish.py delete mode 100644 release_scope/_render.py delete mode 100644 tests/test_render.py diff --git a/release_scope/__main__.py b/release_scope/__main__.py index 64247c0..44b7285 100644 --- a/release_scope/__main__.py +++ b/release_scope/__main__.py @@ -1,6 +1,5 @@ import functools import importlib.metadata -import json import pathlib import typing @@ -12,9 +11,7 @@ from release_scope._errors import ConfigError, ReleaseScopeError from release_scope._files import write_text_atomic from release_scope._jira_keys import JIRA_KEY_PATTERN -from release_scope._publish import PublishUseCase -from release_scope._render import render_markdown -from release_scope._report import SCHEMA_VERSION, Report +from release_scope._report import Report from release_scope._use_case import CollectUseCase @@ -134,51 +131,6 @@ def _jira_errors(report: Report) -> list[str]: return errors -@MAIN_APP.command("render", help="Render a JSON report as a Markdown page for a GitLab wiki.") -def _render_command( - report_path: typing.Annotated[pathlib.Path, typer.Argument(help="Report JSON written by `collect`.")], - output: typing.Annotated[pathlib.Path, typer.Option("--output", "-o", help="Where to write the Markdown page.")], -) -> None: - try: - raw = json.loads(report_path.read_bytes()) - version = raw.get("schema_version") if isinstance(raw, dict) else None - if isinstance(version, int) and version < SCHEMA_VERSION: - typer.echo( - f"Error: Cannot read report {report_path}: schema_version {version} is not supported; " - "run collect again.", - err=True, - ) - raise typer.Exit(code=ConfigError.exit_code) - report = Report.model_validate(raw) - except (OSError, ValueError) as exc: - typer.echo(f"Error: Cannot read report {report_path}: {type(exc).__name__}.", err=True) - raise typer.Exit(code=ConfigError.exit_code) from exc - try: - write_text_atomic(output, render_markdown(report)) - except OSError as exc: - typer.echo(f"Error: Cannot write page {output}: {type(exc).__name__}.", err=True) - raise typer.Exit(code=ReleaseScopeError.exit_code) from exc - typer.echo(f"{len(report.services)} services -> {output}", err=True) - - -@MAIN_APP.command("publish", help="Replace the content of an existing GitLab wiki page with a rendered page.") -@_exit_on_error -@modern_di_typer.inject -def _publish_command( - use_case: typing.Annotated[PublishUseCase, modern_di_typer.FromDI(PublishUseCase)], - page_path: typing.Annotated[pathlib.Path, typer.Argument(help="Markdown page written by `render`.")], - project: typing.Annotated[str, typer.Option("--project", "-p", help="GitLab project path that holds the wiki.")], - page: typing.Annotated[str, typer.Option("--page", help="Slug of the wiki page, such as releases/backend.")], -) -> None: - try: - content = page_path.read_text(encoding="utf-8") - except (OSError, ValueError) as exc: - typer.echo(f"Error: Cannot read page {page_path}: {type(exc).__name__}.", err=True) - raise typer.Exit(code=ConfigError.exit_code) from exc - published: typing.Final = use_case(project=project, slug=page, content=content) - typer.echo(f"{'Updated' if published.updated else 'Unchanged'} {published.url}", err=True) - - def main() -> None: with ioc.container: MAIN_APP() diff --git a/release_scope/_gitlab.py b/release_scope/_gitlab.py index 7fdb4ac..65585d2 100644 --- a/release_scope/_gitlab.py +++ b/release_scope/_gitlab.py @@ -104,11 +104,6 @@ class Bridge(Job): downstream_pipeline: DownstreamPipeline | None = None -class WikiPage(pydantic.BaseModel): - slug: str - content: str - - class _Projects(pydantic.RootModel[list[Project]]): pass @@ -142,7 +137,7 @@ class _Bridges(pydantic.RootModel[list[Bridge]]): Resource: typing.TypeAlias = typing.Literal[ - "group", "project", "deployments", "pipelines", "repository", "merge_requests", "wiki" + "group", "project", "deployments", "pipelines", "repository", "merge_requests" ] @@ -170,12 +165,6 @@ def _get(self, url: str, params: dict[str, typing.Any], model: type[_ModelT], *, except httpware.ClientError as exc: raise _translate(exc, url=url, resource=resource) from exc - def _put(self, url: str, body: dict[str, typing.Any], *, resource: Resource) -> None: - try: - self.http.put(url, json=body) - except httpware.ClientError as exc: - raise _translate(exc, url=url, resource=resource) from exc - def _pages( self, url: str, @@ -299,9 +288,3 @@ def failed_bridges(self, project_id: int, pipeline_id: int) -> list[Bridge]: resource="pipelines", ) return bridges - - def get_wiki_page(self, project: str, slug: str) -> WikiPage: - return self._get(f"{_API}/projects/{_quote(project)}/wikis/{_quote(slug)}", {}, WikiPage, resource="wiki") - - def update_wiki_page(self, project: str, slug: str, *, content: str) -> None: - self._put(f"{_API}/projects/{_quote(project)}/wikis/{_quote(slug)}", {"content": content}, resource="wiki") diff --git a/release_scope/_publish.py b/release_scope/_publish.py deleted file mode 100644 index f390cf9..0000000 --- a/release_scope/_publish.py +++ /dev/null @@ -1,51 +0,0 @@ -import dataclasses -import http -import typing - -from release_scope._errors import AuthError, GitLabError, ReleaseScopeError -from release_scope._gitlab import GitLabApi -from release_scope._settings import Settings - - -@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) -class Published: - url: str - updated: bool - - -def _wiki_error(error: GitLabError, *, project: str, slug: str, size: int | None) -> ReleaseScopeError: - if error.status == http.HTTPStatus.FORBIDDEN: - return AuthError( - f"GitLab denied access to the wiki of project '{project}' (403). Check that the token has the 'api' " - "scope and that its user has at least the Developer role there." - ) - if error.status == http.HTTPStatus.NOT_FOUND: - return GitLabError( - f"Wiki page '{slug}' does not exist in project '{project}', or the token cannot see it. " - "Create the page in GitLab first.", - resource="wiki", - status=error.status, - ) - if size is None: - return error - return GitLabError(f"{error} The page is {size} bytes.", resource="wiki", status=error.status, reason=error.reason) - - -@dataclasses.dataclass(frozen=True, slots=True, kw_only=True) -class PublishUseCase: - api: GitLabApi - settings: Settings - - def __call__(self, *, project: str, slug: str, content: str) -> Published: - url: typing.Final = f"{self.settings.gitlab.endpoint.rstrip('/')}/{project}/-/wikis/{slug}" - try: - page = self.api.get_wiki_page(project, slug) - except GitLabError as exc: - raise _wiki_error(exc, project=project, slug=slug, size=None) from exc - if page.content == content: - return Published(url=url, updated=False) - try: - self.api.update_wiki_page(project, slug, content=content) - except GitLabError as exc: - raise _wiki_error(exc, project=project, slug=slug, size=len(content.encode())) from exc - return Published(url=url, updated=True) diff --git a/release_scope/_render.py b/release_scope/_render.py deleted file mode 100644 index 0264237..0000000 --- a/release_scope/_render.py +++ /dev/null @@ -1,339 +0,0 @@ -import collections.abc -import datetime -import html -import typing -from urllib.parse import quote - -from release_scope._report import ( - EnvironmentState, - FailedJob, - JiraIssue, - JiraKeyRef, - JiraState, - PipelineState, - Report, - Row, - Service, - TagRef, -) - - -_STATUS_ICONS: typing.Final = { - "success": "✅", - "failed": "❌", - "created": "🔄", - "waiting_for_resource": "🔄", - "preparing": "🔄", - "pending": "🔄", - "running": "🔄", - "scheduled": "🔄", - "canceled": "⏭", - "skipped": "⏭", - "manual": "⏭", -} -_LEGEND: typing.Final = ( - "Legend: ✅ success · ❌ failed · 🔄 running · ⏭ canceled or skipped · ⚠️ warning or allowed failure" -) -_FAILED, _SKIPPED, _PENDING = 0, 1, 2 -_UNRELEASED: typing.Final = {"not_merged": "⏳ not merged", "not_found": "⚠️ linked change not found"} -_LINKED: typing.Final = "🎯" - - -def _inline(value: str) -> str: - return html.escape(value, quote=False).replace("|", "\\|").replace("[", "\\[").replace("]", "\\]") - - -def _cell(value: str) -> str: - return _inline(value).replace("\r\n", "
").replace("\n", "
") - - -def _link(label: str, url: str | None) -> str: - if not url: - return label - return f"[{label}]({url.replace(' ', '%20').replace(')', '%29').replace('|', '%7C')})" - - -def _icon(status: str) -> str: - return _STATUS_ICONS.get(status, f"`{status}`") - - -def _plural(count: int, noun: str) -> str: - return f"{count} {noun}" if count == 1 else f"{count} {noun}s" - - -def _nonzero(separator: str, parts: collections.abc.Iterable[tuple[int, str]]) -> str: - return separator.join(text for count, text in parts if count) - - -def _table( - header: collections.abc.Sequence[str], rows: collections.abc.Iterable[collections.abc.Sequence[str]] -) -> list[str]: - lines: typing.Final = [f"| {' | '.join(header)} |", f"|{'---|' * len(header)}"] - lines.extend(f"| {' | '.join(row)} |" for row in rows) - return lines - - -def _collapsed(summary: str, body: list[str]) -> list[str]: - return ["
", f"{summary}", "", *body, "", "
", ""] - - -def _state(service: Service) -> int | None: - if service.error: - return _FAILED - if service.rows: - return _PENDING - if service.release and service.release.state in _UNRELEASED: - return _SKIPPED - return _SKIPPED if service.warnings else None - - -def _environment_names(report: Report) -> list[str]: - names: dict[str, None] = {report.production_environment: None} - for service in report.services: - names.update((item.name, None) for item in service.environments) - return list(names) - - -def _environment(service: Service, name: str) -> EnvironmentState | None: - return next((item for item in service.environments if item.name == name), None) - - -def _environment_ref(environment: EnvironmentState | None) -> str: - return _link(_cell(environment.ref), environment.deployment_url) if environment else "—" - - -def _failure_counts(service: Service) -> str: - pipelines: typing.Final = [row.main_pipeline for row in service.rows] + [ - tag.pipeline for row in service.rows for tag in row.tags - ] - jobs: typing.Final = [job for pipeline in pipelines if pipeline for job in pipeline.failed_jobs] - allowed: typing.Final = sum(1 for job in jobs if job.allow_failure) - blocking: typing.Final = len(jobs) - allowed - return _nonzero(" · ", [(blocking, f"❌ {blocking}"), (allowed, f"⚠️ {allowed} allowed")]) - - -def _pending(service: Service) -> str: - state: typing.Final = _state(service) - release: typing.Final = service.release - if state == _FAILED: - return "❌ failed to collect" - if state == _SKIPPED: - return _UNRELEASED.get(release.state, "⚠️ see below") if release else "⚠️ see below" - if release: - suffix = f" · release {_tag(release.tag)}" if release.tag else " · needs a new tag" - else: - suffix = "" if service.rows[0].tags else " · untagged head" - return f"{_plural(len(service.rows), 'change')}{suffix}" - - -def _not_done(service: Service, issues: dict[str, JiraIssue]) -> str: - keys: typing.Final = {key.key for row in service.rows for key in row.jira_keys} - count: typing.Final = sum(1 for key in keys if key in issues and issues[key].status_category != "done") - return f"{count} not done" if count else "" - - -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), - ] - - -def _job(job: FailedJob) -> str: - text: str = _link(_cell(job.name), job.url) - if job.allow_failure: - text += " (allowed)" - if job.downstream_pipeline_url: - text += f" → {_link('child', job.downstream_pipeline_url)}" - return text - - -def _jobs(pipeline: PipelineState) -> str: - return ", ".join(_job(job) for job in pipeline.failed_jobs) - - -def _main_pipeline(pipeline: PipelineState) -> str: - text: typing.Final = f"main {_icon(pipeline.status)} {_link(str(pipeline.id), pipeline.url)}" - return f"{text}: {_jobs(pipeline)}" if pipeline.failed_jobs else text - - -def _tag(tag: TagRef) -> str: - if tag.pipeline is None: - return f"{_link(_cell(tag.name), tag.url)} ⚠️ no pipeline" - return f"{_link(_cell(tag.name), tag.pipeline.url)} {_icon(tag.pipeline.status)}" - - -def _reference(label: str, url: str | None, title: str, author: str | None) -> str: - return f"{_link(label, url)} {_cell(title)}" + (f" · {_cell(author)}" if author else "") - - -def _merge_requests_or_commits(row: Row) -> str: - if row.merge_requests: - return "
".join( - _reference(f"!{item.iid}", item.url, item.title, f"@{item.author}" if item.author else None) - for item in row.merge_requests - ) - return "
".join(_reference(f"`{item.short_sha}`", item.url, item.title, item.author) for item in row.commits) - - -def _failed_jobs(row: Row) -> str: - parts: typing.Final = [_main_pipeline(row.main_pipeline)] if row.main_pipeline else [] - parts.extend( - f"tag {_cell(tag.name)}: {_jobs(tag.pipeline)}" for tag in row.tags if tag.pipeline and tag.pipeline.failed_jobs - ) - return "
".join(parts) - - -def _jira_key(key: JiraKeyRef, issues: dict[str, JiraIssue]) -> str: - text: typing.Final = _link(_cell(key.key), key.url) - issue: typing.Final = issues.get(key.key) - return f"{text} {_cell(issue.summary)} · {_cell(issue.status)}" if issue else text - - -def _related_services(row: Row, project: str, issues: dict[str, JiraIssue]) -> str: - related: typing.Final = { - link.project: link.project_url - for key in row.jira_keys - if key.key in issues - for link in issues[key.key].links - if link.project != project - } - return ", ".join(_link(_cell(name), related[name]) for name in sorted(related)) - - -def _row(row: Row, project: str, jira: JiraState | None) -> list[str]: - issues: typing.Final = jira.issues if jira else {} - return [ - "
".join(_tag(tag) for tag in row.tags), - f"{_LINKED} {_merge_requests_or_commits(row)}" if row.linked else _merge_requests_or_commits(row), - "
".join(_jira_key(key, issues) for key in row.jira_keys), - *([_related_services(row, project, issues)] if jira else []), - ", ".join(_cell(name) for name in row.environments), - _failed_jobs(row), - ] - - -def _counts(service: Service) -> str: - merge_requests: typing.Final = len({item.iid for row in service.rows for item in row.merge_requests}) - commits: typing.Final = sum(1 for row in service.rows if row.kind == "commit") - return _nonzero( - ", ", [(merge_requests, _plural(merge_requests, "merge request")), (commits, _plural(commits, "direct commit"))] - ) - - -def _rows_section(service: Service, production: str, jira: JiraState | None) -> list[str]: - ordered: typing.Final = sorted(service.environments, key=lambda item: item.name != production) - environments: typing.Final = [f"{_cell(item.name)} {_environment_ref(item)}" for item in ordered] - production_state: typing.Final = _environment(service, production) - since: typing.Final = f" since {_inline(production_state.ref)}" if production_state else "" - return [ - " · ".join([*environments, _counts(service)]), - "", - *_collapsed( - f"{_plural(len(service.rows), 'change')}{since}", - _table( - ["Tag", "Change", "Jira", *(["Related services"] if jira else []), "Deployed to", "Failed jobs"], - (_row(row, service.project, jira) for row in service.rows), - ), - ), - ] - - -def _service_section(service: Service, production: str, jira: JiraState | None) -> list[str]: - lines: typing.Final = [f"## {_inline(service.project)}", ""] - if service.error: - lines.extend([f"❌ {_inline(service.error)}", ""]) - lines.extend(line for warning in service.warnings for line in (f"⚠️ {_inline(warning)}", "")) - release: typing.Final = service.release - if release and release.pending_merge_requests: - waiting: typing.Final = ", ".join( - _reference(f"!{item.iid}", item.url, item.title, f"@{item.author}" if item.author else None) - for item in release.pending_merge_requests - ) - lines.extend([f"⏳ Not merged: {waiting}", ""]) - if release and release.state == "not_found": - lines.extend(["⚠️ No linked change was found between production and the head of the default branch.", ""]) - if service.rows: - lines.extend(_rows_section(service, production, jira)) - return lines - - -def _scope_issue(key: str, jira: JiraState | None) -> str: - issue: typing.Final = jira.issues.get(key) if jira else None - if issue is None: - return f"- {_inline(key)} · not found in Jira" - return f"- {_link(_inline(key), issue.url)} {_cell(issue.summary)} · {_cell(issue.status)}" - - -def render_markdown(report: Report) -> str: - production: typing.Final = report.production_environment - collected: typing.Final = report.collected_at.astimezone(datetime.UTC).strftime("%Y-%m-%d %H:%M") - jira: typing.Final = report.jira - scope: typing.Final = report.jira_scope - lines: typing.Final = [ - f"# Release scope: {', '.join(_inline(key) for key in scope)}" if scope else "# Release scope", - "", - ( - f"Collected {collected} UTC. Changes run from the commit on `{_inline(production)}` " - + ("to the latest change linked to the issues." if scope else "to the head of the default branch.") - ), - "", - ] - if scope: - lines.extend([*(_scope_issue(key, jira) for key in scope), ""]) - lines.extend([f"{_LEGEND} · {_LINKED} linked to the issues" if scope else _LEGEND, ""]) - if jira and jira.error: - lines.extend([f"❌ {_inline(jira.error)}", ""]) - if not report.services: - lines.append("No services were collected.") - return "\n".join(lines) + "\n" - - attention: typing.Final = sorted( - (service for service in report.services if _state(service) is not None), - key=lambda service: (_state(service), service.project), - ) - up_to_date: typing.Final = [service for service in report.services if _state(service) is None] - environments: typing.Final = _environment_names(report) - if attention: - header: typing.Final = [ - "Service", - *(_cell(name) for name in environments), - "Pending", - "Compare", - *(["Jira"] if jira else []), - "Failed jobs", - ] - 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: - lines.extend( - _collapsed( - f"{_plural(len(up_to_date), 'service')} up to date", - _table( - ["Service", _cell(production)], - ( - [_link(_cell(item.project), item.project_url), _environment_ref(_environment(item, production))] - for item in up_to_date - ), - ), - ) - ) - for service in attention: - lines.extend(_service_section(service, production, jira)) - return "\n".join(lines).rstrip("\n") + "\n" diff --git a/release_scope/ioc.py b/release_scope/ioc.py index bf2dbba..d1d8d03 100644 --- a/release_scope/ioc.py +++ b/release_scope/ioc.py @@ -6,7 +6,6 @@ from release_scope._gitlab import GitLabApi from release_scope._jira import JiraApi -from release_scope._publish import PublishUseCase from release_scope._settings import Settings, load_settings from release_scope._use_case import CollectUseCase @@ -74,7 +73,6 @@ class UseCasesGroup(modern_di.Group): collect_use_case = providers.Factory( scope=Scope.APP, creator=CollectUseCase, kwargs={"jira": ClientsGroup.jira_api} ) - publish_use_case = providers.Factory(scope=Scope.APP, creator=PublishUseCase) ALL_GROUPS: typing.Final[list[type[modern_di.Group]]] = [SettingsGroup, ClientsGroup, UseCasesGroup] diff --git a/tests/test_cli.py b/tests/test_cli.py index c238f19..7af6eea 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -243,159 +243,3 @@ def test_module_entry_point_runs_the_app(monkeypatch: pytest.MonkeyPatch) -> Non runpy.run_module("release_scope", run_name="__main__") assert exc_info.value.code == 0 - - -def test_render_writes_markdown_from_a_report(tmp_path: pathlib.Path) -> None: - report: typing.Final = tmp_path / "report.json" - report.write_text( - '{"schema_version": 2, "collected_at": "2026-09-29T10:15:00Z", ' - '"production_environment": "prod", "services": []}' - ) - page: typing.Final = tmp_path / "out" / "report.md" - - result: typing.Final = _invoke("render", str(report), "--output", str(page)) - - assert result.exit_code == 0, result.output - assert page.read_text().startswith("# Release scope\n") - assert f"-> {page}" in result.output - - -@pytest.mark.parametrize( - ("content", "reason"), - [ - (None, "FileNotFoundError."), - ('{"schema_version": 2}', "ValidationError."), - ("not json", "JSONDecodeError."), - ("[]", "ValidationError."), - ('{"schema_version": 1}', "schema_version 1 is not supported; run collect again."), - ], -) -def test_render_rejects_an_unreadable_report(tmp_path: pathlib.Path, content: str | None, reason: str) -> None: - report: typing.Final = tmp_path / "report.json" - if content is not None: - report.write_text(content) - - result: typing.Final = _invoke("render", str(report), "-o", str(tmp_path / "report.md")) - - assert result.exit_code == 2 - assert f"Error: Cannot read report {report}: {reason}" in result.output - assert not (tmp_path / "report.md").exists() - - -def test_render_reports_a_page_it_cannot_write(tmp_path: pathlib.Path) -> None: - report: typing.Final = tmp_path / "report.json" - report.write_text( - '{"schema_version": 2, "collected_at": "2026-09-29T10:15:00Z", ' - '"production_environment": "prod", "services": []}' - ) - blocked: typing.Final = tmp_path / "report.md" - blocked.mkdir() - - result: typing.Final = _invoke("render", str(report), "-o", str(blocked)) - - assert result.exit_code == 1 - assert f"Error: Cannot write page {blocked}: IsADirectoryError." in result.output - - -_WIKI_PAGE: typing.Final = f"{API}/projects/team%2Fdocs/wikis/releases%2Fbackend" - - -def _wiki_page(content: str) -> dict[str, str]: - return {"slug": "releases/backend", "title": "backend", "format": "markdown", "content": content} - - -def _publish(tmp_path: pathlib.Path, content: str = "# Release scope\n") -> typing.Any: # noqa: ANN401 - page: typing.Final = tmp_path / "report.md" - page.write_text(content) - return _invoke("publish", str(page), "--project", "team/docs", "--page", "releases/backend") - - -@pytest.mark.usefixtures("cli_env") -def test_publish_updates_the_wiki_page(httpx2_mock: respx.Router, tmp_path: pathlib.Path) -> None: - httpx2_mock.get(_WIKI_PAGE).respond(json=_wiki_page("# Old\n")) - update: typing.Final = httpx2_mock.put(_WIKI_PAGE).respond(json=_wiki_page("# Release scope\n")) - - result: typing.Final = _publish(tmp_path) - - assert result.exit_code == 0, result.output - assert json.loads(update.calls.last.request.content) == {"content": "# Release scope\n"} - assert update.calls.last.request.headers["PRIVATE-TOKEN"] == "glpat-test" - assert f"Updated {ENDPOINT}/team/docs/-/wikis/releases/backend" in result.output - - -@pytest.mark.usefixtures("cli_env") -def test_publish_skips_an_unchanged_page(httpx2_mock: respx.Router, tmp_path: pathlib.Path) -> None: - httpx2_mock.get(_WIKI_PAGE).respond(json=_wiki_page("# Release scope\n")) - - result: typing.Final = _publish(tmp_path) - - assert result.exit_code == 0, result.output - assert [call.request.method for call in httpx2_mock.calls] == ["GET"] - assert f"Unchanged {ENDPOINT}/team/docs/-/wikis/releases/backend" in result.output - - -@pytest.mark.usefixtures("cli_env") -def test_publish_does_not_create_a_missing_page(httpx2_mock: respx.Router, tmp_path: pathlib.Path) -> None: - httpx2_mock.get(_WIKI_PAGE).respond(404) - - result: typing.Final = _publish(tmp_path) - - assert result.exit_code == 4 - assert ( - "Error: Wiki page 'releases/backend' does not exist in project 'team/docs', or the token cannot see it. " - "Create the page in GitLab first." in result.output - ) - - -@pytest.mark.parametrize(("method", "status", "code"), [("get", 401, 3), ("get", 403, 3), ("put", 403, 3)]) -@pytest.mark.usefixtures("cli_env") -def test_publish_explains_a_denied_token( - httpx2_mock: respx.Router, tmp_path: pathlib.Path, method: str, status: int, code: int -) -> None: - if method == "put": - httpx2_mock.get(_WIKI_PAGE).respond(json=_wiki_page("# Old\n")) - httpx2_mock.route(method=method.upper(), url=_WIKI_PAGE).respond(status) - - result: typing.Final = _publish(tmp_path) - - assert result.exit_code == code - if status == 401: - assert "Error: GitLab rejected the token (401)." in result.output - else: - assert ( - "Error: GitLab denied access to the wiki of project 'team/docs' (403). Check that the token has " - "the 'api' scope and that its user has at least the Developer role there." in result.output - ) - - -@pytest.mark.parametrize(("method", "size"), [("get", ""), ("put", " The page is 20 bytes.")]) -@pytest.mark.usefixtures("cli_env") -def test_publish_reports_a_failed_request( - httpx2_mock: respx.Router, tmp_path: pathlib.Path, method: str, size: str -) -> None: - if method == "put": - httpx2_mock.get(_WIKI_PAGE).respond(json=_wiki_page("# Old\n")) - httpx2_mock.route(method=method.upper(), url=_WIKI_PAGE).respond(400) - - result: typing.Final = _publish(tmp_path, "é" * 10) - - assert result.exit_code == 4 - assert f"Error: GitLab returned 400 for /api/v4/projects/team/docs/wikis/releases/backend.{size}\n" in result.output - - -@pytest.mark.usefixtures("cli_env") -def test_publish_rejects_an_unreadable_page(httpx2_mock: respx.Router, tmp_path: pathlib.Path) -> None: - page: typing.Final = tmp_path / "report.md" - - result: typing.Final = _invoke("publish", str(page), "-p", "team/docs", "--page", "releases/backend") - - assert result.exit_code == 2 - assert f"Error: Cannot read page {page}: FileNotFoundError." in result.output - assert not httpx2_mock.calls - - -def test_publish_needs_a_token(tmp_path: pathlib.Path) -> None: - result: typing.Final = _publish(tmp_path) - - assert result.exit_code == 2 - assert "GitLab token is missing" in result.output diff --git a/tests/test_render.py b/tests/test_render.py deleted file mode 100644 index 4d66b41..0000000 --- a/tests/test_render.py +++ /dev/null @@ -1,456 +0,0 @@ -import datetime -import typing - -from release_scope._render import render_markdown -from release_scope._report import ( - CommitRef, - EnvironmentState, - FailedJob, - JiraIssue, - JiraKeyRef, - JiraState, - LinkedChange, - MergeRequestRef, - PipelineState, - Release, - Report, - Row, - Service, - TagRef, -) - - -_COLLECTED_AT: typing.Final = datetime.datetime(2026, 9, 29, 10, 15, tzinfo=datetime.UTC) -_WARNING: typing.Final = ( - "Environments are disabled, so it has no deployments. Enable them at " - "https://g.test/acme/utils/edit#js-shared-permissions → Visibility, project features, permissions → Environments." -) - - -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, tag=tag - ) - - -def _job(name: str, *, allow_failure: bool = False, downstream: str | None = None) -> FailedJob: - return FailedJob( - kind="bridge" if downstream else "job", - name=name, - stage="test", - status="failed", - allow_failure=allow_failure, - url=f"https://g.test/j/{name}", - failure_reason="script_failure", - downstream_pipeline_url=downstream, - ) - - -def _pipeline(pipeline_id: int, status: str, *jobs: FailedJob) -> PipelineState: - return PipelineState(id=pipeline_id, status=status, url=f"https://g.test/p/{pipeline_id}", failed_jobs=list(jobs)) - - -def _merge_request(iid: int, title: str, author: str | None) -> MergeRequestRef: - return MergeRequestRef( - iid=iid, title=title, url=f"https://g.test/mr/{iid}", author=author, merged_at="2026-09-26T00:00:00Z" - ) - - -def _commit(sha: str, title: str) -> CommitRef: - return CommitRef( - sha=sha, - short_sha=sha[:7], - title=title, - url=f"https://g.test/c/{sha}", - author="J. Doe", - committed_at=_COLLECTED_AT, - ) - - -def _report(*services: Service, jira: JiraState | None = None) -> Report: - return Report(collected_at=_COLLECTED_AT, production_environment="prod", services=list(services), jira=jira) - - -def _issue(key: str, summary: str, status: str, category: str | None, *links: LinkedChange) -> JiraIssue: - return JiraIssue( - key=key, summary=summary, status=status, status_category=category, issue_type="Task", links=list(links) - ) - - -def _linked(project: str, iid: int) -> LinkedChange: - return LinkedChange( - kind="merge_request", - project=project, - project_url=f"https://g.test/{project}", - url=f"https://g.test/{project}/-/merge_requests/{iid}", - iid=iid, - ) - - -_JIRA: typing.Final = JiraState( - issues={ - "SHOP-140": _issue( - "SHOP-140", - "Refund | endpoint", - "In Progress", - "indeterminate", - _linked("acme/web", 7), - _linked("acme/api", 311), - _linked("acme/web", 8), - LinkedChange( - kind="commit", - project="acme/worker", - project_url="https://g.test/acme/worker", - url="https://g.test/acme/worker/-/commit/abc", - sha="abc", - ), - ), - "SHOP-9": _issue("SHOP-9", "Typo", "Done", "done"), - }, - missing=["OPS-1"], -) - - -_API: typing.Final = Service( - project="acme/api", - project_url="https://g.test/acme/api", - default_branch="main", - environments=[_environment("prod", "2.3.0", "https://g.test/d1", tag=True), _environment("preview", "2.4.0")], - rows=[ - Row( - kind="commit", - tags=[], - merge_requests=[], - commits=[_commit("9ac01f2aaaa", "fix | typo")], - jira_keys=[JiraKeyRef(key="SHOP-9", url="https://j.test/browse/SHOP-9")], - environments=[], - main_pipeline=_pipeline(5130, "running"), - ), - Row( - kind="merge_request", - tags=[ - TagRef( - name="2.4.0", - url="https://g.test/t/2.4.0", - pipeline=_pipeline(5120, "failed", _job("smoke")), - ) - ], - merge_requests=[ - _merge_request(311, "SHOP-140 [refund] endpoint", "jdoe"), - _merge_request(312, "Second\nline", None), - ], - commits=[_commit("b72e41daaaa", "Merge branch 'feature/SHOP-140'")], - jira_keys=[ - JiraKeyRef(key="SHOP-140", url="https://j.test/browse/SHOP-140"), - JiraKeyRef(key="OPS-1", url=None), - ], - environments=["preview"], - main_pipeline=_pipeline( - 5118, - "failed", - _job("lint", allow_failure=True), - _job("appsec", downstream="https://g.test/p/9"), - ), - ), - Row( - kind="merge_request", - tags=[TagRef(name="2.3.2", url="https://g.test/t/2.3.2", pipeline=None)], - merge_requests=[_merge_request(305, "Old change", "asmith")], - commits=[_commit("c0ffee0aaaa", "Old change")], - jira_keys=[], - environments=[], - main_pipeline=None, - ), - ], -) -_BILLING: typing.Final = Service( - project="acme/billing", - project_url="https://g.test/acme/billing", - default_branch="main", - environments=[_environment("prod", "1.8.1", "https://g.test/d2")], -) -_BROKEN: typing.Final = Service( - project="acme/broken", - project_url="https://g.test/acme/broken", - error=( - "acme/broken: GitLab denied access to deployments (403). Check that:\n" - "- Environments are enabled: https://g.test/acme/broken/edit\n" - "- the token's user has a role that can read them: https://g.test/acme/broken/-/project_members" - ), -) -_UTILS: typing.Final = Service(project="acme/utils", project_url="https://g.test/acme/utils", warnings=[_WARNING]) - - -def test_page_lists_attention_first_and_collapses_up_to_date_services() -> None: - page: typing.Final = render_markdown(_report(_API, _BILLING, _BROKEN, _UTILS, jira=_JIRA)) - - assert page.splitlines() == [ - "# Release scope", - "", - "Collected 2026-09-29 10:15 UTC. Changes run from the commit on `prod` to the head of the default branch.", - "", - "Legend: ✅ success · ❌ failed · 🔄 running · ⏭ canceled or skipped · ⚠️ warning or allowed failure", - "", - "| 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 | [2.3.0...2.4.0](https://g.test/acme/api/-/compare/2.3.0...2.4.0) " - "| 1 not done | ❌ 2 · ⚠️ 1 allowed |" - ), - "", - "
", - "1 service up to date", - "", - "| Service | prod |", - "|---|---|", - "| [acme/billing](https://g.test/acme/billing) | [1.8.1](https://g.test/d2) |", - "", - "
", - "", - "## acme/broken", - "", - "❌ acme/broken: GitLab denied access to deployments (403). Check that:", - "- Environments are enabled: https://g.test/acme/broken/edit", - "- the token's user has a role that can read them: https://g.test/acme/broken/-/project_members", - "", - "## acme/utils", - "", - f"⚠️ {_WARNING}", - "", - "## acme/api", - "", - "prod [2.3.0](https://g.test/d1) · preview 2.4.0 · 3 merge requests, 1 direct commit", - "", - "
", - "3 changes since 2.3.0", - "", - "| Tag | Change | Jira | Related services | Deployed to | Failed jobs |", - "|---|---|---|---|---|---|", - ( - "| | [`9ac01f2`](https://g.test/c/9ac01f2aaaa) fix \\| <b>typo</b> · J. Doe " - "| [SHOP-9](https://j.test/browse/SHOP-9) Typo · Done | | | main 🔄 [5130](https://g.test/p/5130) |" - ), - ( - "| [2.4.0](https://g.test/p/5120) ❌ " - "| [!311](https://g.test/mr/311) SHOP-140 \\[refund\\] endpoint · @jdoe" - "
[!312](https://g.test/mr/312) Second
line " - "| [SHOP-140](https://j.test/browse/SHOP-140) Refund \\| endpoint · In Progress
OPS-1 " - "| [acme/web](https://g.test/acme/web), [acme/worker](https://g.test/acme/worker) " - "| preview " - "| main ❌ [5118](https://g.test/p/5118): [lint](https://g.test/j/lint) (allowed), " - "[appsec](https://g.test/j/appsec) → [child](https://g.test/p/9)" - "
tag 2.4.0: [smoke](https://g.test/j/smoke) |" - ), - ( - "| [2.3.2](https://g.test/t/2.3.2) ⚠️ no pipeline " - "| [!305](https://g.test/mr/305) Old change · @asmith | | | | |" - ), - "", - "
", - ] - - -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 | Compare | Failed jobs |" not in page - assert "1 service up to date" in page - - -def test_empty_report_says_nothing_was_collected() -> None: - assert render_markdown(_report()).splitlines()[-1] == "No services were collected." - - -def test_services_up_to_date_are_counted_in_plural() -> None: - other: typing.Final = _BILLING.model_copy(update={"project": "acme/zeta"}) - - assert "2 services up to date" in render_markdown(_report(_BILLING, other)) - - -def test_service_with_changes_keeps_its_warnings_above_the_table() -> None: - warned: typing.Final = _API.model_copy(update={"warnings": ["Stopped after 3 commits; older changes are omitted."]}) - - lines: typing.Final = render_markdown(_report(warned)).splitlines() - - assert lines.index("⚠️ Stopped after 3 commits; older changes are omitted.") < lines.index("
") - - -def test_summary_counts_allowed_failures_apart_from_blocking_ones() -> None: - allowed_only: typing.Final = _API.model_copy( - update={ - "rows": [ - _API.rows[0].model_copy( - update={"main_pipeline": _pipeline(5130, "success", _job("lint", allow_failure=True))} - ) - ] - } - ) - - assert "| 1 change · untagged head | | ⚠️ 1 allowed |" in render_markdown(_report(allowed_only)) - - -def test_link_targets_cannot_break_out_of_markdown() -> None: - odd: typing.Final = _BILLING.model_copy( - update={"project": "acme/odd", "project_url": "https://g.test/a b)c", "rows": _API.rows[:1]} - ) - - assert "[acme/odd](https://g.test/a%20b%29c)" in render_markdown(_report(odd)) - - -def test_single_change_and_single_merge_request_are_singular() -> None: - single: typing.Final = _API.model_copy(update={"rows": _API.rows[2:]}) - - page: typing.Final = render_markdown(_report(single)) - - assert "1 change since 2.3.0" in page - assert "prod [2.3.0](https://g.test/d1) · preview 2.4.0 · 1 merge request" in page - assert "| 1 change |" in page - - -def test_service_with_rows_but_no_production_environment_still_renders() -> None: - orphan: typing.Final = _API.model_copy(update={"environments": [_environment("preview", "2.4.0")]}) - - page: typing.Final = render_markdown(_report(orphan)) - - assert "3 changes" 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: - piped: typing.Final = _BILLING.model_copy( - update={"project": "acme/piped", "project_url": "https://g.test/a|b", "rows": _API.rows[:1]} - ) - - assert "[acme/piped](https://g.test/a%7Cb)" in render_markdown(_report(piped)) - - -def test_section_lists_production_first() -> None: - reordered: typing.Final = _API.model_copy(update={"environments": list(reversed(_API.environments))}) - - assert "prod [2.3.0](https://g.test/d1) · preview 2.4.0 · " in render_markdown(_report(reordered)) - - -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 | Compare | Failed jobs |" in page - assert "| Tag | Change | Jira | Deployed to | Failed jobs |" in page - assert "| [SHOP-140](https://j.test/browse/SHOP-140)
OPS-1 |" in page - - -def test_jira_failure_is_shown_under_the_legend() -> None: - lines: typing.Final = render_markdown( - _report(_API, jira=JiraState(error="Jira rejected the token (401).")), - ).splitlines() - - assert lines[4].startswith("Legend:") - assert lines[6] == "❌ Jira rejected the token (401)." - - -def _scoped(*services: Service) -> Report: - jira: typing.Final = JiraState( - issues={ - "SHOP-140": _JIRA.issues["SHOP-140"].model_copy(update={"url": "https://j.test/browse/SHOP-140"}), - "SHOP-9": _JIRA.issues["SHOP-9"], - }, - missing=["SHOP-404"], - ) - return _report(*services, jira=jira).model_copy(update={"jira_scope": ["SHOP-140", "SHOP-404"]}) - - -_TAG: typing.Final = TagRef(name="2.4.0", url="https://g.test/t/2.4.0", pipeline=_pipeline(5120, "success")) -_SCOPED_API: typing.Final = _API.model_copy( - update={ - "rows": [_API.rows[1].model_copy(update={"linked": True}), _API.rows[2]], - "release": Release(state="pending", tag=_TAG), - } -) - - -def test_scoped_page_names_the_issues_and_the_release_tag() -> None: - lines: typing.Final = render_markdown(_scoped(_SCOPED_API)).splitlines() - - assert lines[0] == "# Release scope: SHOP-140, SHOP-404" - assert lines[2].endswith("Changes run from the commit on `prod` to the latest change linked to the issues.") - assert lines[4:6] == [ - "- [SHOP-140](https://j.test/browse/SHOP-140) Refund \\| endpoint · In Progress", - "- 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) ✅ " - "| [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)) - - -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: - waiting: typing.Final = _BILLING.model_copy( - update={ - "release": Release( - state="not_merged", - pending_merge_requests=[ - MergeRequestRef(iid=7, title="WIP", url="https://g.test/mr/7", author="jdoe", merged_at=None) - ], - ) - } - ) - lost: typing.Final = _BILLING.model_copy(update={"project": "acme/lost", "release": Release(state="not_found")}) - - 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 | | | |" - in page - ) - assert "⏳ Not merged: [!7](https://g.test/mr/7) WIP · @jdoe" in page.splitlines() - - -def test_scoped_service_already_in_production_is_up_to_date() -> None: - shipped: typing.Final = _BILLING.model_copy(update={"release": Release(state="in_production")}) - - assert "1 service up to date" in render_markdown(_scoped(shipped)) From 78237f626628f22e99cf42784c6ad07beaba7631 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 6 Oct 2026 13:46:06 +0300 Subject: [PATCH 02/10] Add release candidates to the report and keep rows above the target out of scope --- CONTEXT.md | 10 ++++++++-- release_scope/_candidates.py | 21 +++++++++++++++++++++ release_scope/_report.py | 13 +++++++++++-- release_scope/_use_case.py | 23 +++++++++++++++-------- tests/test_cli.py | 2 +- tests/test_scope.py | 25 +++++++++++++++++++++---- tests/test_use_case.py | 25 +++++++++++++++++++++++++ 7 files changed, 102 insertions(+), 17 deletions(-) create mode 100644 release_scope/_candidates.py diff --git a/CONTEXT.md b/CONTEXT.md index 2c35160..4571eab 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -39,11 +39,17 @@ the row belongs to. It need not be in the report. The Jira issues a `--jira` run was given. Its services are the projects those issues link to. **Target**: -In an issue scope, the newest row on the default branch whose merge request or commit an issue links to. Rows run -from the production baseline to the target. +In an issue scope, the newest row on the default branch whose merge request or commit an issue links to. Rows from +the production baseline to the target are in scope; rows above it are kept, out of scope, so their tags can still be +picked. **Release tag**: The nearest tag at or above the target: the earliest tag that ships it. Absent when a new tag is needed. +**Candidate**: +A tag in the range that a release could ship. It carries what shipping it means: the rows from its own down to the +production baseline, the Jira keys of those rows that are in scope, its tag pipeline, and the compare link from +production. + **Settled fact**: Data GitLab will not change for the same key, and so the only data the cache may hold. diff --git a/release_scope/_candidates.py b/release_scope/_candidates.py new file mode 100644 index 0000000..709affc --- /dev/null +++ b/release_scope/_candidates.py @@ -0,0 +1,21 @@ +from urllib.parse import quote + +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) + 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)}", + changes=len(shipped), + jira_keys=list(keys.values()), + ) + for tag in row.tags + ) + return candidates diff --git a/release_scope/_report.py b/release_scope/_report.py index 8ca0233..9c6fd00 100644 --- a/release_scope/_report.py +++ b/release_scope/_report.py @@ -4,7 +4,7 @@ import pydantic -SCHEMA_VERSION: typing.Final = 2 +SCHEMA_VERSION: typing.Final = 3 class FailedJob(pydantic.BaseModel): @@ -62,6 +62,7 @@ class Row(pydantic.BaseModel): environments: list[str] main_pipeline: PipelineState | None linked: bool = False + in_scope: bool = True class EnvironmentState(pydantic.BaseModel): @@ -79,12 +80,20 @@ class Release(pydantic.BaseModel): pending_merge_requests: list[MergeRequestRef] = pydantic.Field(default_factory=list) +class Candidate(pydantic.BaseModel): + tag: TagRef + compare_url: str + changes: int + jira_keys: list[JiraKeyRef] + + class Service(pydantic.BaseModel): project: str project_url: str default_branch: str | None = None environments: list[EnvironmentState] = pydantic.Field(default_factory=list) rows: list[Row] = pydantic.Field(default_factory=list) + candidates: list[Candidate] = pydantic.Field(default_factory=list) warnings: list[str] = pydantic.Field(default_factory=list) error: str | None = None release: Release | None = None @@ -116,7 +125,7 @@ class JiraState(pydantic.BaseModel): class Report(pydantic.BaseModel): - schema_version: typing.Literal[2] = SCHEMA_VERSION + schema_version: typing.Literal[3] = SCHEMA_VERSION collected_at: datetime.datetime production_environment: str services: list[Service] diff --git a/release_scope/_use_case.py b/release_scope/_use_case.py index cd55436..004e09b 100644 --- a/release_scope/_use_case.py +++ b/release_scope/_use_case.py @@ -6,6 +6,7 @@ from urllib.parse import quote from release_scope._cache import Cache, CachedPipeline +from release_scope._candidates import build_candidates from release_scope._errors import AuthError, ConfigError, GitLabError, JiraError from release_scope._gitlab import Commit, Deployment, GitLabApi, MergeRequest, Pipeline, Project from release_scope._jira import JiraApi @@ -78,7 +79,7 @@ def _commit_ref(commit: Commit) -> CommitRef: def _row_keys(services: list[Service]) -> list[str]: - return sorted({key.key for service in services for row in service.rows for key in row.jira_keys}) + return sorted({key.key for service in services for row in service.rows if row.in_scope for key in row.jira_keys}) @dataclasses.dataclass(slots=True, kw_only=True) @@ -254,9 +255,9 @@ def _collect_service(self, project: Project, cache: Cache, *, links: list[Linked return service walk: typing.Final = self._walk(project, project.default_branch, production.sha, service, cache) - drafts, linked = walk.drafts, [False] * len(walk.drafts) + drafts, linked, in_scope = walk.drafts, [False] * len(walk.drafts), [True] * len(walk.drafts) if links is not None: - drafts, linked = self._scope_rows(project, project.default_branch, service, walk, links, cache) + drafts, linked, in_scope = self._scope_rows(project, project.default_branch, service, walk, links, cache) service.rows.extend( self._build_row( project=project, @@ -265,9 +266,10 @@ def _collect_service(self, project: Project, cache: Cache, *, links: list[Linked main_pipelines=walk.main_pipelines, environments=service.environments, cache=cache, - ).model_copy(update={"linked": is_linked}) - for draft, is_linked in zip(drafts, linked, strict=True) + ).model_copy(update={"linked": is_linked, "in_scope": is_in_scope}) + for draft, is_linked, is_in_scope in zip(drafts, linked, in_scope, strict=True) ) + service.candidates.extend(build_candidates(service, production)) return service def _walk(self, project: Project, default_branch: str, baseline: str, service: Service, cache: Cache) -> _Walk: @@ -299,15 +301,20 @@ def _scope_rows( # noqa: PLR0913, PLR0917 walk: _Walk, links: list[LinkedChange], cache: Cache, - ) -> tuple[list[RowDraft], list[bool]]: + ) -> tuple[list[RowDraft], list[bool], list[bool]]: target: typing.Final = self._link_target(project, default_branch, links, cache) service.warnings.extend(target.warnings) index: typing.Final = next( (position for position, draft in enumerate(walk.drafts) if target.matches(draft)), None ) service.release = self._release(project, target, walk, index, cache) - drafts: typing.Final = walk.drafts[index:] if index is not None else [] - return drafts, [target.matches(draft) for draft in drafts] + if index is None: + return [], [], [] + return ( + walk.drafts, + [target.matches(draft) for draft in walk.drafts], + [position >= index for position in range(len(walk.drafts))], + ) def _link_target( self, project: Project, default_branch: str, links: list[LinkedChange], cache: Cache diff --git a/tests/test_cli.py b/tests/test_cli.py index 7af6eea..f645f43 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -43,7 +43,7 @@ def test_collect_writes_report_and_cache(gitlab: respx.Router, tmp_path: pathlib assert first.exit_code == 0, first.output report: typing.Final = json.loads(output.read_text()) - assert report["schema_version"] == 2 + assert report["schema_version"] == 3 assert report["jira"] is None assert report["production_environment"] == "production" assert [len(item["rows"]) for item in report["services"]] == [5] diff --git a/tests/test_scope.py b/tests/test_scope.py index bbe9c32..4223e88 100644 --- a/tests/test_scope.py +++ b/tests/test_scope.py @@ -16,6 +16,7 @@ ENDPOINT, JIRA_ENDPOINT, SERVICE_API, + TAGS, commit, merge_request, remote_link, @@ -58,17 +59,32 @@ def test_scope_collects_every_service_the_issues_link_to() -> None: @pytest.mark.httpx2(assert_all_called=False) @pytest.mark.usefixtures("scoped") -def test_rows_run_from_production_to_the_latest_linked_change() -> None: +def test_rows_above_the_latest_linked_change_are_kept_out_of_scope() -> None: service: typing.Final = _service(_scope()) - assert [[item.iid for item in row.merge_requests] for row in service.rows] == [[12], [11], [10], [9]] - assert [row.linked for row in service.rows] == [True, False, False, False] + assert [[item.iid for item in row.merge_requests] for row in service.rows] == [[], [12], [11], [10], [9]] + assert [row.linked for row in service.rows] == [False, True, False, False, False] + assert [row.in_scope for row in service.rows] == [False, True, True, True, True] assert service.release is not None assert service.release.state == "pending" assert service.release.tag is not None assert service.release.tag.name == "1.2.0" +@pytest.mark.httpx2(assert_all_called=False) +def test_tag_above_the_target_is_a_candidate_without_out_of_scope_issues(scoped: respx.Router) -> None: + scoped["tags"].respond(json=[{"name": "1.3.0", "commit": {"id": "head"}}, *TAGS]) + scoped.get(f"{SERVICE_API}/pipelines", params={"ref": "1.3.0"}).respond(json=[]) + + candidates: typing.Final = _service(_scope()).candidates + + assert [(item.tag.name, item.changes, [key.key for key in item.jira_keys]) for item in candidates] == [ + ("1.3.0", 5, ["SHOP-12", "SHOP-13"]), + ("1.2.0", 4, ["SHOP-12", "SHOP-13"]), + ("1.1.0", 2, []), + ] + + @pytest.mark.httpx2(assert_all_called=False) def test_release_tag_is_the_nearest_tag_above_an_untagged_target(scoped: respx.Router) -> None: scoped["remote_links:SHOP-12"].respond(json=_links(f"{ENDPOINT}/team/svc/-/merge_requests/11")) @@ -78,7 +94,8 @@ def test_release_tag_is_the_nearest_tag_above_an_untagged_target(scoped: respx.R service: typing.Final = _service(_scope()) - assert [[item.iid for item in row.merge_requests] for row in service.rows] == [[11], [10], [9]] + assert [[item.iid for item in row.merge_requests] for row in service.rows] == [[], [12], [11], [10], [9]] + assert [row.in_scope for row in service.rows] == [False, False, True, True, True] assert service.release is not None assert service.release.tag is not None assert service.release.tag.name == "1.2.0" diff --git a/tests/test_use_case.py b/tests/test_use_case.py index f944f16..1cb3a55 100644 --- a/tests/test_use_case.py +++ b/tests/test_use_case.py @@ -22,6 +22,7 @@ JIRA_ENDPOINT, JIRA_ISSUE_API, JIRA_ISSUES, + PRODUCTION_DEPLOYMENT, PUSH_PIPELINES, SERVICE, commit, @@ -103,6 +104,30 @@ def test_tags_carry_their_latest_pipeline() -> None: assert untagged_pipeline.pipeline is None +@pytest.mark.usefixtures("gitlab") +def test_each_tag_in_the_range_is_a_candidate_carrying_everything_down_to_production() -> None: + candidates: typing.Final = _only_service(_collect()).candidates + + assert [(item.tag.name, item.changes, [key.key for key in item.jira_keys]) for item in candidates] == [ + ("1.2.0", 4, ["SHOP-12", "SHOP-13"]), + ("1.1.0", 2, []), + ] + assert [item.compare_url for item in candidates] == [ + f"{ENDPOINT}/team/svc/-/compare/1.0.0...1.2.0", + f"{ENDPOINT}/team/svc/-/compare/1.0.0...1.1.0", + ] + assert candidates[0].tag.pipeline is not None + assert candidates[0].tag.pipeline.id == 201 + + +def test_candidate_compares_from_the_production_commit_when_production_runs_a_branch(gitlab: respx.Router) -> None: + gitlab["deploy:production"].respond(json=[{**PRODUCTION_DEPLOYMENT, "ref": "main", "deployable": None}]) + + candidates: typing.Final = _only_service(_collect()).candidates + + assert candidates[0].compare_url == f"{ENDPOINT}/team/svc/-/compare/prod...1.2.0" + + @pytest.mark.usefixtures("gitlab") def test_main_pipeline_is_the_latest_push_pipeline_with_its_failed_jobs() -> None: rows: typing.Final = _only_service(_collect()).rows From a8f1f2444b5fc98155422d2a2939f4bf34fb5190 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 6 Oct 2026 13:49:50 +0300 Subject: [PATCH 03/10] Write a static site with the report from collect --- release_scope/__main__.py | 10 +- release_scope/_files.py | 11 + release_scope/_static/alpine.LICENSE.txt | 21 ++ release_scope/_static/alpine.min.js | 21 ++ release_scope/_static/index.html | 435 +++++++++++++++++++++++ tests/test_cli.py | 49 +-- 6 files changed, 523 insertions(+), 24 deletions(-) create mode 100644 release_scope/_static/alpine.LICENSE.txt create mode 100644 release_scope/_static/alpine.min.js create mode 100644 release_scope/_static/index.html diff --git a/release_scope/__main__.py b/release_scope/__main__.py index 44b7285..9ef3611 100644 --- a/release_scope/__main__.py +++ b/release_scope/__main__.py @@ -9,7 +9,7 @@ from release_scope import ioc from release_scope._cache import Cache from release_scope._errors import ConfigError, ReleaseScopeError -from release_scope._files import write_text_atomic +from release_scope._files import write_site from release_scope._jira_keys import JIRA_KEY_PATTERN from release_scope._report import Report from release_scope._use_case import CollectUseCase @@ -60,12 +60,14 @@ def wrapper(*args: _P.args, **kwargs: _P.kwargs) -> None: return wrapper -@MAIN_APP.command("collect", help="Collect pending changes per service into a JSON report.") +@MAIN_APP.command("collect", help="Collect pending changes per service into a static site with a JSON report.") @_exit_on_error @modern_di_typer.inject def _collect_command( # noqa: PLR0913, PLR0917 use_case: typing.Annotated[CollectUseCase, modern_di_typer.FromDI(CollectUseCase)], - output: typing.Annotated[pathlib.Path, typer.Option("--output", "-o", help="Where to write the report JSON.")], + output: typing.Annotated[ + pathlib.Path, typer.Option("--output", "-o", help="Directory for the site: index.html and report.json.") + ], group: typing.Annotated[ list[str] | None, typer.Option("--group", "-g", help="GitLab group path; repeatable.") ] = None, @@ -92,7 +94,7 @@ def _collect_command( # noqa: PLR0913, PLR0917 report = use_case.for_issues(keys=list(dict.fromkeys(jira)), cache=cache) else: report = use_case(groups=group or [], projects=project or [], include_subgroups=include_subgroups, cache=cache) - write_text_atomic(output, report.model_dump_json(indent=2)) + write_site(output, report.model_dump_json(indent=2)) if cache_path: cache.save(cache_path) failed: typing.Final = [service for service in report.services if service.error] diff --git a/release_scope/_files.py b/release_scope/_files.py index 6eedb81..6976a05 100644 --- a/release_scope/_files.py +++ b/release_scope/_files.py @@ -1,6 +1,11 @@ +import importlib.resources import os import pathlib import tempfile +import typing + + +_STATIC: typing.Final = importlib.resources.files("release_scope") / "_static" def write_text_atomic(path: pathlib.Path, text: str) -> None: @@ -12,3 +17,9 @@ def write_text_atomic(path: pathlib.Path, text: str) -> None: handle.flush() os.fsync(handle.fileno()) pathlib.Path(handle.name).replace(path) + + +def write_site(directory: pathlib.Path, report_json: str) -> None: + for item in _STATIC.iterdir(): + write_text_atomic(directory / item.name, item.read_text(encoding="utf-8")) + write_text_atomic(directory / "report.json", report_json) diff --git a/release_scope/_static/alpine.LICENSE.txt b/release_scope/_static/alpine.LICENSE.txt new file mode 100644 index 0000000..5e2d12a --- /dev/null +++ b/release_scope/_static/alpine.LICENSE.txt @@ -0,0 +1,21 @@ +# MIT License + +Copyright © 2019-2025 Caleb Porzio and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/release_scope/_static/alpine.min.js b/release_scope/_static/alpine.min.js new file mode 100644 index 0000000..02a9087 --- /dev/null +++ b/release_scope/_static/alpine.min.js @@ -0,0 +1,21 @@ +(()=>{var bt=!1,Et=!1,B=[],vt=-1,Fe=!1,wt=!1;function gr(e){ai(e)}function xr(){wt=!0}function yr(){wt=!1,br()}function ai(e){B.includes(e)||(B.push(e),e._x_schedulerPriority!==void 0&&(Fe=!0)),br()}function ue(e){let t=B.indexOf(e);t!==-1&&t>vt&&B.splice(t,1)}function br(){if(!Et&&!bt){if(wt)return;bt=!0,queueMicrotask(ci)}}function ci(){bt=!1,Et=!0;for(let e=0;efi(n,i,t));for(let n=0;ne.effect(t,{scheduler:r=>{St?gr(r):r()}}),At=e.raw}function Tt(e){k=e}function wr(e){let t=()=>{};return[(n,i)=>{let o=i?.priority==="structural"?ui++:void 0,s=k(n);return o!==void 0&&s!==void 0&&(s._x_schedulerPriority={el:e,order:o}),e._x_effects||(e._x_effects=new Set,e._x_runEffects=()=>{e._x_effects.forEach(a=>a())}),e._x_effects.add(s),t=()=>{s!==void 0&&(e._x_effects.delete(s),H(s))},s},()=>{t()}]}function Ve(e,t){let r=!0,n,i,o=k(()=>{let s=e(),a=JSON.stringify(s);if(!r&&(typeof s=="object"||s!==n)){let c=typeof n=="object"?JSON.parse(i):n;queueMicrotask(()=>{t(s,c)})}n=s,i=a,r=!1});return()=>{ue(o),H(o)}}async function Sr(e){xr();try{await e(),await Promise.resolve()}finally{yr()}}var Ar=[],Tr=[],Or=[];function Nr(e){Or.push(e)}function de(e,t){typeof t=="function"?(e._x_cleanups||(e._x_cleanups=[]),e._x_cleanups.push(t)):(t=e,Tr.push(t))}function Be(e){Ar.push(e)}function He(e,t,r){e._x_attributeCleanups||(e._x_attributeCleanups={}),e._x_attributeCleanups[t]||(e._x_attributeCleanups[t]=[]),e._x_attributeCleanups[t].push(r)}function Ot(e,t){e._x_attributeCleanups&&Object.entries(e._x_attributeCleanups).forEach(([r,n])=>{(t===void 0||t.includes(r))&&(n.forEach(i=>i()),delete e._x_attributeCleanups[r])})}function Cr(e){for(e._x_effects?.forEach(ue);e._x_cleanups?.length;)e._x_cleanups.pop()()}var Ue=new MutationObserver(qe),Nt=!1;function we(){Ue.observe(document,{subtree:!0,childList:!0,attributes:!0,attributeOldValue:!0}),Nt=!0}function Ct(){di(),Ue.disconnect(),Nt=!1}var Z=[];function di(){let e=Ue.takeRecords();Z.push(()=>e.length>0&&qe(e));let t=Z.length;queueMicrotask(()=>{if(Z.length===t)for(;Z.length>0;)Z.shift()()})}function Rr(){for(;Z.length>0;)Z.shift()();let e=Ue.takeRecords();e.length>0&&qe(e)}function h(e){if(!Nt)return e();Ct();try{return e()}finally{we()}}var Rt=!1,$e=[];function Dr(){Rt=!0}function Mr(){Rt=!1,qe($e),$e=[]}function qe(e){if(Rt){$e=$e.concat(e);return}let t=[],r=new Set,n=new Map,i=new Map;for(let o=0;o{s.nodeType===1&&s._x_marker&&r.add(s)}),e[o].addedNodes.forEach(s=>{if(s.nodeType===1){if(r.has(s)){r.delete(s);return}s._x_marker||t.push(s)}})),e[o].type==="attributes")){let s=e[o].target,a=e[o].attributeName,c=e[o].oldValue,l=()=>{n.has(s)||n.set(s,[]),n.get(s).push({name:a,value:s.getAttribute(a)})},f=()=>{i.has(s)||i.set(s,[]),i.get(s).push(a)};s.hasAttribute(a)&&c===null?l():s.hasAttribute(a)?(f(),l()):f()}i.forEach((o,s)=>{Ot(s,o)}),n.forEach((o,s)=>{Ar.forEach(a=>a(s,o))});for(let o of r)t.some(s=>s.contains(o))||Tr.forEach(s=>s(o));for(let o of t)o.isConnected&&Or.forEach(s=>s(o));t=null,r=null,n=null,i=null}function ze(e){return I(U(e))}function P(e,t,r){return e._x_dataStack=[t,...U(r||e)],()=>{e._x_dataStack=e._x_dataStack.filter(n=>n!==t)}}function U(e){return e._x_dataStack?e._x_dataStack:typeof ShadowRoot=="function"&&e instanceof ShadowRoot?U(e.host):e.parentNode?U(e.parentNode):[]}function I(e){return new Proxy({objects:e},pi)}function kr(e,t){return e===null||e===Object.prototype?null:Object.prototype.hasOwnProperty.call(e,t)?e:kr(Object.getPrototypeOf(e),t)}var pi={ownKeys({objects:e}){return Array.from(new Set(e.flatMap(t=>Object.keys(t))))},has({objects:e},t){return t==Symbol.unscopables?!1:e.some(r=>Object.prototype.hasOwnProperty.call(r,t)||Reflect.has(r,t))},get({objects:e},t,r){return t=="toJSON"?hi:Reflect.get(e.find(n=>Reflect.has(n,t))||{},t,r)},set({objects:e},t,r,n){let i;for(let s of e)if(i=kr(s,t),i)break;i||(i=e[e.length-1]);let o=Object.getOwnPropertyDescriptor(i,t);return o?.set&&o?.get?o.set.call(n,r)||!0:Reflect.set(i,t,r)}};function hi(){return Reflect.ownKeys(this).reduce((t,r)=>(t[r]=Reflect.get(this,r),t),{})}function pe(e,t=()=>{}){let r=i=>typeof i=="object"&&!Array.isArray(i)&&i!==null,n=(i,o="")=>{Object.entries(Object.getOwnPropertyDescriptors(i)).forEach(([s,{value:a,enumerable:c}])=>{if(c===!1||a===void 0||typeof a=="object"&&a!==null&&a.__v_skip)return;let l=o===""?s:`${o}.${s}`;typeof a=="object"&&a!==null&&a._x_interceptor?i[s]=a.initialize(e,l,s,t):r(a)&&a!==i&&!(a instanceof Element)&&n(a,l)})};return n(e)}function Ke(e,t=()=>{}){let r={initialValue:void 0,_x_interceptor:!0,initialize(n,i,o,s){return e(this.initialValue,()=>mi(n,i),a=>Dt(n,i,a),i,o,s)}};return t(r),n=>{if(typeof n=="object"&&n!==null&&n._x_interceptor){let i=r.initialize.bind(r);r.initialize=(o,s,a,c)=>{let l=n.initialize(o,s,a,c);return r.initialValue=l,i(o,s,a,c)}}else r.initialValue=n;return r}}function mi(e,t){return t.split(".").reduce((r,n)=>r[n],e)}function Dt(e,t,r){if(typeof t=="string"&&(t=t.split(".")),t.length===1)e[t[0]]=r;else{if(t.length===0)throw error;return e[t[0]]||(e[t[0]]={}),Dt(e[t[0]],t.slice(1),r)}}var Pr={};function b(e,t){Pr[e]=t}function G(e,t){let r=_i(t);return Object.entries(Pr).forEach(([n,i])=>{Object.defineProperty(e,`$${n}`,{get(){return i(t,r)},enumerable:!1})}),e}function _i(e){let[t,r]=Mt(e),n={interceptor:Ke,...t};return de(e,r),n}function Ir(e,t,r,...n){try{return r(...n)}catch(i){Y(i,e,t)}}function Y(...e){return Lr(...e)}var Lr=gi;function jr(e){Lr=e}function gi(e,t,r=void 0){e=Object.assign(e??{message:"No error message given."},{el:t,expression:r}),console.warn(`Alpine Expression Error: ${e.message} + +${r?'Expression: "'+r+`" + +`:""}`,t),setTimeout(()=>{throw e},0)}var he=!0;function We(e){let t=he;he=!1;let r=e();return he=t,r}function D(e,t,r={}){let n;return x(e,t)(i=>n=i,r),n}function x(...e){return Fr(...e)}var Fr=()=>{};function Vr(e){Fr=e}var $r;function Br(e){$r=e}function Hr(e,t){let r={};G(r,e);let n=[r,...U(e)],i=typeof t=="function"?xi(n,t):bi(n,t,e);return Ir.bind(null,e,t,i)}function xi(e,t){return(r=()=>{},{scope:n={},params:i=[],context:o}={})=>{if(!he){Se(r,t,I([n,...e]),i);return}let s=t.apply(I([n,...e]),i);Se(r,s)}}var kt={};function yi(e,t){if(kt[e])return kt[e];let r=Object.getPrototypeOf(async function(){}).constructor,n=/^[\n\s]*if.*\(.*\)/.test(e.trim())||/^(let|const)\s/.test(e.trim())?`(async()=>{ ${e} })()`:e,o=(()=>{try{let s=new r(["__self","scope"],`with (scope) { __self.result = ${n} }; __self.finished = true; return __self.result;`);return Object.defineProperty(s,"name",{value:`[Alpine] ${e}`}),s}catch(s){return Y(s,t,e),Promise.resolve()}})();return kt[e]=o,o}function bi(e,t,r){let n=yi(t,r);return(i=()=>{},{scope:o={},params:s=[],context:a}={})=>{n.result=void 0,n.finished=!1;let c=I([o,...e]);if(typeof n=="function"){let l=n.call(a,n,c).catch(f=>Y(f,r,t));n.finished?(Se(i,n.result,c,s,r),n.result=void 0):l.then(f=>{Se(i,f,c,s,r)}).catch(f=>Y(f,r,t)).finally(()=>n.result=void 0)}}}function Se(e,t,r,n,i){if(he&&typeof t=="function"){let o=t.apply(r,n);o instanceof Promise?o.then(s=>Se(e,s,r,n)).catch(s=>Y(s,i,t)):e(o)}else typeof t=="object"&&t instanceof Promise?t.then(o=>e(o)):e(t)}function Ur(...e){return $r(...e)}function qr(e,t,r={}){let n={};G(n,e);let i=[n,...U(e)],o=I([r.scope??{},...i]),s=r.params??[];if(t.includes("await")){let a=Object.getPrototypeOf(async function(){}).constructor,c=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(async()=>{ ${t} })()`:t;return new a(["scope"],`with (scope) { let __result = ${c}; return __result }`).call(r.context,o)}else{let a=/^[\n\s]*if.*\(.*\)/.test(t.trim())||/^(let|const)\s/.test(t.trim())?`(()=>{ ${t} })()`:t,l=new Function(["scope"],`with (scope) { let __result = ${a}; return __result }`).call(r.context,o);return typeof l=="function"&&he?l.apply(o,s):l}}var Lt="x-";function N(e=""){return Lt+e}function zr(e){Lt=e}var Ge={};function p(e,t){return Ge[e]=t,{before(r){if(!Ge[r]){console.warn(String.raw`Cannot find directive \`${r}\`. \`${e}\` will use the default order of execution`);return}let n=Q.indexOf(r);Q.splice(n>=0?n:Q.indexOf("DEFAULT"),0,e)}}}function Kr(e){return Object.keys(Ge).includes(e)}function ee(e,t,r){if(t=Array.from(t),e._x_virtualDirectives){let o=Object.entries(e._x_virtualDirectives).map(([a,c])=>({name:a,value:c})),s=jt(o);o=o.map(a=>s.find(c=>c.name===a.name)?{name:`x-bind:${a.name}`,value:`"${a.value}"`}:a),t=t.concat(o)}let n={};return t.map(Yr((o,s)=>n[o]=s)).filter(Xr).map(vi(n,r)).sort(wi).map(o=>Ei(e,o))}function jt(e){return Array.from(e).map(Yr()).filter(t=>!Xr(t))}var Pt=!1,Ae=new Map,Wr=Symbol();function Gr(e){Pt=!0;let t=Symbol();Wr=t,Ae.set(t,[]);let r=()=>{for(;Ae.get(t).length;)Ae.get(t).shift()();Ae.delete(t)},n=()=>{Pt=!1,r()};e(r),n()}function Mt(e){let t=[],r=a=>t.push(a),[n,i]=wr(e);return t.push(i),[{Alpine:q,effect:n,cleanup:r,evaluateLater:x.bind(x,e),evaluate:D.bind(D,e)},()=>t.forEach(a=>a())]}function Ei(e,t){let r=()=>{},n=Ge[t.type]||r,[i,o]=Mt(e);He(e,t.original,o);let s=()=>{e._x_ignore||e._x_ignoreSelf||(n.inline&&n.inline(e,t,i),n=n.bind(n,e,t,i),Pt?Ae.get(Wr).push(n):n())};return s.runCleanups=o,s}var Ye=(e,t)=>({name:r,value:n})=>(r.startsWith(e)&&(r=r.replace(e,t)),{name:r,value:n}),Je=e=>e;function Yr(e=()=>{}){return({name:t,value:r})=>{let{name:n,value:i}=Jr.reduce((o,s)=>s(o),{name:t,value:r});return n!==t&&e(n,t),{name:n,value:i}}}var Jr=[];function me(e){Jr.push(e)}function Xr({name:e}){return Zr().test(e)}var Zr=()=>new RegExp(`^${Lt}([^:^.]+)\\b`);function vi(e,t){return({name:r,value:n})=>{r===n&&(n="");let i=r.match(Zr()),o=r.match(/:([a-zA-Z0-9\-_:]+)/),s=r.match(/\.[^.\]]+(?=[^\]]*$)/g)||[],a=t||e[r]||r;return{type:i?i[1]:null,value:o?o[1]:null,modifiers:s.map(c=>c.replace(".","")),expression:n,original:a}}}var It="DEFAULT",Q=["ignore","ref","id","data","anchor","bind","init","for","model","modelable","transition","show","if",It,"teleport"];function wi(e,t){let r=Q.indexOf(e.type)===-1?It:e.type,n=Q.indexOf(t.type)===-1?It:t.type;return Q.indexOf(r)-Q.indexOf(n)}function L(e,t){if(typeof ShadowRoot=="function"&&e instanceof ShadowRoot){Array.from(e.children).forEach(i=>L(i,t));return}let r=!1;if(t(e,()=>r=!0),r)return;let n=e.firstElementChild;for(;n;)L(n,t,!1),n=n.nextElementSibling}var j=!1;function w(e,t=()=>{}){return(...r)=>j?t(...r):e(...r)}function Qr(e){return(...t)=>j&&e(...t)}var en=[];function F(e){en.push(e)}function tn(e,t){en.forEach(r=>r(e,t)),j=!0,nn(()=>{S(t,(r,n)=>{n(r,()=>{})})}),j=!1}var Xe=!1;function rn(e,t){t._x_dataStack||(t._x_dataStack=e._x_dataStack),j=!0,Xe=!0,nn(()=>{Si(t)}),j=!1,Xe=!1}function Si(e){let t=!1;S(e,(n,i)=>{L(n,(o,s)=>{if(t&&on(o))return s();t=!0,i(o,s)})})}function nn(e){let t=k;Tt((r,n)=>{let i=t(r);return H(i),()=>{}}),e(),Tt(t)}var Ze=0;function sn(e,t){let r=e._x_deferInit;r||(r=e._x_deferInit={pending:0,ownsIgnore:!e._x_ignore,queuedAttributes:new Map},r.ownsIgnore&&(e._x_ignore=!0),Ze++),r.pending++,Promise.resolve(t).catch(n=>{try{Y(n,e)}catch(i){setTimeout(()=>{throw i},0)}}).then(()=>Ai(e,r))}function Ai(e,t){t.pending--,!(t.pending>0)&&(Rr(),!(t.pending>0)&&e._x_deferInit===t&&(delete e._x_deferInit,t.ownsIgnore&&delete e._x_ignore,Ze--,e.isConnected&&(Ti(t),S(e))))}function an(e,t){if(Ze===0)return!1;let r=A(e,n=>n._x_deferInit);return r?(cn(r._x_deferInit,e,t.map(({name:n})=>n)),!0):!1}function cn(e,t,r){let n=e.queuedAttributes.get(t);(!n||n.marker!==t._x_marker)&&(n={marker:t._x_marker,names:new Set},e.queuedAttributes.set(t,n)),r.forEach(i=>n.names.add(i))}function Ti(e){e.queuedAttributes.forEach((t,r)=>{if(!r.isConnected||!r._x_marker||r._x_marker!==t.marker)return;let n=A(r,o=>o._x_deferInit);if(n){cn(n._x_deferInit,r,Array.from(t.names));return}let i=Array.from(t.names).filter(o=>r.hasAttribute(o)).map(o=>({name:o,value:r.getAttribute(o)}));i.length!==0&&ee(r,i).forEach(o=>o())})}F((e,t)=>{Ze!==0&&(!e||e.nodeType!==1||!t||t.nodeType!==1||A(e,r=>r._x_deferInit)&&(t._x_ignore=!0))});function te(e,t,r={},n={}){return e.dispatchEvent(new CustomEvent(t,{detail:r,bubbles:!0,composed:!0,cancelable:!0,...n}))}function T(e,...t){console.warn(`Alpine Warning: ${e}`,...t)}var ln=!1;function fn(){ln&&T("Alpine has already been initialized on this page. Calling Alpine.start() more than once can cause problems."),ln=!0,document.body||T("Unable to initialize. Trying to load Alpine before `` is available. Did you forget to add `defer` in Alpine's ` + + + +
+

+ +
+ + + + diff --git a/tests/test_cli.py b/tests/test_cli.py index f645f43..4ad43a4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -36,13 +36,19 @@ def _invoke(*args: str) -> typing.Any: # noqa: ANN401 @pytest.mark.usefixtures("cli_env") def test_collect_writes_report_and_cache(gitlab: respx.Router, tmp_path: pathlib.Path) -> None: - output: typing.Final = tmp_path / "out" / "report.json" + output: typing.Final = tmp_path / "public" cache: typing.Final = tmp_path / "cache.json" first: typing.Final = _invoke("collect", "--group", "team", "--output", str(output), "--cache", str(cache)) assert first.exit_code == 0, first.output - report: typing.Final = json.loads(output.read_text()) + assert sorted(item.name for item in output.iterdir()) == [ + "alpine.LICENSE.txt", + "alpine.min.js", + "index.html", + "report.json", + ] + report: typing.Final = json.loads((output / "report.json").read_text()) assert report["schema_version"] == 3 assert report["jira"] is None assert report["production_environment"] == "production" @@ -62,7 +68,7 @@ def test_unreadable_cache_is_ignored_with_a_warning(tmp_path: pathlib.Path) -> N cache: typing.Final = tmp_path / "cache.json" cache.write_text('{"schema_version": 99}') - result: typing.Final = _invoke("collect", "-g", "team", "-o", str(tmp_path / "r.json"), "--cache", str(cache)) + result: typing.Final = _invoke("collect", "-g", "team", "-o", str(tmp_path / "public"), "--cache", str(cache)) assert result.exit_code == 0, result.output assert f"Warning: Ignoring unreadable cache {cache}: ValidationError." in result.output @@ -73,13 +79,16 @@ def test_unreadable_cache_is_ignored_with_a_warning(tmp_path: pathlib.Path) -> N def test_failed_service_is_reported_and_exits_non_zero(gitlab: respx.Router, tmp_path: pathlib.Path) -> None: gitlab["group"].respond(json=[SERVICE, project(2, "team/broken")]) gitlab.get(f"{API}/projects/2/deployments").respond(400) - output: typing.Final = tmp_path / "report.json" + output: typing.Final = tmp_path / "public" result: typing.Final = _invoke("collect", "-g", "team", "-o", str(output)) assert result.exit_code == 1 assert "Error: team/broken: GitLab returned 400 for deployments." in result.output - assert [item["error"] is None for item in json.loads(output.read_text())["services"]] == [False, True] + assert [item["error"] is None for item in json.loads((output / "report.json").read_text())["services"]] == [ + False, + True, + ] @pytest.mark.usefixtures("cli_env") @@ -88,12 +97,12 @@ def test_collect_reads_jira_issues_with_a_bearer_token( ) -> None: monkeypatch.setenv("RELEASE_SCOPE_JIRA_ENDPOINT", JIRA_ENDPOINT) monkeypatch.setenv("JIRA_TOKEN", "jira-pat") - output: typing.Final = tmp_path / "report.json" + output: typing.Final = tmp_path / "public" result: typing.Final = _invoke("collect", "-g", "team", "-o", str(output)) assert result.exit_code == 0, result.output - assert sorted(json.loads(output.read_text())["jira"]["issues"]) == ["SHOP-12", "SHOP-9"] + assert sorted(json.loads((output / "report.json").read_text())["jira"]["issues"]) == ["SHOP-12", "SHOP-9"] assert jira["jira_search"].calls.last.request.headers["Authorization"] == "Bearer jira-pat" @@ -105,20 +114,20 @@ def test_jira_failure_is_reported_and_exits_non_zero( monkeypatch.setenv("RELEASE_SCOPE_JIRA_ENDPOINT", JIRA_ENDPOINT) monkeypatch.setenv("RELEASE_SCOPE_JIRA_TOKEN", "expired") jira["jira_search"].respond(401) - output: typing.Final = tmp_path / "report.json" + output: typing.Final = tmp_path / "public" result: typing.Final = _invoke("collect", "-g", "team", "-o", str(output)) assert result.exit_code == 1 assert "Error: Jira rejected the token (401)." in result.output - assert json.loads(output.read_text())["services"][0]["rows"] + assert json.loads((output / "report.json").read_text())["services"][0]["rows"] @pytest.mark.usefixtures("cli_env") def test_jira_token_needs_a_jira_endpoint(monkeypatch: pytest.MonkeyPatch, tmp_path: pathlib.Path) -> None: monkeypatch.setenv("JIRA_TOKEN", "jira-pat") - result: typing.Final = _invoke("collect", "-g", "team", "-o", str(tmp_path / "r.json")) + result: typing.Final = _invoke("collect", "-g", "team", "-o", str(tmp_path / "public")) assert result.exit_code == 2 assert "Jira token is set but RELEASE_SCOPE_JIRA_ENDPOINT is not." in result.output @@ -134,13 +143,13 @@ def jira_env(monkeypatch: pytest.MonkeyPatch) -> None: @pytest.mark.usefixtures("cli_env", "jira_env", "scoped") @pytest.mark.httpx2(assert_all_called=False) def test_collect_for_jira_issues_writes_a_scoped_report(tmp_path: pathlib.Path) -> None: - output: typing.Final = tmp_path / "report.json" + output: typing.Final = tmp_path / "public" cache: typing.Final = tmp_path / "cache.json" result: typing.Final = _invoke("collect", "--jira", "SHOP-12", "-o", str(output), "--cache", str(cache)) assert result.exit_code == 1 - report: typing.Final = json.loads(output.read_text()) + report: typing.Final = json.loads((output / "report.json").read_text()) assert report["jira_scope"] == ["SHOP-12"] assert [item["project"] for item in report["services"]] == ["team/svc", "team/web", "team/worker"] assert json.loads(cache.read_text())["merge_requests"]["1"]["12"]["iid"] == 12 @@ -150,7 +159,7 @@ def test_collect_for_jira_issues_writes_a_scoped_report(tmp_path: pathlib.Path) @pytest.mark.usefixtures("cli_env", "jira_env", "scoped") @pytest.mark.httpx2(assert_all_called=False) def test_jira_issue_missing_from_jira_exits_non_zero(tmp_path: pathlib.Path) -> None: - result: typing.Final = _invoke("collect", "--jira", "SHOP-404", "-o", str(tmp_path / "r.json")) + result: typing.Final = _invoke("collect", "--jira", "SHOP-404", "-o", str(tmp_path / "public")) assert result.exit_code == 1 assert "Error: Jira has no issue SHOP-404." in result.output @@ -165,7 +174,7 @@ def test_jira_issue_missing_from_jira_exits_non_zero(tmp_path: pathlib.Path) -> ], ) def test_jira_option_is_validated(tmp_path: pathlib.Path, args: tuple[str, ...], message: str) -> None: - result: typing.Final = _invoke("collect", *args, "-o", str(tmp_path / "r.json")) + result: typing.Final = _invoke("collect", *args, "-o", str(tmp_path / "public")) assert result.exit_code == 2 assert message in result.output @@ -173,7 +182,7 @@ def test_jira_option_is_validated(tmp_path: pathlib.Path, args: tuple[str, ...], @pytest.mark.usefixtures("cli_env") def test_jira_option_needs_jira_settings(tmp_path: pathlib.Path) -> None: - result: typing.Final = _invoke("collect", "--jira", "SHOP-1", "-o", str(tmp_path / "r.json")) + result: typing.Final = _invoke("collect", "--jira", "SHOP-1", "-o", str(tmp_path / "public")) assert result.exit_code == 2 assert "--jira needs RELEASE_SCOPE_JIRA_ENDPOINT and RELEASE_SCOPE_JIRA_TOKEN." in result.output @@ -184,7 +193,7 @@ def test_forbidden_service_fails_alone_and_exits_non_zero(gitlab: respx.Router, gitlab["group"].respond(json=[SERVICE, project(2, "team/nodeploy")]) gitlab.get(f"{API}/projects/2/deployments").respond(403) - result: typing.Final = _invoke("collect", "-g", "team", "-o", str(tmp_path / "report.json")) + result: typing.Final = _invoke("collect", "-g", "team", "-o", str(tmp_path / "public")) assert result.exit_code == 1 assert "Error: team/nodeploy: GitLab denied access to deployments (403). Check that:" in result.output @@ -195,7 +204,7 @@ def test_forbidden_service_fails_alone_and_exits_non_zero(gitlab: respx.Router, def test_rejected_token_exits_with_auth_code(httpx2_mock: respx.Router, tmp_path: pathlib.Path) -> None: httpx2_mock.get(f"{API}/groups/team/projects").respond(401) - result: typing.Final = _invoke("collect", "-g", "team", "-o", str(tmp_path / "report.json")) + result: typing.Final = _invoke("collect", "-g", "team", "-o", str(tmp_path / "public")) assert result.exit_code == 3 assert "Error: GitLab rejected the token (401)." in result.output @@ -204,7 +213,7 @@ def test_rejected_token_exits_with_auth_code(httpx2_mock: respx.Router, tmp_path @pytest.mark.usefixtures("cli_env") def test_authentication_failure_exits_with_auth_code(httpx2_mock: respx.Router, tmp_path: pathlib.Path) -> None: httpx2_mock.get(f"{API}/groups/team/projects").respond(403) - output: typing.Final = tmp_path / "report.json" + output: typing.Final = tmp_path / "public" result: typing.Final = _invoke("collect", "-g", "team", "-o", str(output)) @@ -215,14 +224,14 @@ def test_authentication_failure_exits_with_auth_code(httpx2_mock: respx.Router, @pytest.mark.usefixtures("cli_env") def test_collect_needs_a_group_or_project(tmp_path: pathlib.Path) -> None: - result: typing.Final = _invoke("collect", "-o", str(tmp_path / "r.json")) + result: typing.Final = _invoke("collect", "-o", str(tmp_path / "public")) assert result.exit_code == 2 assert "Pass --jira, or at least one --group or --project." in result.output def test_collect_needs_a_token(tmp_path: pathlib.Path) -> None: - result: typing.Final = _invoke("collect", "-g", "team", "-o", str(tmp_path / "r.json")) + result: typing.Final = _invoke("collect", "-g", "team", "-o", str(tmp_path / "public")) assert result.exit_code == 2 assert "GitLab token is missing" in result.output From f2809d72d10c1ffb0f6b495f46047dcebd1a03fa Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 6 Oct 2026 13:50:34 +0300 Subject: [PATCH 04/10] Document the GitLab Pages site and drop the wiki docs --- AGENTS.md | 4 +- README.md | 98 ++++++------- docs/research/gitlab-pages-report.md | 210 +++++++++++++++++++++++++++ skills/release-scope/SKILL.md | 50 +++---- 4 files changed, 278 insertions(+), 84 deletions(-) create mode 100644 docs/research/gitlab-pages-report.md diff --git a/AGENTS.md b/AGENTS.md index 9e70c3c..f186288 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,9 @@ issue title. ## Architecture `_use_case.py` drives one run; `_gitlab.py` and `_jira.py` are the only modules that speak HTTP; `_rows.py` is pure and -turns the range plus merge requests into rows; `_render.py` is pure and turns a report into the Markdown page. Tests +turns the range plus merge requests into rows; `_candidates.py` is pure and turns a service's rows into the tags a +release could ship. `_static/` is the site `collect` copies next to `report.json`: `index.html` with vendored Alpine.js, +which has no tests, so check it in a browser against a report from the CLI tests. Tests mock GitLab and Jira only with respx routes (pytest-httpx2): the `gitlab` and `jira` fixtures in `tests/conftest.py` declare one named static route per call of the scenario in `tests/payloads.py`. A test changes a response by re-mocking a named route; add no fakes, callbacks, or stubs. diff --git a/README.md b/README.md index db9ca16..454b3ef 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ Jira issues, failed jobs. For every service it reads the latest successful production deployment, walks the default branch down to that -commit, and writes one JSON report: a row per merge request or direct commit, newest first, with the tags 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, and the GitLab merge requests and commits linked to each issue. GitLab's Jira integration adds those links to the @@ -32,13 +32,14 @@ issue's Web links whenever a commit or MR mentions it; a link counts only if it ```sh export RELEASE_SCOPE_GITLAB__ENDPOINT=https://gitlab.example.com -export RELEASE_SCOPE_GITLAB__TOKEN=glpat-... # read_api scope; api for publish +export RELEASE_SCOPE_GITLAB__TOKEN=glpat-... # read_api scope export RELEASE_SCOPE_ENVIRONMENTS='["prod", "preview"]' export RELEASE_SCOPE_PRODUCTION_ENVIRONMENT=prod -uvx release-scope collect --group team/backend --output report.json --cache cache.json +uvx release-scope collect --group team/backend --output public --cache cache.json ``` +`--output` is a directory: `collect` writes `report.json` there, next to the page that shows it (see Site). `--group` and `--project` are repeatable and can be mixed. The command exits `1` when any service failed to collect; the report is still written and names the error on that service. A service GitLab denies access to fails alone, and its error lists the project settings and member page to check. A project with CI/CD or Environments @@ -51,11 +52,13 @@ and also exits `1`; the GitLab part is still written. `--jira` scopes the report to Jira issues instead of groups or projects, and needs the Jira settings: ```sh -uvx release-scope collect --jira SHOP-140 --jira SHOP-141 --output report.json --cache cache.json +uvx release-scope collect --jira SHOP-140 --jira SHOP-141 --output public --cache cache.json ``` -It reads the issues and their GitLab links, then collects every project they link to. In each project the rows run -from the production baseline up to the latest linked change, so they show everything that ships with the issues. The service +It reads the issues and their GitLab links, then collects every project they link to. In each project the rows from +the production baseline up to the latest linked change are in scope: they show everything that ships with the issues. +Rows above that change are kept with `in_scope: false`, so their tags can still be picked, but their Jira keys are +neither looked up nor counted as tasks of a release. The service records the release state: `pending` with the nearest tag at or above that change (or none, when a new tag is needed), `in_production` when every linked merge request is already deployed, `not_merged` when only open merge requests link to it, or `not_found`. Open merge requests and merges into other branches are listed either way. @@ -83,7 +86,8 @@ The report is versioned by `schema_version`; the models live in [`release_scope/_report.py`](https://github.com/modern-python/release-scope/blob/main/release_scope/_report.py). 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. One row, trimmed: +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: ```json { @@ -97,60 +101,53 @@ request failed. One row, trimmed: } ``` -## Page +## Site -`render` turns a report into a Markdown page for a GitLab wiki, without calling GitLab: +Besides `report.json`, `collect` writes `index.html` and its script into the output directory. They come from the +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. -```sh -uvx release-scope collect --group team/backend --output report.json --cache cache.json; \ - 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 -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 -and tag pipelines. With Jira issues, the summary table also counts the issues per service whose status is not done. -A `--jira` report names its issues at the top, shows the tag to release per service, and marks the rows linked to -the issues. - -Chain the two commands with `;`, not `&&`: `collect` exits `1` when a service failed, which is exactly when the page -should show it. Alert on the exit code of `collect`, not on whether to render. `render` fails only when it cannot -read the report or write the page. +**Release** lists every service with a tag between production and the head of the default branch. Pick at most one +tag per service; a `--jira` report starts with each service's release tag picked. The picks drive three lists, each +with a copy button and a text box to copy from by hand, since browsers allow the copy button only over HTTPS: -## Wiki +- **Jira tasks**: the keys of every in-scope row from each picked tag down to production, without duplicates, with + summary and status, flagging issues that are not done. Copy them one per line or as a JQL `key in (...)` clause. +- **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. -`publish` replaces the content of an existing page in a project wiki with a rendered page: +**Services** shows each service with pending changes or a collection error: the ref each environment runs, the +number of changes and the newest tag, Jira issues that are not done, and failed jobs. Each has a collapsible table of +its rows with tags and their pipelines, merge requests or commits, Jira keys, environments, and failed jobs; rows out +of scope are dimmed. Services already up to date collapse into one list. Services without a production deployment are +left out of the page. -```sh -uvx release-scope publish report.md --project team/docs --page releases/backend -``` +## GitLab Pages -`--page` is the page slug, as in its URL after `/-/wikis/`. The page must already exist; `publish` never creates -one, so create it once in GitLab. It keeps the page title and format, and skips the write when the content is -unchanged, so a scheduled run does not add a page version every time. Publishing needs a token with the `api` scope -whose user has at least the Developer role in the wiki's project; a denied token exits `3`, a missing page or any -other failed request exits `4`. GitLab rejects pages larger than its wiki page size limit, 5 MB by default; the error -then names the size of the page. - -A scheduled GitLab CI job keeps the page current. It publishes even when a service failed, then fails the job: +A scheduled pipeline publishes the site with [GitLab Pages](https://docs.gitlab.com/user/project/pages/). Keep it in +a project of its own, such as `team/release-report`: Pages serves only the site of the project that runs the job, +and its members are who can view it. `collect` reads the services through the API, so they need no change. ```yaml -release-page: - image: python:3.13-slim +release-report: + image: ghcr.io/astral-sh/uv:python3.13-trixie-slim rules: - if: $CI_PIPELINE_SOURCE == "schedule" script: - - pip install 'release-scope>=0.4,<0.5' - - release-scope collect --group team/backend --output report.json || status=$? - - release-scope render report.json --output report.md - - release-scope publish report.md --project team/docs --page releases/backend - - exit "${status:-0}" + - uvx --from 'release-scope>=0.5,<0.6' release-scope collect --group team/backend --output public || [ $? -eq 1 ] + pages: true ``` -Set `RELEASE_SCOPE_GITLAB__ENDPOINT` and a masked `RELEASE_SCOPE_GITLAB__TOKEN` as CI/CD variables of the project -that runs the job, along with the other settings. +`|| [ $? -eq 1 ]` keeps the job green when only some services failed: the page shows their errors, and GitLab deploys +Pages only from a successful job. A configuration error, a rejected token, or an unreachable group still fails the +job and keeps the previous site. `pages: true` needs GitLab 17.6 and publishes `public` as the job artifact from 17.10; +on older versions name the job `pages` and add `artifacts: {paths: [public]}`. + +Set `RELEASE_SCOPE_GITLAB__ENDPOINT` and a masked `RELEASE_SCOPE_GITLAB__TOKEN` as CI/CD variables of the project, +along with the other settings, then add a pipeline schedule. Without [Pages access +control](https://docs.gitlab.com/administration/pages/#access-control), which an administrator of a self-managed +instance turns on, a Pages site is public to anyone who can reach it, even for a private project. With it, set +**Settings > General > Visibility > Pages** to *Only project members*. ## Cache @@ -167,8 +164,7 @@ requests and never changes the report. skill that runs `release-scope` through `uvx` and answers release questions from the report. Ask your coding agent what in the current repository has not reached production, what a group will ship with the next tag, or whether a Jira issue is released and which services it touches. For the current repository the skill takes `--project` from -the git remote. It keeps the report and cache outside the repository, and publishes to a wiki page only when you ask -for it and name the page. +the git remote. It keeps the report and cache outside the repository. Install it with [skills](https://github.com/vercel-labs/skills): @@ -177,4 +173,4 @@ npx skills add modern-python/release-scope ``` The agent reads the same environment variables as the CLI, so set them first as described under Configuration. -The skill runs `release-scope>=0.4,<0.5`, the range whose flags and report schema it describes. +The skill runs `release-scope>=0.5,<0.6`, the range whose flags and report schema it describes. diff --git a/docs/research/gitlab-pages-report.md b/docs/research/gitlab-pages-report.md new file mode 100644 index 0000000..7af5e19 --- /dev/null +++ b/docs/research/gitlab-pages-report.md @@ -0,0 +1,210 @@ +# GitLab Pages report with interactive version picking + +Research date: 2026-10-06. GitLab docs were read from `gitlab-org/gitlab` master (`VERSION` = `19.5.0-pre`). +Source code links point to the same branch. Release dates come from the GitHub, npm and PyPI APIs on the research date. + +Legend: **[src]** is GitLab source code, **[doc]** is official docs. "Unverified" means no primary source was found. + +## 1. GitLab Pages mechanics + +### Which job publishes + +| Mechanism | Status | Version | Source | +|---|---|---|---| +| A job **named** `pages` | Deprecated but still works (legacy branch in code). No removal milestone is listed on the deprecations page. | Long-standing | [doc](https://docs.gitlab.com/ci/yaml/deprecated_keywords/#publish-keyword-and-pages-job-name-for-gitlab-pages), [src `Ci::Build#pages_generator?`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/models/ci/build.rb) | +| `pages: true` or `pages: {...}` on **any** job name | Current way | 17.5 behind flag `customizable_pages_job_name`, **GA in 17.6** | [doc](https://docs.gitlab.com/user/project/pages/#user-defined-job-names) | +| A job named `pages` with `pages: false` | Does not deploy | 17.6+ | same | +| Job-level `publish: dir` | Deprecated in **17.9** | Introduced 16.1 (flag), on by default for self-managed in **16.2** | [doc](https://docs.gitlab.com/user/project/pages/introduction/#customize-the-default-folder) | +| `pages.publish: dir` | Current. Variables allowed. | **17.9** | [doc](https://docs.gitlab.com/ci/yaml/#pagespublish) | +| `publish` and `pages.publish` together | Deployment fails validation | n/a | [src `DeploymentValidations`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/pages/deployment_validations.rb) | + +The code decides it like this ([src](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/models/ci/build.rb)). Pages must be enabled on the instance first: + +```ruby +return false unless Gitlab.config.pages.enabled +return true if options[:pages].is_a?(Hash) || options[:pages] == true +options[:pages] != false && name == 'pages' # Legacy behaviour +``` + +On an instance where Pages is disabled, the job runs as an ordinary job and nothing is deployed. + +### The `public/` artifact + +- The default content directory is `public`, and it needs a non-empty `index.html` at its root ([doc](https://docs.gitlab.com/ci/yaml/#pages)). +- From **17.10**, `public` (or `pages.publish`) is appended to `artifacts:paths` automatically ([doc](https://docs.gitlab.com/ci/yaml/#artifactspaths)). Before 17.10 you must list it yourself. +- The deploy runs only after the job reaches `success` (`after_transition any => [:success]` enqueues `PagesWorker`, see [src](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/models/ci/build.rb)). A failed job, including one with `allow_failure`, does **not** deploy. A job allowed to fail still shows as failed with a warning ([doc](https://docs.gitlab.com/ci/yaml/#allow_failure)). This matters because `collect` exits `1` when a service fails. +- The deploy appears in the pipeline as an extra `pages:deploy` status in the `deploy` stage ([src `UpdatePagesService`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/services/projects/update_pages_service.rb)). + +### Scheduled pipelines + +- No Pages-specific restriction on pipeline source was found in the docs or in `UpdatePagesService` / `DeploymentValidations`. A schedule deploys like any other pipeline. Gate the job with `rules: - if: $CI_PIPELINE_SOURCE == "schedule"` ([doc](https://docs.gitlab.com/ci/jobs/job_rules/#run-jobs-for-scheduled-pipelines)). +- Caveat ([src `validate_outdated_sha`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/pages/deployment_validations.rb)): a deploy is rejected with "build SHA is outdated for this ref" in one case. This happens when the job's SHA is no longer the ref's HEAD **and** a newer pipeline already deployed to the same `path_prefix`. If only the schedule deploys, this can only hit two overlapping scheduled runs. +- If several Pages jobs share a `path_prefix`, the last one to finish wins ([doc](https://docs.gitlab.com/user/project/pages/#user-defined-job-names)). +- A schedule runs with its owner's permissions, and a manual "Run" uses the clicker's ([doc](https://docs.gitlab.com/ci/pipelines/schedules/#run-manually)). Unverified: whether a manual run of a schedule has `CI_PIPELINE_SOURCE == "schedule"`. The doc only says it triggers the schedule. + +### Access control (self-managed) + +- **Admin switch:** `gitlab_pages['access_control'] = true` in `gitlab.rb`, which is off by default ([doc](https://docs.gitlab.com/administration/pages/#access-control)). +- **Without that switch, every Pages site is public, even for a private project.** In `ProjectFeature#public_pages?`, the first line is `return true unless Gitlab.config.pages.access_control` ([src](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/models/project_feature.rb)). "Public" here means anyone who can reach the Pages host. The site can still be firewalled: Pages behind a private network is reachable only from inside it ([doc](https://docs.gitlab.com/administration/pages/#prerequisites)). +- **With the switch on:** set per project in **Settings > General > Visibility > Pages** ([doc](https://docs.gitlab.com/user/project/pages/pages_access_control/)): + - Private project: "Only project members" or "Everyone". + - Internal project: "Only project members", "Everyone with access" (any logged-in non-external user), or "Everyone". + - Public project: "Only project members" or "Everyone with access". + - A member needs at least the Guest role. +- **Reuses GitLab auth:** yes. The Pages daemon is registered as an OAuth application. Unauthenticated users are redirected to GitLab to sign in, and the token is kept in a signed cookie. Each request is checked against the GitLab API ([doc](https://docs.gitlab.com/administration/pages/#access-control)). Since 17.10, scripts can also send `Authorization: Bearer ` with `read_api` scope ([doc](https://docs.gitlab.com/user/project/pages/pages_access_control/#authenticate-with-an-access-token)). +- Admins can force non-public sites instance-wide: "Disable public access to Pages sites" ([doc](https://docs.gitlab.com/administration/pages/#disable-public-access-to-all-pages-sites)). A group Owner can do the same for a group, from **17.9** ([doc](https://docs.gitlab.com/user/project/pages/pages_access_control/#remove-public-access-for-group-pages)). +- All of the above is **Free** tier. + +### Limits + +| Limit | Default | Tier | Source | +|---|---|---|---| +| Max site size (instance) | 100 MB, admin-configurable | Free | [doc](https://docs.gitlab.com/administration/pages/#set-global-maximum-size-of-each-gitlab-pages-site) | +| Per-group / per-project size override | n/a | Premium | same page | +| Files per site | 200,000 | Free | [doc](https://docs.gitlab.com/administration/instance_limits/#number-of-files-per-gitlab-pages-website) | +| Parallel deployments per top-level namespace | 1000 | Premium | [doc](https://docs.gitlab.com/administration/instance_limits/#number-of-parallel-pages-deployments) | + +The size is measured on the extracted publish directory ([src `total_size`](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/gitlab/pages/deployment_validations.rb)). One HTML file with inline JSON is far below the limit unless the report is huge. + +### Parallel deployments and `path_prefix` + +- **Tier: Premium/Ultimate.** Experiment in 16.7 behind flag `pages_multiple_versions_setting`. Enabled by default in 17.4, project setting removed in 17.7, periods allowed in 17.8, **GA in 17.9** ([doc](https://docs.gitlab.com/user/project/pages/parallel_deployments/), [doc](https://docs.gitlab.com/ci/yaml/#pagespath_prefix)). +- `path_prefix` is lowercased, cut to 63 bytes, and characters other than `[a-z0-9.]` become `-`. The site is then served at `//`. +- **Parallel deployments expire after 24 h by default.** For long-lived per-group reports, set `pages.expire_in: never` (Premium, 17.4+) ([doc](https://docs.gitlab.com/ci/yaml/#pagesexpire_in)). An admin can change the default ([doc](https://docs.gitlab.com/administration/pages/#configure-the-default-expiry-for-parallel-deployments)). +- A prefix that matches a folder of the main deployment shadows it ([doc](https://docs.gitlab.com/user/project/pages/parallel_deployments/#path-clash)). +- **Free-tier alternatives for one report per group:** + - Render all groups into subfolders (`public/backend/`, `public/frontend/`) in **one** job, because each deploy replaces the whole site. + - Or use one Pages project per group. + +### Single `index.html` with inline JS and JSON + +Yes. Pages serves static files, including "plain HTML, CSS, JavaScript, and Wasm"; server-side processing is not supported ([doc](https://docs.gitlab.com/user/project/pages/)). Notes: + +- Project sites are served under `//`, and URLs ending in `/` break relative links ([doc](https://docs.gitlab.com/user/project/pages/introduction/#broken-relative-links)). A single file with everything inline avoids this. +- Admins can add response headers such as CSP via `gitlab_pages['headers']` ([doc](https://docs.gitlab.com/administration/pages/#global-settings)). No default CSP header was found in the docs (unverified that none is set). A strict CSP without `unsafe-eval` breaks standard Alpine.js, which then needs its CSP build ([doc](https://github.com/alpinejs/alpine/blob/main/packages/docs/src/en/advanced/csp.md)). + +### Hosting in a different project + +- A Pages site belongs to the project whose pipeline ran the Pages job. The deployment is created on `build.project` ([src](https://gitlab.com/gitlab-org/gitlab/-/blob/master/app/services/projects/update_pages_service.rb)). A job **cannot** publish into another project's Pages. +- So the design is: a dedicated "docs" or "release-report" project holds `.gitlab-ci.yml`, the schedule, the CI variables and the Pages site. `collect` reads the service projects through the REST API with `RELEASE_SCOPE_GITLAB__TOKEN` (`read_api`), as the current README already describes. The service projects need no changes. +- Viewer access is governed by membership of the **docs project** when access control is "Only project members". Readers must be added there, or the docs project should be internal with "Everyone with access". + +## 2. Building an interactive page from Python-produced JSON + +Requirement recap: per-service radio groups with a "none" option, three derived lists (union of Jira keys, pipelines, compare links) that update live, copy-friendly output, and a static overview. + +### Options + +| Option | CI build needs | Runtime weight | Derived state | License | Latest release (verified) | Fit for `release-scope render --output public/` | +|---|---|---|---|---|---|---| +| **Python template + vanilla JS** (stdlib `string.Template`/`str.replace`, `json.dumps`) | Python only | ~0 KB library; your own JS | Hand-written: a `change` handler recomputes 3 lists | n/a (your code) | n/a | Best: no new runtime dependency | +| **Python template + Alpine.js, vendored** | Python only | `cdn.min.js` 3.17.4: 55.9 KB raw / 19.9 KB gzip (measured) | `x-data` getters act as computed properties, uncached ([doc](https://github.com/alpinejs/alpine/blob/main/packages/docs/src/en/directives/data.md)) | MIT | 3.17.4, 2026-09-21 (npm, GitHub) | Very good: ship one `.js` file as package data | +| Python template + Preact + htm standalone (ESM) | Python only | `htm/preact/standalone.mjs`: 13.2 KB raw / 5.3 KB gzip | Hooks/`useMemo` | MIT / Apache-2.0 | preact 11.0.0, 2026-09-30; **htm 3.1.1, 2022-04-26** | Good, but the htm bundle is stale and the code is more verbose than Alpine | +| Python template + petite-vue | Python only | 16.9 KB raw / 7.1 KB gzip | Vue-like reactivity | MIT | **0.4.1, 2022-01-18**; last push 2024-07-13 | Not recommended: effectively unmaintained | +| Jinja2 instead of stdlib templating | Adds `jinja2` (BSD-3-Clause) dependency | none | n/a (server side only) | BSD-3-Clause | 3.1.6, 2025-03-05 (PyPI) | Useful only if the overview is rendered server-side as HTML. Autoescaping helps; otherwise unnecessary | +| Observable Framework | **Node >= 18** ([doc](https://github.com/observablehq/framework/blob/main/docs/getting-started.md)); `npm:` imports downloaded from **jsDelivr at build time** ([doc](https://github.com/observablehq/framework/blob/main/docs/imports.md)) | Multi-file site, Observable runtime + Inputs | Excellent: reactive cells, `Inputs.radio([... , null])` supports "none" ([doc](https://github.com/observablehq/framework/blob/main/docs/inputs/radio.md)) | ISC | 1.13.4, 2026-03-02; last commit 2026-05-15 (slowing) | Poor: the CLI would shell out to `npx`; a Node image is needed in CI; build-time CDN access breaks behind a firewall | +| Evidence | Node toolchain; the repo now distributes a new CLI via `curl ... install.sh` and Evidence Studio ([README](https://github.com/evidence-dev/evidence/blob/main/README.md)) | SvelteKit site + DuckDB-wasm (unverified size) | Input components exist (e.g. `ButtonGroup`), SQL-driven | MIT | npm `@evidence-dev/evidence` 40.1.8, 2026-02-06; repo active (commits 2026-10-02), product in transition | Poor: SQL/BI-oriented, heavy, distribution in flux | +| Quarto + OJS | `quarto` binary. The PyPI `quarto-cli` is an sdist that **downloads the binary from GitHub at install time** (read in `setup.py`) | OJS runtime; `embed-resources: true` gives one file ([doc](https://github.com/quarto-dev/quarto-web/blob/main/docs/output-formats/html-basics.qmd)) | Excellent: `viewof` + Inputs, reactive ([doc](https://github.com/quarto-dev/quarto-web/blob/main/docs/interactive/ojs/index.qmd)) | MIT | 1.10.19, 2026-10-06 | Poor: a large external binary, GitHub download behind firewall; whether OJS pulls libraries from a CDN at runtime is unverified | +| Streamlit via stlite | None at build time (HTML embeds Python source) | Loads Pyodide + Streamlit wheels in the browser. `@stlite/browser` is 110 MB unpacked on npm; Pyodide comes **from a CDN by default**, and self-hosting needs the full Pyodide distribution ([README](https://github.com/whitphx/stlite/blob/main/README.md)) | Yes (Streamlit reruns) | Apache-2.0 | `@stlite/browser` 1.9.2, 2026-09-23 | Poor: seconds-to-load page, CDN dependency, heavy self-hosting | +| Datasette-lite | None (hosted app at lite.datasette.io) | Pyodide + Datasette | No picker UI; it is a SQL explorer. Data must be on a CORS-enabled URL ([README](https://github.com/simonw/datasette-lite/blob/main/README.md)) | Apache-2.0 | No releases (deployed app); last push 2026-08-09 | Not a fit | +| Panel (`panel convert` to Pyodide) | Python | Pyodide-based (unverified size) | Yes | BSD-3-Clause | 1.9.4, 2026-08-17 (PyPI) | Same Pyodide weight and CDN issue as stlite; listed for completeness, not deeply evaluated | + +### Vendor or CDN + +**Vendor the library into the wheel.** + +- `uv_build` packages everything under the module root, so `release_scope/_static/alpine.min.js` ships in the wheel with no config. Only `__pycache__`, `*.pyc` and `*.pyo` are excluded by default ([doc](https://github.com/astral-sh/uv/blob/main/docs/concepts/build-backend.md#file-inclusion-and-exclusion)). Read it with `importlib.resources.files("release_scope")` (stdlib). +- **Inline** it into `index.html` rather than copying it next to it. One file works opened from `file://`, as a CI artifact download, or on Pages. It is immune to the trailing-slash relative-link issue and has no runtime CDN dependency behind a firewall. +- A CDN `` cannot end the element ([spec](https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements)). Parse it with `JSON.parse`. +- **Keep the logic in Python:** precompute a view model in Python. For each service with a production deploy, list the candidate tags. For each tag, store the **cumulative** Jira keys from that tag's row down to the oldest row, the tag pipeline (`TagRef.pipeline`), and the compare URL. The current `_compare` in `_render.py` builds that link. + - JS then only takes a union in pick order and renders. The derivation stays under pytest and 100% coverage, and the JS stays at a few dozen lines regardless of framework. +- **Copy buttons:** `navigator.clipboard` is `[SecureContext]`, which means HTTPS or localhost only ([spec](https://w3c.github.io/clipboard-apis/)). If the instance serves Pages over plain HTTP, the API is missing. Always render each list in a ` +
diff --git a/release_scope/_use_case.py b/release_scope/_use_case.py index 004e09b..ff53597 100644 --- a/release_scope/_use_case.py +++ b/release_scope/_use_case.py @@ -277,6 +277,7 @@ def _walk(self, project: Project, default_branch: str, baseline: str, service: S project.id, f"{baseline}..{default_branch}", max_items=self.settings.max_commits ) walk: typing.Final = _Walk(truncated=truncated) + service.truncated = truncated if truncated: service.warnings.append(f"Stopped after {self.settings.max_commits} commits; older changes are omitted.") if not commits: diff --git a/skills/release-scope/SKILL.md b/skills/release-scope/SKILL.md index f96a52b..677d997 100644 --- a/skills/release-scope/SKILL.md +++ b/skills/release-scope/SKILL.md @@ -81,6 +81,7 @@ 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. +- `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 `compare_url`. diff --git a/tests/test_use_case.py b/tests/test_use_case.py index df8658d..f9d90b5 100644 --- a/tests/test_use_case.py +++ b/tests/test_use_case.py @@ -515,6 +515,7 @@ def test_range_spanning_pages_is_read_to_the_end(gitlab: respx.Router) -> None: service: typing.Final = _only_service(_collect()) assert len(service.rows) == 5 + assert not service.truncated assert [call.request.url.params["page"] for call in gitlab["commits"].calls] == ["1", "2"] @@ -526,6 +527,7 @@ def test_long_range_is_truncated_with_a_warning(gitlab: respx.Router) -> None: assert [row.commits[0].sha for row in service.rows] == ["head", "c3"] assert service.warnings == ["Stopped after 2 commits; older changes are omitted."] + assert service.truncated assert gitlab["commits"].call_count == 1 From cd7f178c759cfb1431360d90c8092c6b31fc6753 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 6 Oct 2026 15:02:24 +0300 Subject: [PATCH 09/10] Use the singular for one merge request or commit --- release_scope/_static/index.html | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/release_scope/_static/index.html b/release_scope/_static/index.html index 9d74c74..335a974 100644 --- a/release_scope/_static/index.html +++ b/release_scope/_static/index.html @@ -340,9 +340,12 @@

Compare

lineDetails(service) { const candidate = this.pickOf(service); if (candidate) { - return `${candidate.rows} of ${service.rows.length} merge requests or commits · ${this.plural(candidate.jira_keys.length, "task")}`; + return `${candidate.rows} of ${this.rowsLabel(service.rows.length)} · ${this.plural(candidate.jira_keys.length, "task")}`; } - return service.rows.length ? `${service.rows.length} merge requests or commits` : ""; + return service.rows.length ? this.rowsLabel(service.rows.length) : ""; + }, + rowsLabel(count) { + return count === 1 ? "1 merge request or commit" : `${count} merge requests or commits`; }, ships(service, index) { const tag = this.picks[service.project]; From 7a0dcf184392dc87cc93c3384f4bc1b849f7e53d Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Tue, 6 Oct 2026 20:10:24 +0300 Subject: [PATCH 10/10] Call the chosen tag a picked tag, and tidy the page after review --- README.md | 31 ++++++++++++----------- docs/research/gitlab-pages-report.md | 3 +++ release_scope/_static/index.html | 38 ++++++++++++++-------------- 3 files changed, 38 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index c8535d2..b815478 100644 --- a/README.md +++ b/README.md @@ -107,21 +107,22 @@ 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 as one line: what production runs, the picked -target, its failed jobs, and a mark when the range was cut at `RELEASE_SCOPE_MAX_COMMITS`. Opening a line shows the -service's environments, warnings, and its rows with tags and their pipelines, merge requests or commits, Jira keys, -environments, and failed jobs; rows out of scope are dimmed. Each tag has a **pick** button: picking makes it the -service's target, 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** at the bottom turns the targets into three lists, each with a copy button and a text box to copy from by -hand, since browsers allow the copy button only over HTTPS: - -- **Jira tasks**: the keys of every in-scope row from each target down to production, without duplicates, with summary - and status, flagging issues that are not done. Copy them one per line or as a JQL `key in (...)` clause, each in its - own box. -- **Tag pipelines**: the pipeline of each target, as a Markdown list. -- **Compare**: a GitLab compare link per service from production to the target, as a Markdown list. +**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, +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, environments, and failed jobs; rows out of scope are dimmed. 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** at the bottom turns the picked tags into three lists, each with a copy button and a text box to copy from +by hand, since browsers allow the copy button only over HTTPS: + +- **Jira tasks**: the keys of every in-scope row from each picked tag down to production, without duplicates, with + summary and status, flagging issues that are not 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. ## GitLab Pages diff --git a/docs/research/gitlab-pages-report.md b/docs/research/gitlab-pages-report.md index 7af5e19..9d378ac 100644 --- a/docs/research/gitlab-pages-report.md +++ b/docs/research/gitlab-pages-report.md @@ -1,5 +1,8 @@ # GitLab Pages report with interactive version picking +Outcome: release 0.5.0 took the vendored Alpine.js page, with `report.json` next to it instead of inlined, and a +pick button per tag inside each service's line instead of radio groups. Jira release creation was left out of scope. + Research date: 2026-10-06. GitLab docs were read from `gitlab-org/gitlab` master (`VERSION` = `19.5.0-pre`). Source code links point to the same branch. Release dates come from the GitHub, npm and PyPI APIs on the research date. diff --git a/release_scope/_static/index.html b/release_scope/_static/index.html index 335a974..e5e94c2 100644 --- a/release_scope/_static/index.html +++ b/release_scope/_static/index.html @@ -48,15 +48,15 @@ tr.shipped td { background: color-mix(in srgb, var(--accent) 12%, transparent); } .tag { white-space: nowrap; } .service { border: 1px solid var(--border); border-radius: 6px; margin: 6px 0; } - .service > summary { display: flex; flex-wrap: wrap; gap: 4px 12px; align-items: baseline; padding: 8px 12px; cursor: pointer; } + .service > summary { display: flex; flex-wrap: wrap; gap: 4px 12px; align-items: baseline; padding: 8px 12px; cursor: pointer; + list-style: none; } .service[open] > summary { border-bottom: 1px solid var(--border); } - .service > summary { list-style: none; } .service > summary::before { content: "▸"; color: var(--muted); } .service[open] > summary::before { content: "▾"; } .service .name { min-width: 200px; font-weight: 600; } .service .body { padding: 8px 12px; } .service .body > p:first-child { margin-top: 0; } - .target { font-weight: 600; } + .picked-tag { font-weight: 600; } button.picked { border-color: var(--accent); color: var(--accent); } @@ -69,7 +69,7 @@

Collected UTC. Changes run from the commit on to the head of the default branch + x-text="report.jira_scope.length ? '; rows above the latest merge request or commit linked to the issues are out of scope.' : '.'">