From 8297d2758f64516cc12f84c277582d2b27f142b6 Mon Sep 17 00:00:00 2001 From: Artur Shiriev Date: Sat, 10 Oct 2026 13:10:17 +0300 Subject: [PATCH 1/2] Show when each row first reached every non-production environment (schema 6) Rows carry deployed_at: per environment other than production, when its first successful deployment of the row or a newer one finished. The page shows it and every other time in the browser's time zone. --- README.md | 13 ++++++++----- release_scope/_gitlab.py | 17 +++++++++++++++++ release_scope/_report.py | 5 +++-- release_scope/_rows.py | 17 +++++++++++++++++ release_scope/_static/index.html | 29 ++++++++++++++++++++++++----- release_scope/_use_case.py | 24 +++++++++++++++++++++--- skills/release-scope/SKILL.md | 7 ++++--- tests/conftest.py | 6 ++++++ tests/payloads.py | 18 ++++++++++++++++++ tests/test_cli.py | 2 +- tests/test_units.py | 27 +++++++++++++++++++++++++-- tests/test_use_case.py | 17 +++++++++++++++++ 12 files changed, 161 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index ae3e103..5d6ad6e 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Jira issues, failed jobs. For every service it reads the latest successful production deployment, walks the default branch down to that commit, or down to its first commit when the service was never deployed to production, and writes a static site with one JSON report: a row per merge request or direct commit, newest first, with the tags that point into it, the -environments running it, the Jira keys its MR mentions, and the failed jobs of its main-branch and tag pipelines. With a Jira token, it also reads the summary and status of every key in one batched search, +environments running it, when each environment other than production first ran it, the Jira keys its MR mentions, and the failed jobs of its main-branch and tag pipelines. With a Jira token, it also reads the summary and status of every key in one batched search, and the GitLab merge requests and commits linked to each issue. GitLab's Jira integration adds those links to the issue's Web links whenever a commit or MR mentions it; a link counts only if it starts with `RELEASE_SCOPE_GITLAB__ENDPOINT`. The projects they point to are the issue's related services. @@ -105,6 +105,7 @@ comes from one more request for the highest versions. One row, trimmed: "commits": [{"sha": "c3...", "title": "Merge branch 'feature/SHOP-12'"}], "jira_keys": [{"key": "SHOP-12", "url": "https://jira.example.com/browse/SHOP-12"}], "environments": ["preview"], + "deployed_at": {"preview": "2026-09-22T00:00:00Z"}, "main_pipeline": {"id": 103, "status": "failed", "failed_jobs": [{"kind": "job", "name": "lint", "allow_failure": false}]} } ``` @@ -118,7 +119,8 @@ installed package and change only with it, so the page always matches the report Switches at the top pick the language, English or Russian, and the theme: as in the system, light, or dark. The page starts in the browser's language and remembers both choices in the browser. The copied lists and release post follow the language, and so do warnings and errors from `collect`; texts from GitLab and Jira, such as titles, statuses and -Jira's own error details, stay as written. +Jira's own error details, stay as written. Times show in the browser's time zone; hovering one shows the value +as the report holds it. **Services** lists every service with a production deployment or with rows, and every service that failed to collect, as one line: what production runs, the picked tag, how many merge requests or commits and Jira tasks it ships, the @@ -128,7 +130,8 @@ range holds no tag, so more untagged rows may lie below it. The failed jobs are the main pipeline of its commit; failures in other rows show only when the line is opened. Opening a line shows the service's environments, warnings, a link to create the next tag on the head when rows have no tag yet, merge requests that are not merged yet, and its rows with -tags and their pipelines, merge requests or commits, Jira keys with their status, environments, and failed jobs; rows +tags and their pipelines, merge requests or commits, Jira keys with their status, environments with the time each +first ran the row, and failed jobs; rows out of scope are dimmed. Links, including the GitLab settings pages that warnings and errors point to, open in a new tab. Each tag has a **pick** button: picking it highlights the rows it ships and closes the line again. A `--jira` report starts with each service's release tag picked. Services with neither a production deployment nor rows are left @@ -163,7 +166,7 @@ release-report: rules: - if: $CI_PIPELINE_SOURCE == "schedule" script: - - uvx --from 'release-scope>=0.7,<0.8' release-scope collect --group team/backend --output public || [ $? -eq 1 ] + - uvx --from 'release-scope>=0.8,<0.9' release-scope collect --group team/backend --output public || [ $? -eq 1 ] pages: true ``` @@ -202,4 +205,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.7,<0.8`, the range whose flags and report schema it describes. +The skill runs `release-scope>=0.8,<0.9`, the range whose flags and report schema it describes. diff --git a/release_scope/_gitlab.py b/release_scope/_gitlab.py index 5ffcd8a..10ed72d 100644 --- a/release_scope/_gitlab.py +++ b/release_scope/_gitlab.py @@ -29,6 +29,7 @@ class Project(pydantic.BaseModel): class Deployable(pydantic.BaseModel): web_url: str | None = None tag: bool = False + finished_at: dt.datetime | None = None class Deployment(pydantic.BaseModel): @@ -213,6 +214,22 @@ def latest_deployment(self, project_id: int, environment: str) -> Deployment | N ) return deployments.root[0] if deployments.root else None + def list_successful_deployments( + self, project_id: int, environment: str, *, finished_after: dt.datetime + ) -> list[Deployment]: + deployments, _ = self._pages( + f"{_API}/projects/{project_id}/deployments", + { + "environment": environment, + "status": "success", + "order_by": "finished_at", + "finished_after": finished_after.isoformat(), + }, + _Deployments, + resource="deployments", + ) + return deployments + def list_tags(self, project_id: int) -> tuple[list[Tag], bool]: return self._pages(f"{_API}/projects/{project_id}/repository/tags", {}, _Tags, resource="repository") diff --git a/release_scope/_report.py b/release_scope/_report.py index 0874e31..f1a882f 100644 --- a/release_scope/_report.py +++ b/release_scope/_report.py @@ -5,7 +5,7 @@ import pydantic -SCHEMA_VERSION: typing.Final = 5 +SCHEMA_VERSION: typing.Final = 6 class MessageCode(enum.StrEnum): @@ -83,6 +83,7 @@ class Row(pydantic.BaseModel): commits: list[CommitRef] jira_keys: list[JiraKeyRef] environments: list[str] + deployed_at: dict[str, dt.datetime] = pydantic.Field(default_factory=dict) main_pipeline: PipelineState | None linked: bool = False in_scope: bool = True @@ -157,7 +158,7 @@ class JiraState(pydantic.BaseModel): class Report(pydantic.BaseModel): - schema_version: typing.Literal[5] = SCHEMA_VERSION + schema_version: typing.Literal[6] = SCHEMA_VERSION collected_at: dt.datetime production_environment: str services: list[Service] diff --git a/release_scope/_rows.py b/release_scope/_rows.py index 17447e0..d7313bf 100644 --- a/release_scope/_rows.py +++ b/release_scope/_rows.py @@ -1,5 +1,7 @@ import collections.abc import dataclasses +import datetime as dt +import typing from release_scope._gitlab import Commit, MergeRequest @@ -35,3 +37,18 @@ def group_rows( row = rows.setdefault(key, RowDraft(commits=[], merge_requests=list(merge_requests))) row.commits.append(commit) return list(rows.values()) + + +def first_deployed( + drafts: collections.abc.Sequence[RowDraft], deployments: collections.abc.Iterable[tuple[str, dt.datetime]] +) -> list[dt.datetime | None]: + positions: typing.Final = {commit.id: index for index, draft in enumerate(drafts) for commit in draft.commits} + earliest: typing.Final[list[dt.datetime | None]] = [None] * len(drafts) + for sha, finished_at in deployments: + if sha not in positions: + continue + for index in range(positions[sha], len(drafts)): + current = earliest[index] + if current is None or finished_at < current: + earliest[index] = finished_at + return earliest diff --git a/release_scope/_static/index.html b/release_scope/_static/index.html index 57eb2ab..b4b8544 100644 --- a/release_scope/_static/index.html +++ b/release_scope/_static/index.html @@ -102,7 +102,7 @@

- +

@@ -206,7 +206,15 @@

- + + +