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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,9 @@ production.
The rows above the newest row with a tag, or every row when none has one. Only a tag that does not exist yet can
ship them. The next tag is the highest `X.Y.Z` tag with its minor version bumped.

**First deployment**:
For a row and an environment other than production, when the deploy job finished that first shipped the row or a
newer row to it. A later rollback does not undo it.

**Settled fact**:
Data GitLab will not change for the same key, and so the only data the cache may hold.
17 changes: 11 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ 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,
one JSON report: a row per merge request or direct commit, newest first. A row has 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. It
also has its first deployment to each environment other than production: when the first deploy job that shipped the
row, or a newer row, finished. With a Jira token, it also reads the summary and status of every key in one batched search,
and the GitLab merge requests and commits linked to each issue. GitLab's Jira integration adds those links to the
issue's Web links whenever a commit or MR mentions it; a link counts only if it starts with
`RELEASE_SCOPE_GITLAB__ENDPOINT`. The projects they point to are the issue's related services.
Expand Down Expand Up @@ -105,6 +107,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"],
"first_deployed_at": {"preview": "2026-09-22T00:00:00Z"},
"main_pipeline": {"id": 103, "status": "failed", "failed_jobs": [{"kind": "job", "name": "lint", "allow_failure": false}]}
}
```
Expand All @@ -118,7 +121,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
Expand All @@ -128,7 +132,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
Expand Down Expand Up @@ -163,7 +168,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
```

Expand Down Expand Up @@ -202,4 +207,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.
24 changes: 23 additions & 1 deletion release_scope/_gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,22 @@ 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):
id: int
ref: str
sha: str
created_at: str
created_at: dt.datetime
deployable: Deployable | None = None

@property
def finished_at(self) -> dt.datetime:
if self.deployable and self.deployable.finished_at:
return self.deployable.finished_at
return self.created_at


class TagCommit(pydantic.BaseModel):
id: str
Expand Down Expand Up @@ -213,6 +220,21 @@ 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
) -> tuple[list[Deployment], bool]:
return self._pages(
f"{_API}/projects/{project_id}/deployments",
{
"environment": environment,
"status": "success",
"order_by": "finished_at",
"finished_after": finished_after.isoformat(),
},
_Deployments,
resource="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")

Expand Down
8 changes: 8 additions & 0 deletions release_scope/_messages.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,14 @@ def tags_truncated() -> Message:
return Message(code=MessageCode.TAGS_TRUNCATED, text="Tag list was truncated; some tags may be missing from rows.")


def deployments_truncated(environment: str) -> Message:
return Message(
code=MessageCode.DEPLOYMENTS_TRUNCATED,
params={"environment": environment},
text=f"Deployment history of {environment} was truncated; newer rows may miss when they reached it.",
)


def merged_elsewhere(iid: int, target_branch: str, default_branch: str) -> Message:
return Message(
code=MessageCode.MERGED_ELSEWHERE,
Expand Down
8 changes: 5 additions & 3 deletions release_scope/_report.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import pydantic


SCHEMA_VERSION: typing.Final = 5
SCHEMA_VERSION: typing.Final = 6


class MessageCode(enum.StrEnum):
Expand All @@ -15,6 +15,7 @@ class MessageCode(enum.StrEnum):
NO_PRODUCTION = "no_production"
COMMITS_TRUNCATED = "commits_truncated"
TAGS_TRUNCATED = "tags_truncated"
DEPLOYMENTS_TRUNCATED = "deployments_truncated"
MERGED_ELSEWHERE = "merged_elsewhere"
GITLAB_UNREACHABLE = "gitlab_unreachable"
GITLAB_STATUS = "gitlab_status"
Expand Down Expand Up @@ -83,6 +84,7 @@ class Row(pydantic.BaseModel):
commits: list[CommitRef]
jira_keys: list[JiraKeyRef]
environments: list[str]
first_deployed_at: dict[str, dt.datetime] = pydantic.Field(default_factory=dict)
main_pipeline: PipelineState | None
linked: bool = False
in_scope: bool = True
Expand All @@ -92,7 +94,7 @@ class EnvironmentState(pydantic.BaseModel):
name: str
ref: str
sha: str
deployed_at: str
deployed_at: dt.datetime
deployment_url: str | None
tag: bool = False

Expand Down Expand Up @@ -157,7 +159,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]
Expand Down
21 changes: 20 additions & 1 deletion release_scope/_rows.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import collections.abc
import dataclasses
import datetime as dt
import typing

from release_scope._gitlab import Commit, MergeRequest
from release_scope._gitlab import Commit, Deployment, MergeRequest


@dataclasses.dataclass(slots=True, kw_only=True)
Expand Down Expand Up @@ -35,3 +37,20 @@ 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.Mapping[str, collections.abc.Iterable[Deployment]],
) -> list[dict[str, dt.datetime]]:
positions: typing.Final = {commit.id: index for index, draft in enumerate(drafts) for commit in draft.commits}
earliest: typing.Final[list[dict[str, dt.datetime]]] = [{} for _ in drafts]
for environment, items in deployments.items():
for deployment in items:
if deployment.sha not in positions:
continue
for times in earliest[positions[deployment.sha] :]:
current = times.get(environment)
if current is None or deployment.finished_at < current:
times[environment] = deployment.finished_at
return earliest
30 changes: 25 additions & 5 deletions release_scope/_static/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -102,7 +102,7 @@
<div>
<h1 x-text="report.jira_scope.length ? t('titleIssues', { keys: report.jira_scope.join(', ') }) : t('title')"></h1>
<p class="muted">
<span x-text="t('rangeStart', { at: collectedAt })"></span>
<span :title="report.collected_at" x-text="t('rangeStart', { at: collectedAt })"></span>
<code x-text="report.production_environment"></code>
<span x-text="report.jira_scope.length ? t('rangeEndIssues') : t('rangeEnd')"></span>
</p>
Expand Down Expand Up @@ -206,7 +206,15 @@ <h2 x-text="t('services')"></h2>
</div>
</template>
</td>
<td x-text="row.environments.join(', ')"></td>
<td>
<template x-for="item in deployments(row)" :key="item.name">
<div class="tag" :title="item.at">
<span x-text="item.name"></span>
<span x-show="item.at" class="muted" x-text="item.at && formatTime(item.at)"></span>
<span x-show="item.now" x-text="t('deployedNow')"></span>
</div>
</template>
</td>
<td>
<template x-for="(line, position) in failedJobs(row)" :key="position">
<div>
Expand Down Expand Up @@ -318,7 +326,7 @@ <h3 x-text="t('releasePost')"></h3>
loadFailed: "Cannot load the report: {error}.",
title: "Release scope",
titleIssues: "Release scope: {keys}",
rangeStart: "Collected {at} UTC. Changes run from the commit on",
rangeStart: "Collected {at}. Changes run from the commit on",
rangeEnd: "to the head of the default branch.",
rangeEndIssues:
"to the head of the default branch; rows above the latest merge request or commit linked to the issues are out of scope.",
Expand Down Expand Up @@ -355,6 +363,7 @@ <h3 x-text="t('releasePost')"></h3>
columnTag: "Tag",
columnChange: "Change",
columnDeployed: "Deployed to",
deployedNow: "(now)",
columnFailed: "Failed jobs",
pick: "pick",
picked: "picked",
Expand Down Expand Up @@ -391,7 +400,7 @@ <h3 x-text="t('releasePost')"></h3>
loadFailed: "Не удалось загрузить отчёт: {error}.",
title: "Состав релиза",
titleIssues: "Состав релиза: {keys}",
rangeStart: "Данные собраны {at} UTC. Показаны изменения от коммита на",
rangeStart: "Данные собраны {at}. Показаны изменения от коммита на",
rangeEnd: "до последнего коммита основной ветки.",
rangeEndIssues:
"до последнего коммита основной ветки; строки выше последнего MR или коммита, связанного с задачами, вне скоупа.",
Expand Down Expand Up @@ -430,6 +439,7 @@ <h3 x-text="t('releasePost')"></h3>
columnTag: "Тег",
columnChange: "Изменение",
columnDeployed: "Задеплоено на",
deployedNow: "(сейчас)",
columnFailed: "Упавшие джобы",
pick: "выбрать",
picked: "выбран",
Expand Down Expand Up @@ -488,6 +498,7 @@ <h3 x-text="t('releasePost')"></h3>
`Нет успешного деплоя на «${p.environment}»; строки начинаются с первого коммита ветки ${p.branch}.`,
commits_truncated: (p) => `Достигнут лимит коммитов (${p.max_commits}); более старые изменения не показаны.`,
tags_truncated: () => "Список тегов обрезан; у некоторых строк теги могут не отображаться.",
deployments_truncated: (p) => `История деплоев на ${p.environment} обрезана; у новых строк может не быть времени деплоя.`,
merged_elsewhere: (p) => `!${p.iid} влит в ${p.target_branch}, а не в ${p.default_branch}.`,
gitlab_unreachable: (p) => `${p.project}: не удалось получить ${resourceAccusativeRu(p)} из GitLab (${p.reason}).`,
gitlab_status: (p) => `${p.project}: не удалось получить ${resourceAccusativeRu(p)} из GitLab: ответ ${p.status}.`,
Expand Down Expand Up @@ -580,7 +591,12 @@ <h3 x-text="t('releasePost')"></h3>
},

get collectedAt() {
return this.report.collected_at.slice(0, 16).replace("T", " ");
return this.formatTime(this.report.collected_at);
},
formatTime(value) {
const date = new Date(value);
const pad = (number) => String(number).padStart(2, "0");
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
},
get listed() {
return this.report.services.filter((service) => service.error || this.production(service) || service.rows.length);
Expand Down Expand Up @@ -715,6 +731,10 @@ <h3 x-text="t('releasePost')"></h3>
isMissing(key) {
return Boolean(this.report.jira && this.report.jira.missing.includes(key));
},
deployments(row) {
const names = [...new Set([...Object.keys(row.first_deployed_at), ...row.environments])];
return names.map((name) => ({ name, at: row.first_deployed_at[name], now: row.environments.includes(name) }));
},
environment(service, name) {
return service.environments.find((item) => item.name === name);
},
Expand Down
19 changes: 15 additions & 4 deletions release_scope/_use_case.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from release_scope._links import parse_gitlab_link
from release_scope._messages import (
commits_truncated,
deployments_truncated,
explain_failure,
merged_elsewhere,
no_default_branch,
Expand All @@ -39,7 +40,7 @@
Service,
TagRef,
)
from release_scope._rows import RowDraft, group_rows, match_merge_requests
from release_scope._rows import RowDraft, first_deployed, group_rows, match_merge_requests
from release_scope._settings import Settings


Expand All @@ -60,7 +61,7 @@ def _environment_state(name: str, deployment: Deployment) -> EnvironmentState:
name=name,
ref=deployment.ref,
sha=deployment.sha,
deployed_at=deployment.created_at,
deployed_at=deployment.finished_at,
deployment_url=deployment.deployable.web_url if deployment.deployable else None,
tag=deployment.deployable.tag if deployment.deployable else False,
)
Expand Down Expand Up @@ -103,6 +104,7 @@ class _Walk:
tags_by_sha: dict[str, list[str]] = dataclasses.field(default_factory=dict)
tag_names: list[str] = dataclasses.field(default_factory=list)
main_pipelines: dict[str, Pipeline] = dataclasses.field(default_factory=dict)
deployments: dict[str, list[Deployment]] = dataclasses.field(default_factory=dict)


@dataclasses.dataclass(frozen=True, slots=True, kw_only=True)
Expand Down Expand Up @@ -290,6 +292,7 @@ def _collect_service(self, project: Project, cache: Cache, *, links: list[Linked
drafts, linked, in_scope = walk.drafts, [False] * len(walk.drafts), [True] * len(walk.drafts)
if links is not None:
drafts, linked, in_scope = self._scope_rows(project, project.default_branch, service, walk, links, cache)
deployed: typing.Final = first_deployed(drafts, walk.deployments)
service.rows.extend(
self._build_row(
project=project,
Expand All @@ -298,8 +301,8 @@ 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, "in_scope": is_in_scope})
for draft, is_linked, is_in_scope in zip(drafts, linked, in_scope, strict=True)
).model_copy(update={"linked": is_linked, "in_scope": is_in_scope, "first_deployed_at": times})
for draft, is_linked, is_in_scope, times in zip(drafts, linked, in_scope, deployed, strict=True)
)
service.candidates.extend(build_candidates(service, production))
service.untagged = build_untagged(service, walk.tag_names)
Expand Down Expand Up @@ -331,6 +334,14 @@ def _walk(
walk.main_pipelines = self._latest_by_sha(
self.api.list_push_pipelines(project.id, ref=default_branch, updated_after=since)
)
for name in self.settings.environments:
if name == self.settings.production_environment:
continue
walk.deployments[name], history_cut = self.api.list_successful_deployments(
project.id, name, finished_after=since
)
if history_cut:
service.warnings.append(deployments_truncated(name))
return walk

def _scope_rows( # noqa: PLR0913, PLR0917
Expand Down
Loading
Loading