diff --git a/CONTEXT.md b/CONTEXT.md
index 9ca64ae..339a700 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -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.
diff --git a/README.md b/README.md
index ae3e103..735a30e 100644
--- a/README.md
+++ b/README.md
@@ -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.
@@ -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}]}
}
```
@@ -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
@@ -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
@@ -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
```
@@ -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.
diff --git a/release_scope/_gitlab.py b/release_scope/_gitlab.py
index 5ffcd8a..db58aa4 100644
--- a/release_scope/_gitlab.py
+++ b/release_scope/_gitlab.py
@@ -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
@@ -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")
diff --git a/release_scope/_messages.py b/release_scope/_messages.py
index 6e59be4..c509942 100644
--- a/release_scope/_messages.py
+++ b/release_scope/_messages.py
@@ -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,
diff --git a/release_scope/_report.py b/release_scope/_report.py
index 0874e31..a637d4a 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):
@@ -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"
@@ -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
@@ -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
@@ -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]
diff --git a/release_scope/_rows.py b/release_scope/_rows.py
index 17447e0..205b936 100644
--- a/release_scope/_rows.py
+++ b/release_scope/_rows.py
@@ -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)
@@ -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
diff --git a/release_scope/_static/index.html b/release_scope/_static/index.html
index 57eb2ab..a47e91f 100644
--- a/release_scope/_static/index.html
+++ b/release_scope/_static/index.html
@@ -102,7 +102,7 @@
- |
+
+
+
+
+
+
+
+
+ |
@@ -318,7 +326,7 @@
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.",
@@ -355,6 +363,7 @@
columnTag: "Tag",
columnChange: "Change",
columnDeployed: "Deployed to",
+ deployedNow: "(now)",
columnFailed: "Failed jobs",
pick: "pick",
picked: "picked",
@@ -391,7 +400,7 @@
loadFailed: "Не удалось загрузить отчёт: {error}.",
title: "Состав релиза",
titleIssues: "Состав релиза: {keys}",
- rangeStart: "Данные собраны {at} UTC. Показаны изменения от коммита на",
+ rangeStart: "Данные собраны {at}. Показаны изменения от коммита на",
rangeEnd: "до последнего коммита основной ветки.",
rangeEndIssues:
"до последнего коммита основной ветки; строки выше последнего MR или коммита, связанного с задачами, вне скоупа.",
@@ -430,6 +439,7 @@
columnTag: "Тег",
columnChange: "Изменение",
columnDeployed: "Задеплоено на",
+ deployedNow: "(сейчас)",
columnFailed: "Упавшие джобы",
pick: "выбрать",
picked: "выбран",
@@ -488,6 +498,7 @@
`Нет успешного деплоя на «${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}.`,
@@ -580,7 +591,12 @@
},
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);
@@ -715,6 +731,10 @@
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);
},
diff --git a/release_scope/_use_case.py b/release_scope/_use_case.py
index d04ac90..3390a5c 100644
--- a/release_scope/_use_case.py
+++ b/release_scope/_use_case.py
@@ -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,
@@ -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
@@ -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,
)
@@ -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)
@@ -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,
@@ -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)
@@ -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
diff --git a/skills/release-scope/SKILL.md b/skills/release-scope/SKILL.md
index e2bb6b4..43b5506 100644
--- a/skills/release-scope/SKILL.md
+++ b/skills/release-scope/SKILL.md
@@ -14,7 +14,7 @@ description: >
through uvx with this version range; it matches the flags and report schema described here:
```bash
-uvx --from 'release-scope>=0.7,<0.8' release-scope --help
+uvx --from 'release-scope>=0.8,<0.9' release-scope --help
```
## Check the settings
@@ -58,7 +58,7 @@ run and only saves requests:
```bash
out="${XDG_CACHE_HOME:-$HOME/.cache}/release-scope"
mkdir -p "$out"
-uvx --from 'release-scope>=0.7,<0.8' release-scope collect --project team/backend/shop \
+uvx --from 'release-scope>=0.8,<0.9' release-scope collect --project team/backend/shop \
--output "$out/site" --cache "$out/cache.json"
```
@@ -77,12 +77,13 @@ For `2` to `4` nothing was written; show the message and stop.
Read `$out/site/report.json` and answer the user's question from it, briefly.
-- `services[]`: `project`, `environments` (what each environment runs), `rows` (newest first, from the default
- branch head down to production), `warnings`, `error`. Warnings and errors are messages: read their `text`;
+- `services[]`: `project`, `environments` (what each environment runs, and `deployed_at`, when its deploy job
+ finished), `rows` (newest first, from the default branch head down to production), `warnings`, `error`. Warnings and errors are messages: read their `text`;
`code` and `params` carry the same facts in structured form.
- A row is one merged merge request or a direct commit: `merge_requests`, `commits`, `tags` pointing into it,
- `environments` already running it, `jira_keys`, and `main_pipeline` with `failed_jobs`. Tag pipelines carry their
- own `failed_jobs`.
+ `environments` running exactly its commit now, `first_deployed_at` (per environment other than production, when the
+ first deploy job that shipped this row or a newer one finished), `jira_keys`, and `main_pipeline` with
+ `failed_jobs`. Tag pipelines carry their own `failed_jobs`.
- A service with no rows is up to date with production.
- A service never deployed to production has a `no_production` warning; its rows run down to the first commit of the
default branch and its `compare_url` lists the tag's commits.
diff --git a/tests/conftest.py b/tests/conftest.py
index 01a9406..bfecfed 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -16,6 +16,7 @@
LINKED_MERGE_REQUEST,
MERGED_MERGE_REQUESTS,
PREVIEW_DEPLOYMENT,
+ PREVIEW_DEPLOYMENTS,
PRODUCTION_DEPLOYMENT,
PUSH_PIPELINES,
SERVICE,
@@ -54,6 +55,11 @@ def gitlab(httpx2_mock: respx.Router) -> respx.Router:
httpx2_mock.get(
f"{SERVICE_API}/deployments", params={"environment": "production"}, name="deploy:production"
).respond(json=[PRODUCTION_DEPLOYMENT])
+ httpx2_mock.get(
+ f"{SERVICE_API}/deployments",
+ params={"environment": "preview", "order_by": "finished_at"},
+ name="deployments:preview",
+ ).respond(json=PREVIEW_DEPLOYMENTS)
httpx2_mock.get(f"{SERVICE_API}/deployments", params={"environment": "preview"}, name="deploy:preview").respond(
json=[PREVIEW_DEPLOYMENT]
)
diff --git a/tests/payloads.py b/tests/payloads.py
index e755409..3f469f3 100644
--- a/tests/payloads.py
+++ b/tests/payloads.py
@@ -90,6 +90,24 @@ def job(name: str, *, allow_failure: bool = False, **fields: typing.Any) -> dict
"created_at": "2026-09-22T00:00:00Z",
"deployable": None,
}
+PREVIEW_DEPLOYMENTS: typing.Final = [
+ {**PRODUCTION_DEPLOYMENT, "id": 3, "deployable": {"finished_at": "2026-09-02T00:00:00Z"}},
+ {
+ "id": 4,
+ "ref": "main",
+ "sha": "c0b",
+ "created_at": "2026-09-19T10:00:00Z",
+ "deployable": {"finished_at": "2026-09-19T12:00:00Z"},
+ },
+ {
+ "id": 6,
+ "ref": "main",
+ "sha": "c2",
+ "created_at": "2026-09-21T10:00:00Z",
+ "deployable": {"finished_at": "2026-09-21T12:00:00+03:00"},
+ },
+ PREVIEW_DEPLOYMENT,
+]
COMMITS: typing.Final = [
commit("head", "SHOP-9 hotfix typo", date="2026-09-25T00:00:00Z"),
commit("c3", "Merge branch 'branch-12'", date="2026-09-22T00:00:00Z"),
diff --git a/tests/test_cli.py b/tests/test_cli.py
index 4d045b7..bcd48a7 100644
--- a/tests/test_cli.py
+++ b/tests/test_cli.py
@@ -49,7 +49,7 @@ def test_collect_writes_report_and_cache(gitlab: respx.Router, tmp_path: pathlib
"report.json",
]
report: typing.Final = json.loads((output / "report.json").read_text())
- assert report["schema_version"] == 5
+ assert report["schema_version"] == 6
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_units.py b/tests/test_units.py
index ef341f1..e2a4e00 100644
--- a/tests/test_units.py
+++ b/tests/test_units.py
@@ -1,3 +1,4 @@
+import datetime as dt
import pathlib
import typing
@@ -10,11 +11,12 @@
from release_scope import ioc
from release_scope._cache import Cache, CacheData, CachedPipeline
from release_scope._errors import ConfigError, GitLabError
-from release_scope._gitlab import GitLabApi, MergeRequest
+from release_scope._gitlab import Commit, Deployment, GitLabApi, MergeRequest
from release_scope._jira_keys import extract_jira_keys
from release_scope._links import parse_gitlab_link
+from release_scope._rows import RowDraft, first_deployed
from release_scope._settings import GitLabConfig, Settings, load_settings
-from tests.payloads import merge_request
+from tests.payloads import commit, merge_request
@pytest.mark.parametrize(
@@ -180,3 +182,60 @@ def test_gitlab_links_are_parsed_against_the_endpoint(url: str | None, expected:
actual: typing.Final = (change.kind, change.project, change.project_url, change.iid, change.sha) if change else None
assert actual == expected
+
+
+def _draft(*shas: str) -> RowDraft:
+ return RowDraft(
+ commits=[Commit.model_validate(commit(sha, sha, date="2026-09-01T00:00:00Z")) for sha in shas],
+ merge_requests=[],
+ )
+
+
+def _at(day: int) -> dt.datetime:
+ return dt.datetime(2026, 9, day, tzinfo=dt.UTC)
+
+
+def _deployment(sha: str, day: int) -> Deployment:
+ return Deployment.model_validate(
+ {
+ "id": day,
+ "ref": "main",
+ "sha": sha,
+ "created_at": "2026-09-01T00:00:00Z",
+ "deployable": {"finished_at": _at(day)},
+ }
+ )
+
+
+def test_a_row_is_first_deployed_by_the_earliest_deployment_of_it_or_a_newer_row() -> None:
+ drafts: typing.Final = [_draft("head"), _draft("c3"), _draft("c2"), _draft("c1"), _draft("c0b", "c0a")]
+
+ deployed: typing.Final = first_deployed(
+ drafts,
+ {
+ "preview": [
+ _deployment("c0a", 10),
+ _deployment("c2", 12),
+ _deployment("other", 11),
+ _deployment("c3", 13),
+ _deployment("c0a", 14),
+ ],
+ "staging": [_deployment("c1", 15)],
+ },
+ )
+
+ assert deployed == [
+ {},
+ {"preview": _at(13)},
+ {"preview": _at(12)},
+ {"preview": _at(12), "staging": _at(15)},
+ {"preview": _at(10), "staging": _at(15)},
+ ]
+
+
+def test_a_deployment_without_a_finished_job_counts_from_its_creation() -> None:
+ deployment: typing.Final = Deployment.model_validate(
+ {"id": 1, "ref": "main", "sha": "c1", "created_at": "2026-09-02T00:00:00Z", "deployable": None}
+ )
+
+ assert deployment.finished_at == _at(2)
diff --git a/tests/test_use_case.py b/tests/test_use_case.py
index 149aeca..f062a70 100644
--- a/tests/test_use_case.py
+++ b/tests/test_use_case.py
@@ -1,3 +1,4 @@
+import datetime as dt
import json
import typing
@@ -22,6 +23,7 @@
JIRA_ENDPOINT,
JIRA_ISSUE_API,
JIRA_ISSUES,
+ PREVIEW_DEPLOYMENTS,
PRODUCTION_DEPLOYMENT,
PUSH_PIPELINES,
SERVICE,
@@ -95,6 +97,54 @@ def test_only_rows_holding_a_deployed_commit_name_the_environment() -> None:
assert [row.environments for row in service.rows] == [[], ["preview"], [], [], []]
+def test_rows_carry_when_each_environment_first_ran_them(gitlab: respx.Router) -> None:
+ service: typing.Final = _only_service(_collect())
+
+ assert [row.first_deployed_at for row in service.rows] == [
+ {},
+ {"preview": dt.datetime(2026, 9, 22, tzinfo=dt.UTC)},
+ {"preview": dt.datetime(2026, 9, 21, 9, tzinfo=dt.UTC)},
+ {"preview": dt.datetime(2026, 9, 21, 9, tzinfo=dt.UTC)},
+ {"preview": dt.datetime(2026, 9, 19, 12, tzinfo=dt.UTC)},
+ ]
+ params: typing.Final = gitlab["deployments:preview"].calls.last.request.url.params
+ assert params["status"] == "success"
+ assert params["finished_after"] == "2026-09-18T00:00:00+00:00"
+ assert [
+ call.request.url.params["environment"]
+ for call in gitlab.calls
+ if call.request.url.path.endswith("/deployments") and "finished_after" in call.request.url.params
+ ] == ["preview"]
+
+
+def test_environment_shows_when_its_deploy_job_finished(gitlab: respx.Router) -> None:
+ gitlab["deploy:production"].respond(
+ json=[{**PRODUCTION_DEPLOYMENT, "deployable": {"tag": True, "finished_at": "2026-09-01T00:30:00+03:00"}}]
+ )
+
+ environments: typing.Final = _only_service(_collect()).environments
+
+ assert [item.deployed_at for item in environments] == [
+ dt.datetime(2026, 8, 31, 21, 30, tzinfo=dt.UTC),
+ dt.datetime(2026, 9, 22, tzinfo=dt.UTC),
+ ]
+
+
+def test_long_deployment_history_is_truncated_with_a_warning(gitlab: respx.Router) -> None:
+ gitlab["deployments:preview"].respond(json=PREVIEW_DEPLOYMENTS[:2], headers={"x-next-page": "2"})
+
+ service: typing.Final = _only_service(_collect())
+
+ assert service.warnings == [
+ Message(
+ code=MessageCode.DEPLOYMENTS_TRUNCATED,
+ params={"environment": "preview"},
+ text="Deployment history of preview was truncated; newer rows may miss when they reached it.",
+ )
+ ]
+ assert gitlab["deployments:preview"].call_count == 50
+
+
@pytest.mark.usefixtures("gitlab")
def test_tags_carry_their_latest_pipeline() -> None:
rows: typing.Final = _only_service(_collect()).rows
|