release-scope 0.5.0__tar.gz → 0.5.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {release_scope-0.5.0 → release_scope-0.5.2}/PKG-INFO +27 -17
  2. {release_scope-0.5.0 → release_scope-0.5.2}/README.md +25 -15
  3. {release_scope-0.5.0 → release_scope-0.5.2}/pyproject.toml +2 -2
  4. {release_scope-0.5.0 → release_scope-0.5.2}/pyproject.toml.orig +2 -2
  5. {release_scope-0.5.0 → release_scope-0.5.2}/release_scope/_candidates.py +7 -3
  6. {release_scope-0.5.0 → release_scope-0.5.2}/release_scope/_static/index.html +77 -14
  7. {release_scope-0.5.0 → release_scope-0.5.2}/release_scope/_use_case.py +13 -5
  8. {release_scope-0.5.0 → release_scope-0.5.2}/release_scope/__init__.py +0 -0
  9. {release_scope-0.5.0 → release_scope-0.5.2}/release_scope/__main__.py +0 -0
  10. {release_scope-0.5.0 → release_scope-0.5.2}/release_scope/_cache.py +0 -0
  11. {release_scope-0.5.0 → release_scope-0.5.2}/release_scope/_errors.py +0 -0
  12. {release_scope-0.5.0 → release_scope-0.5.2}/release_scope/_files.py +0 -0
  13. {release_scope-0.5.0 → release_scope-0.5.2}/release_scope/_gitlab.py +0 -0
  14. {release_scope-0.5.0 → release_scope-0.5.2}/release_scope/_jira.py +0 -0
  15. {release_scope-0.5.0 → release_scope-0.5.2}/release_scope/_jira_keys.py +0 -0
  16. {release_scope-0.5.0 → release_scope-0.5.2}/release_scope/_links.py +0 -0
  17. {release_scope-0.5.0 → release_scope-0.5.2}/release_scope/_messages.py +0 -0
  18. {release_scope-0.5.0 → release_scope-0.5.2}/release_scope/_report.py +0 -0
  19. {release_scope-0.5.0 → release_scope-0.5.2}/release_scope/_rows.py +0 -0
  20. {release_scope-0.5.0 → release_scope-0.5.2}/release_scope/_settings.py +0 -0
  21. {release_scope-0.5.0 → release_scope-0.5.2}/release_scope/_static/alpine.LICENSE.txt +0 -0
  22. {release_scope-0.5.0 → release_scope-0.5.2}/release_scope/_static/alpine.min.js +0 -0
  23. {release_scope-0.5.0 → release_scope-0.5.2}/release_scope/ioc.py +0 -0
  24. {release_scope-0.5.0 → release_scope-0.5.2}/release_scope/py.typed +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: release-scope
3
- Version: 0.5.0
4
- Summary: Collect what sits between production and the default branch across GitLab services: tags, MRs, Jira keys, failed jobs
3
+ Version: 0.5.2
4
+ Summary: Collect what sits between production and the default branch across GitLab services into a GitLab Pages site for picking release tags
5
5
  Keywords: release,gitlab,jira,deployments,merge-requests,ci,cli,python
6
6
  Author: Artur Shiriev
7
7
  Author-email: Artur Shiriev <me@shiriev.ru>
@@ -54,9 +54,9 @@ Description-Content-Type: text/markdown
54
54
  Jira issues, failed jobs.
55
55
 
56
56
  For every service it reads the latest successful production deployment, walks the default branch down to that
57
- commit, and writes a static site with one JSON report: a row per merge request or direct commit, newest first, with the tags that
58
- point into it, the environments running it, the Jira keys its MR mentions, and the failed jobs of its main-branch
59
- and tag pipelines. With a Jira token, it also reads the summary and status of every key in one batched search,
57
+ commit, or down to its first commit when the service was never deployed to production, and writes a static site with
58
+ one JSON report: a row per merge request or direct commit, newest first, with the tags that point into it, the
59
+ 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,
60
60
  and the GitLab merge requests and commits linked to each issue. GitLab's Jira integration adds those links to the
61
61
  issue's Web links whenever a commit or MR mentions it; a link counts only if it starts with
62
62
  `RELEASE_SCOPE_GITLAB__ENDPOINT`. The projects they point to are the issue's related services.
@@ -120,7 +120,8 @@ The report is versioned by `schema_version`; the models live in
120
120
  Top-level `jira` is `null` without a Jira token; otherwise it holds `issues` by key (summary, status, status category,
121
121
  issue type, linked GitLab changes), the `missing` keys Jira did not return, and an `error` if a Jira request failed.
122
122
  Each service lists its `candidates`: the tags a release could ship, newest first, each with its pipeline, the number of
123
- rows it ships, the in-scope Jira keys of those rows, and the compare link from production. One row, trimmed:
123
+ rows it ships, the in-scope Jira keys of those rows, and the compare link from production, or the tag's commit history
124
+ when the service has no production deployment. One row, trimmed:
124
125
 
125
126
  ```json
126
127
  {
@@ -140,22 +141,31 @@ Besides `report.json`, `collect` writes `index.html` and its script into the out
140
141
  installed package and change only with it, so the page always matches the report schema. The page loads
141
142
  `report.json` from next to itself; it needs a web server, not a `file://` URL.
142
143
 
143
- **Services** lists every service with a production deployment, and every service that failed to collect, as one
144
- line: what production runs, the picked tag, how many merge requests or commits and Jira tasks it ships, failed jobs,
145
- and a mark when the range was cut at `RELEASE_SCOPE_MAX_COMMITS`. Opening a line shows the service's environments,
146
- warnings, merge requests that are not merged yet, and its rows with tags and their pipelines, merge requests or
147
- commits, Jira keys, environments, and failed jobs; rows out of scope are dimmed. Each tag has a **pick** button:
144
+ **Services** lists every service with a production deployment or with rows, and every service that failed to collect,
145
+ as one line: what production runs, the picked tag, how many merge requests or commits and Jira tasks it ships, failed jobs
146
+ with the ones allowed to fail counted apart, and a mark when the range was cut at `RELEASE_SCOPE_MAX_COMMITS`.
147
+ Opening a line shows the service's environments, warnings, merge requests that are not merged yet, and its rows with
148
+ tags and their pipelines, merge requests or commits, Jira keys with their status, environments, and failed jobs; rows
149
+ out of scope are dimmed. Links open in a new tab. Each tag has a **pick** button:
148
150
  picking it highlights the rows it ships and closes the line again. A `--jira` report starts with each service's
149
- release tag picked. Services without a production deployment are left out of the page.
151
+ release tag picked. Services with neither a production deployment nor rows are left out of the page.
150
152
 
151
- **Release** at the bottom turns the picked tags into three lists, each with a copy button and a text box to copy from
152
- by hand, since browsers allow the copy button only over HTTPS:
153
+ The address keeps the picks after `#`, as `team/api=2.4.0&team/web=5.12.0`, so sharing or reloading the page keeps
154
+ them. An address with picks replaces the release tags of a `--jira` report; picks that are not in the report, such as
155
+ a tag that a later run no longer lists, are skipped with a warning. Clearing every pick empties the address, so a
156
+ reload starts from the release tags again.
157
+
158
+ **Release** at the bottom turns the picked tags into three lists and a release post, each with a copy button and a
159
+ text box to copy from by hand, since browsers allow the copy button only over HTTPS:
153
160
 
154
161
  - **Jira tasks**: the keys of every in-scope row from each picked tag down to production, without duplicates, with
155
- summary and status, flagging issues that are not done. Copy them one per line or as a JQL `key in (...)` clause,
156
- each in its own box.
162
+ summary and a status badge: grey to do, blue in progress, green done. Copy them one per line or as a JQL
163
+ `key in (...)` clause, each in its own box.
157
164
  - **Tag pipelines**: the pipeline of each picked tag, as a Markdown list.
158
- - **Compare**: a GitLab compare link per service from production to the picked tag, as a Markdown list.
165
+ - **Compare**: a GitLab compare link per service from production to the picked tag, as a Markdown list. A service
166
+ never deployed to production gets the commit history of the tag instead.
167
+ - **Release post**: one Markdown text with a line per picked tag, holding its compare link and pipeline, followed by
168
+ the Jira tasks with their summaries.
159
169
 
160
170
  ## GitLab Pages
161
171
 
@@ -21,9 +21,9 @@
21
21
  Jira issues, failed jobs.
22
22
 
23
23
  For every service it reads the latest successful production deployment, walks the default branch down to that
24
- commit, and writes a static site with one JSON report: a row per merge request or direct commit, newest first, with the tags that
25
- point into it, the environments running it, the Jira keys its MR mentions, and the failed jobs of its main-branch
26
- and tag pipelines. With a Jira token, it also reads the summary and status of every key in one batched search,
24
+ commit, or down to its first commit when the service was never deployed to production, and writes a static site with
25
+ one JSON report: a row per merge request or direct commit, newest first, with the tags that point into it, the
26
+ 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,
27
27
  and the GitLab merge requests and commits linked to each issue. GitLab's Jira integration adds those links to the
28
28
  issue's Web links whenever a commit or MR mentions it; a link counts only if it starts with
29
29
  `RELEASE_SCOPE_GITLAB__ENDPOINT`. The projects they point to are the issue's related services.
@@ -87,7 +87,8 @@ The report is versioned by `schema_version`; the models live in
87
87
  Top-level `jira` is `null` without a Jira token; otherwise it holds `issues` by key (summary, status, status category,
88
88
  issue type, linked GitLab changes), the `missing` keys Jira did not return, and an `error` if a Jira request failed.
89
89
  Each service lists its `candidates`: the tags a release could ship, newest first, each with its pipeline, the number of
90
- rows it ships, the in-scope Jira keys of those rows, and the compare link from production. One row, trimmed:
90
+ rows it ships, the in-scope Jira keys of those rows, and the compare link from production, or the tag's commit history
91
+ when the service has no production deployment. One row, trimmed:
91
92
 
92
93
  ```json
93
94
  {
@@ -107,22 +108,31 @@ Besides `report.json`, `collect` writes `index.html` and its script into the out
107
108
  installed package and change only with it, so the page always matches the report schema. The page loads
108
109
  `report.json` from next to itself; it needs a web server, not a `file://` URL.
109
110
 
110
- **Services** lists every service with a production deployment, and every service that failed to collect, as one
111
- line: what production runs, the picked tag, how many merge requests or commits and Jira tasks it ships, failed jobs,
112
- and a mark when the range was cut at `RELEASE_SCOPE_MAX_COMMITS`. Opening a line shows the service's environments,
113
- warnings, merge requests that are not merged yet, and its rows with tags and their pipelines, merge requests or
114
- commits, Jira keys, environments, and failed jobs; rows out of scope are dimmed. Each tag has a **pick** button:
111
+ **Services** lists every service with a production deployment or with rows, and every service that failed to collect,
112
+ as one line: what production runs, the picked tag, how many merge requests or commits and Jira tasks it ships, failed jobs
113
+ with the ones allowed to fail counted apart, and a mark when the range was cut at `RELEASE_SCOPE_MAX_COMMITS`.
114
+ Opening a line shows the service's environments, warnings, merge requests that are not merged yet, and its rows with
115
+ tags and their pipelines, merge requests or commits, Jira keys with their status, environments, and failed jobs; rows
116
+ out of scope are dimmed. Links open in a new tab. Each tag has a **pick** button:
115
117
  picking it highlights the rows it ships and closes the line again. A `--jira` report starts with each service's
116
- release tag picked. Services without a production deployment are left out of the page.
118
+ release tag picked. Services with neither a production deployment nor rows are left out of the page.
117
119
 
118
- **Release** at the bottom turns the picked tags into three lists, each with a copy button and a text box to copy from
119
- by hand, since browsers allow the copy button only over HTTPS:
120
+ The address keeps the picks after `#`, as `team/api=2.4.0&team/web=5.12.0`, so sharing or reloading the page keeps
121
+ them. An address with picks replaces the release tags of a `--jira` report; picks that are not in the report, such as
122
+ a tag that a later run no longer lists, are skipped with a warning. Clearing every pick empties the address, so a
123
+ reload starts from the release tags again.
124
+
125
+ **Release** at the bottom turns the picked tags into three lists and a release post, each with a copy button and a
126
+ text box to copy from by hand, since browsers allow the copy button only over HTTPS:
120
127
 
121
128
  - **Jira tasks**: the keys of every in-scope row from each picked tag down to production, without duplicates, with
122
- summary and status, flagging issues that are not done. Copy them one per line or as a JQL `key in (...)` clause,
123
- each in its own box.
129
+ summary and a status badge: grey to do, blue in progress, green done. Copy them one per line or as a JQL
130
+ `key in (...)` clause, each in its own box.
124
131
  - **Tag pipelines**: the pipeline of each picked tag, as a Markdown list.
125
- - **Compare**: a GitLab compare link per service from production to the picked tag, as a Markdown list.
132
+ - **Compare**: a GitLab compare link per service from production to the picked tag, as a Markdown list. A service
133
+ never deployed to production gets the commit history of the tag instead.
134
+ - **Release post**: one Markdown text with a line per picked tag, holding its compare link and pipeline, followed by
135
+ the Jira tasks with their summaries.
126
136
 
127
137
  ## GitLab Pages
128
138
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "release-scope"
3
- description = "Collect what sits between production and the default branch across GitLab services: tags, MRs, Jira keys, failed jobs"
3
+ description = "Collect what sits between production and the default branch across GitLab services into a GitLab Pages site for picking release tags"
4
4
  requires-python = ">=3.11,<4"
5
5
  license = "MIT"
6
6
  readme = "README.md"
@@ -25,7 +25,7 @@ classifiers = [
25
25
  "Typing :: Typed",
26
26
  "Topic :: Software Development :: Build Tools",
27
27
  ]
28
- version = "0.5.0"
28
+ version = "0.5.2"
29
29
  dependencies = [
30
30
  "typer>=0.13",
31
31
  "pydantic>=2; python_version < '3.12'",
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "release-scope"
3
- description = "Collect what sits between production and the default branch across GitLab services: tags, MRs, Jira keys, failed jobs"
3
+ description = "Collect what sits between production and the default branch across GitLab services into a GitLab Pages site for picking release tags"
4
4
  authors = [{ name = "Artur Shiriev", email = "me@shiriev.ru" }]
5
5
  requires-python = ">=3.11,<4"
6
6
  license = "MIT"
@@ -17,7 +17,7 @@ classifiers = [
17
17
  "Typing :: Typed",
18
18
  "Topic :: Software Development :: Build Tools",
19
19
  ]
20
- version = "0.5.0"
20
+ version = "0.5.2"
21
21
  dependencies = [
22
22
  "typer>=0.13",
23
23
  # The first release whose pydantic-core ships a wheel for each interpreter.
@@ -3,8 +3,10 @@ from urllib.parse import quote
3
3
  from release_scope._report import Candidate, EnvironmentState, Service
4
4
 
5
5
 
6
- def build_candidates(service: Service, production: EnvironmentState) -> list[Candidate]:
7
- base: str = quote(production.ref if production.tag else production.sha)
6
+ def build_candidates(service: Service, production: EnvironmentState | None) -> list[Candidate]:
7
+ base: str | None = None
8
+ if production is not None:
9
+ base = quote(production.ref if production.tag else production.sha)
8
10
  candidates: list[Candidate] = []
9
11
  for index, row in enumerate(service.rows):
10
12
  shipped = service.rows[index:]
@@ -12,7 +14,9 @@ def build_candidates(service: Service, production: EnvironmentState) -> list[Can
12
14
  candidates.extend(
13
15
  Candidate(
14
16
  tag=tag,
15
- compare_url=f"{service.project_url}/-/compare/{base}...{quote(tag.name)}",
17
+ compare_url=f"{service.project_url}/-/compare/{base}...{quote(tag.name)}"
18
+ if base
19
+ else f"{service.project_url}/-/commits/{quote(tag.name)}",
16
20
  rows=len(shipped),
17
21
  jira_keys=list(keys.values()),
18
22
  )
@@ -4,18 +4,19 @@
4
4
  <meta charset="utf-8">
5
5
  <meta name="viewport" content="width=device-width, initial-scale=1">
6
6
  <title>Release scope</title>
7
+ <base target="_blank">
7
8
  <script>
8
9
  if (!/(\/|\.html)$/.test(location.pathname)) location.replace(location.pathname + "/" + location.search + location.hash);
9
10
  </script>
10
11
  <style>
11
12
  :root {
12
13
  --bg: #ffffff; --fg: #1f2328; --muted: #656d76; --border: #d0d7de; --panel: #f6f8fa;
13
- --accent: #0969da; --bad: #cf222e; --warn: #9a6700;
14
+ --accent: #0969da; --bad: #cf222e; --warn: #9a6700; --good: #1a7f37;
14
15
  }
15
16
  @media (prefers-color-scheme: dark) {
16
17
  :root {
17
18
  --bg: #0d1117; --fg: #e6edf3; --muted: #8d96a0; --border: #30363d; --panel: #161b22;
18
- --accent: #4493f8; --bad: #f85149; --warn: #d29922;
19
+ --accent: #4493f8; --bad: #f85149; --warn: #d29922; --good: #3fb950;
19
20
  }
20
21
  }
21
22
  [x-cloak] { display: none !important; }
@@ -43,6 +44,7 @@
43
44
  .warn { color: var(--warn); }
44
45
  .scroll { overflow-x: auto; }
45
46
  .outputs { display: grid; grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); gap: 12px; margin-top: 16px; }
47
+ .outputs .wide { grid-column: 1 / -1; }
46
48
  .out-head { display: flex; justify-content: space-between; align-items: center; gap: 8px; flex-wrap: wrap; }
47
49
  tr.out-of-scope td { opacity: 0.55; }
48
50
  tr.shipped td { background: color-mix(in srgb, var(--accent) 12%, transparent); }
@@ -57,6 +59,10 @@
57
59
  .service .body { padding: 8px 12px; }
58
60
  .service .body > p:first-child { margin-top: 0; }
59
61
  .picked-tag { font-weight: 600; }
62
+ .status { display: inline-block; padding: 0 8px; border: 1px solid currentColor; border-radius: 10px; font-size: 12px;
63
+ font-weight: 600; white-space: nowrap; color: var(--muted); }
64
+ .status-indeterminate { color: var(--accent); background: color-mix(in srgb, var(--accent) 12%, transparent); }
65
+ .status-done { color: var(--good); background: color-mix(in srgb, var(--good) 12%, transparent); }
60
66
  button.picked { border-color: var(--accent); color: var(--accent); }
61
67
  </style>
62
68
  </head>
@@ -75,7 +81,8 @@
75
81
  <template x-for="key in report.jira_scope" :key="key">
76
82
  <li>
77
83
  <a :href="issue(key) && issue(key).url" x-text="key"></a>
78
- <span x-text="issue(key) ? issue(key).summary + ' · ' + issue(key).status : 'not found in Jira'"></span>
84
+ <span x-show="issue(key)" :class="statusClass(issue(key))" x-text="issue(key) && issue(key).status"></span>
85
+ <span x-text="issue(key) ? issue(key).summary : 'not found in Jira'"></span>
79
86
  </li>
80
87
  </template>
81
88
  </ul>
@@ -84,8 +91,9 @@
84
91
  </template>
85
92
 
86
93
  <h2>Services</h2>
87
- <p x-show="pickable.length" class="muted">Open a service and pick the tag to release: it ships every merge request and commit from its row down to production.</p>
88
- <template x-for="service in deployed" :key="service.project">
94
+ <p x-show="pickable.length" class="muted">Open a service and pick the tag to release: it ships every merge request and commit from its row down to production. The address keeps the picks, so share it to share them.</p>
95
+ <p x-show="ignored" class="warn" x-text="ignored"></p>
96
+ <template x-for="service in listed" :key="service.project">
89
97
  <details class="service" name="services">
90
98
  <summary>
91
99
  <span class="name" x-text="service.project"></span>
@@ -155,7 +163,8 @@
155
163
  <template x-for="key in row.jira_keys" :key="key.key">
156
164
  <div>
157
165
  <a :href="key.url" x-text="key.key"></a>
158
- <span x-text="issue(key.key) ? issue(key.key).summary + ' · ' + issue(key.key).status : ''"></span>
166
+ <span x-show="issue(key.key)" :class="statusClass(issue(key.key))" x-text="issue(key.key) && issue(key.key).status"></span>
167
+ <span x-text="issue(key.key) ? issue(key.key).summary : ''"></span>
159
168
  </div>
160
169
  </template>
161
170
  </td>
@@ -165,7 +174,7 @@
165
174
  <div>
166
175
  <span x-text="line.label"></span>
167
176
  <a :href="line.url" x-text="line.name"></a>
168
- <span x-show="line.allowed" class="muted">(allowed)</span>
177
+ <span x-show="line.allowed" class="muted">(allowed to fail)</span>
169
178
  </div>
170
179
  </template>
171
180
  </td>
@@ -199,8 +208,8 @@
199
208
  <template x-for="task in tasks" :key="task.key">
200
209
  <li>
201
210
  <a :href="task.url" x-text="task.key"></a>
202
- <span x-text="task.issue ? task.issue.summary + ' · ' + task.issue.status : ''"></span>
203
- <span x-show="task.issue && task.issue.status_category !== 'done'" class="warn">not done</span>
211
+ <span x-show="task.issue" :class="statusClass(task.issue)" x-text="task.issue && task.issue.status"></span>
212
+ <span x-text="task.issue ? task.issue.summary : ''"></span>
204
213
  <span x-show="isMissing(task.key)" class="warn">not found in Jira</span>
205
214
  </li>
206
215
  </template>
@@ -242,6 +251,13 @@
242
251
  </ul>
243
252
  <textarea readonly x-text="compareMarkdown"></textarea>
244
253
  </section>
254
+ <section class="wide">
255
+ <div class="out-head">
256
+ <h3>Release post</h3>
257
+ <button type="button" @click="copy(releasePost)">Copy</button>
258
+ </div>
259
+ <textarea readonly rows="12" x-text="releasePost"></textarea>
260
+ </section>
245
261
  </div>
246
262
 
247
263
  <p class="muted">Legend: ✅ success · ❌ failed · 🔄 running · ⏭ canceled or skipped · ⚠️ no pipeline</p>
@@ -259,6 +275,7 @@
259
275
  report: null,
260
276
  failure: "",
261
277
  notice: "",
278
+ ignored: "",
262
279
  picks: {},
263
280
 
264
281
  async init() {
@@ -271,6 +288,8 @@
271
288
  this.picks[service.project] = service.candidates.some((item) => item.tag.name === tag) ? tag : "";
272
289
  }
273
290
  this.report = report;
291
+ this.readHash();
292
+ window.addEventListener("hashchange", () => this.readHash());
274
293
  } catch (error) {
275
294
  this.failure = `Cannot load the report: ${error.message}.`;
276
295
  }
@@ -279,11 +298,11 @@
279
298
  get collectedAt() {
280
299
  return this.report.collected_at.slice(0, 16).replace("T", " ");
281
300
  },
282
- get deployed() {
283
- return this.report.services.filter((service) => service.error || this.production(service));
301
+ get listed() {
302
+ return this.report.services.filter((service) => service.error || this.production(service) || service.rows.length);
284
303
  },
285
304
  get pickable() {
286
- return this.deployed.filter((service) => service.candidates.length);
305
+ return this.listed.filter((service) => service.candidates.length);
287
306
  },
288
307
  get problems() {
289
308
  const jira = this.report.jira;
@@ -321,6 +340,16 @@
321
340
  })
322
341
  .join("\n");
323
342
  },
343
+ get releasePost() {
344
+ const services = this.picked.map((item) => {
345
+ const pipeline = item.candidate.tag.pipeline;
346
+ const build = pipeline ? `[pipeline #${pipeline.id}](${pipeline.url}) ${pipeline.status}` : "no pipeline";
347
+ const compare = `[${this.compareLabel(item)}](${item.candidate.compare_url})`;
348
+ return `- ${item.service.project} ${item.candidate.tag.name}: ${compare} · ${build}`;
349
+ });
350
+ const tasks = this.tasks.map((task) => `- [${task.key}](${task.url})${task.issue ? " " + task.issue.summary : ""}`);
351
+ return ["**Services**", ...services, "", `**Jira tasks (${tasks.length})**`, ...(tasks.length ? tasks : ["- none"])].join("\n");
352
+ },
324
353
  get compareMarkdown() {
325
354
  return this.picked
326
355
  .map((item) => `- ${item.service.project}: [${this.compareLabel(item)}](${item.candidate.compare_url})`)
@@ -330,6 +359,36 @@
330
359
  pick(service, tag, element) {
331
360
  this.picks[service.project] = tag;
332
361
  element.closest("details").open = false;
362
+ this.writeHash();
363
+ },
364
+ readHash() {
365
+ const decode = (value) => {
366
+ try {
367
+ return decodeURIComponent(value);
368
+ } catch {
369
+ return value;
370
+ }
371
+ };
372
+ const wanted = new Map(
373
+ location.hash.slice(1).split("&").filter(Boolean)
374
+ .map((part) => part.split("=", 2).map(decode)),
375
+ );
376
+ if (wanted.size) {
377
+ for (const service of this.pickable) {
378
+ const tag = wanted.get(service.project) || "";
379
+ const known = !tag || service.candidates.some((item) => item.tag.name === tag);
380
+ this.picks[service.project] = known ? tag : "";
381
+ if (known) wanted.delete(service.project);
382
+ }
383
+ }
384
+ const unknown = [...wanted].map(([project, tag]) => (tag ? `${project} ${tag}` : project));
385
+ this.ignored = unknown.length ? `Ignored picks from the address that are not in this report: ${unknown.join(", ")}.` : "";
386
+ this.writeHash();
387
+ },
388
+ writeHash() {
389
+ const encode = (value) => encodeURIComponent(value).replaceAll("%2F", "/");
390
+ const hash = this.picked.map(({ service, candidate }) => `${encode(service.project)}=${encode(candidate.tag.name)}`).join("&");
391
+ history.replaceState(null, "", hash ? `#${hash}` : location.pathname + location.search);
333
392
  },
334
393
  pickOf(service) {
335
394
  return service.candidates.find((item) => item.tag.name === this.picks[service.project]);
@@ -359,6 +418,9 @@
359
418
  issue(key) {
360
419
  return this.report.jira ? this.report.jira.issues[key] : undefined;
361
420
  },
421
+ statusClass(issue) {
422
+ return issue ? `status status-${issue.status_category || "new"}` : "";
423
+ },
362
424
  isMissing(key) {
363
425
  return Boolean(this.report.jira && this.report.jira.missing.includes(key));
364
426
  },
@@ -370,10 +432,11 @@
370
432
  },
371
433
  productionRef(service) {
372
434
  const production = this.production(service);
373
- if (!production) return "";
435
+ if (!production) return "not in production";
374
436
  return production.tag ? production.ref : production.sha.slice(0, 8);
375
437
  },
376
438
  compareLabel({ service, candidate }) {
439
+ if (!this.production(service)) return `commits to ${candidate.tag.name}`;
377
440
  return `${this.productionRef(service)}...${candidate.tag.name}`;
378
441
  },
379
442
  pipelineIcon(pipeline) {
@@ -387,7 +450,7 @@
387
450
  const jobs = pipelines.filter(Boolean).flatMap((pipeline) => pipeline.failed_jobs);
388
451
  const allowed = jobs.filter((job) => job.allow_failure).length;
389
452
  const blocking = jobs.length - allowed;
390
- return [blocking ? `❌ ${blocking}` : "", allowed ? `⚠️ ${allowed} allowed` : ""].filter(Boolean).join(" · ");
453
+ return [blocking ? `❌ ${blocking} failed` : "", allowed ? `${allowed} failed, allowed to fail` : ""].filter(Boolean).join(" · ");
391
454
  },
392
455
  failedJobs(row) {
393
456
  const lines = [];
@@ -251,10 +251,14 @@ class CollectUseCase:
251
251
  service.warnings.append("Project has no default branch.")
252
252
  return service
253
253
  if production is None:
254
- service.warnings.append(f"No successful deployment to '{self.settings.production_environment}'.")
255
- return service
254
+ service.warnings.append(
255
+ f"No successful deployment to '{self.settings.production_environment}'; "
256
+ f"rows run from the first commit of {project.default_branch}."
257
+ )
256
258
 
257
- walk: typing.Final = self._walk(project, project.default_branch, production.sha, service, cache)
259
+ walk: typing.Final = self._walk(
260
+ project, project.default_branch, production.sha if production else None, service, cache
261
+ )
258
262
  drafts, linked, in_scope = walk.drafts, [False] * len(walk.drafts), [True] * len(walk.drafts)
259
263
  if links is not None:
260
264
  drafts, linked, in_scope = self._scope_rows(project, project.default_branch, service, walk, links, cache)
@@ -272,9 +276,13 @@ class CollectUseCase:
272
276
  service.candidates.extend(build_candidates(service, production))
273
277
  return service
274
278
 
275
- def _walk(self, project: Project, default_branch: str, baseline: str, service: Service, cache: Cache) -> _Walk:
279
+ def _walk(
280
+ self, project: Project, default_branch: str, baseline: str | None, service: Service, cache: Cache
281
+ ) -> _Walk:
276
282
  commits, truncated = self.api.list_first_parent_commits(
277
- project.id, f"{baseline}..{default_branch}", max_items=self.settings.max_commits
283
+ project.id,
284
+ f"{baseline}..{default_branch}" if baseline else default_branch,
285
+ max_items=self.settings.max_commits,
278
286
  )
279
287
  walk: typing.Final = _Walk(truncated=truncated)
280
288
  service.truncated = truncated