release-scope 0.5.2__tar.gz → 0.5.3__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.2 → release_scope-0.5.3}/PKG-INFO +5 -1
  2. {release_scope-0.5.2 → release_scope-0.5.3}/README.md +4 -0
  3. {release_scope-0.5.2 → release_scope-0.5.3}/pyproject.toml +1 -1
  4. {release_scope-0.5.2 → release_scope-0.5.3}/pyproject.toml.orig +1 -1
  5. {release_scope-0.5.2 → release_scope-0.5.3}/release_scope/_static/index.html +263 -60
  6. {release_scope-0.5.2 → release_scope-0.5.3}/release_scope/__init__.py +0 -0
  7. {release_scope-0.5.2 → release_scope-0.5.3}/release_scope/__main__.py +0 -0
  8. {release_scope-0.5.2 → release_scope-0.5.3}/release_scope/_cache.py +0 -0
  9. {release_scope-0.5.2 → release_scope-0.5.3}/release_scope/_candidates.py +0 -0
  10. {release_scope-0.5.2 → release_scope-0.5.3}/release_scope/_errors.py +0 -0
  11. {release_scope-0.5.2 → release_scope-0.5.3}/release_scope/_files.py +0 -0
  12. {release_scope-0.5.2 → release_scope-0.5.3}/release_scope/_gitlab.py +0 -0
  13. {release_scope-0.5.2 → release_scope-0.5.3}/release_scope/_jira.py +0 -0
  14. {release_scope-0.5.2 → release_scope-0.5.3}/release_scope/_jira_keys.py +0 -0
  15. {release_scope-0.5.2 → release_scope-0.5.3}/release_scope/_links.py +0 -0
  16. {release_scope-0.5.2 → release_scope-0.5.3}/release_scope/_messages.py +0 -0
  17. {release_scope-0.5.2 → release_scope-0.5.3}/release_scope/_report.py +0 -0
  18. {release_scope-0.5.2 → release_scope-0.5.3}/release_scope/_rows.py +0 -0
  19. {release_scope-0.5.2 → release_scope-0.5.3}/release_scope/_settings.py +0 -0
  20. {release_scope-0.5.2 → release_scope-0.5.3}/release_scope/_static/alpine.LICENSE.txt +0 -0
  21. {release_scope-0.5.2 → release_scope-0.5.3}/release_scope/_static/alpine.min.js +0 -0
  22. {release_scope-0.5.2 → release_scope-0.5.3}/release_scope/_use_case.py +0 -0
  23. {release_scope-0.5.2 → release_scope-0.5.3}/release_scope/ioc.py +0 -0
  24. {release_scope-0.5.2 → release_scope-0.5.3}/release_scope/py.typed +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: release-scope
3
- Version: 0.5.2
3
+ Version: 0.5.3
4
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
@@ -141,6 +141,10 @@ Besides `report.json`, `collect` writes `index.html` and its script into the out
141
141
  installed package and change only with it, so the page always matches the report schema. The page loads
142
142
  `report.json` from next to itself; it needs a web server, not a `file://` URL.
143
143
 
144
+ Switches at the top pick the language, English or Russian, and the theme: as in the system, light, or dark. The page
145
+ starts in the browser's language and remembers both choices in the browser. The copied lists and release post follow
146
+ the language; warnings and errors from `collect`, and texts from GitLab and Jira, stay as written.
147
+
144
148
  **Services** lists every service with a production deployment or with rows, and every service that failed to collect,
145
149
  as one line: what production runs, the picked tag, how many merge requests or commits and Jira tasks it ships, failed jobs
146
150
  with the ones allowed to fail counted apart, and a mark when the range was cut at `RELEASE_SCOPE_MAX_COMMITS`.
@@ -108,6 +108,10 @@ Besides `report.json`, `collect` writes `index.html` and its script into the out
108
108
  installed package and change only with it, so the page always matches the report schema. The page loads
109
109
  `report.json` from next to itself; it needs a web server, not a `file://` URL.
110
110
 
111
+ Switches at the top pick the language, English or Russian, and the theme: as in the system, light, or dark. The page
112
+ starts in the browser's language and remembers both choices in the browser. The copied lists and release post follow
113
+ the language; warnings and errors from `collect`, and texts from GitLab and Jira, stay as written.
114
+
111
115
  **Services** lists every service with a production deployment or with rows, and every service that failed to collect,
112
116
  as one line: what production runs, the picked tag, how many merge requests or commits and Jira tasks it ships, failed jobs
113
117
  with the ones allowed to fail counted apart, and a mark when the range was cut at `RELEASE_SCOPE_MAX_COMMITS`.
@@ -25,7 +25,7 @@ classifiers = [
25
25
  "Typing :: Typed",
26
26
  "Topic :: Software Development :: Build Tools",
27
27
  ]
28
- version = "0.5.2"
28
+ version = "0.5.3"
29
29
  dependencies = [
30
30
  "typer>=0.13",
31
31
  "pydantic>=2; python_version < '3.12'",
@@ -17,7 +17,7 @@ classifiers = [
17
17
  "Typing :: Typed",
18
18
  "Topic :: Software Development :: Build Tools",
19
19
  ]
20
- version = "0.5.2"
20
+ version = "0.5.3"
21
21
  dependencies = [
22
22
  "typer>=0.13",
23
23
  # The first release whose pydantic-core ships a wheel for each interpreter.
@@ -7,18 +7,29 @@
7
7
  <base target="_blank">
8
8
  <script>
9
9
  if (!/(\/|\.html)$/.test(location.pathname)) location.replace(location.pathname + "/" + location.search + location.hash);
10
+ try {
11
+ const theme = localStorage.getItem("release-scope-theme");
12
+ if (theme === "light" || theme === "dark") document.documentElement.dataset.theme = theme;
13
+ } catch {}
10
14
  </script>
11
15
  <style>
12
16
  :root {
13
17
  --bg: #ffffff; --fg: #1f2328; --muted: #656d76; --border: #d0d7de; --panel: #f6f8fa;
14
18
  --accent: #0969da; --bad: #cf222e; --warn: #9a6700; --good: #1a7f37;
19
+ color-scheme: light;
15
20
  }
16
21
  @media (prefers-color-scheme: dark) {
17
- :root {
22
+ :root:not([data-theme="light"]) {
18
23
  --bg: #0d1117; --fg: #e6edf3; --muted: #8d96a0; --border: #30363d; --panel: #161b22;
19
24
  --accent: #4493f8; --bad: #f85149; --warn: #d29922; --good: #3fb950;
25
+ color-scheme: dark;
20
26
  }
21
27
  }
28
+ :root[data-theme="dark"] {
29
+ --bg: #0d1117; --fg: #e6edf3; --muted: #8d96a0; --border: #30363d; --panel: #161b22;
30
+ --accent: #4493f8; --bad: #f85149; --warn: #d29922; --good: #3fb950;
31
+ color-scheme: dark;
32
+ }
22
33
  [x-cloak] { display: none !important; }
23
34
  * { box-sizing: border-box; }
24
35
  body { margin: 0; background: var(--bg); color: var(--fg); font: 14px/1.5 system-ui, sans-serif; }
@@ -37,8 +48,10 @@
37
48
  summary { cursor: pointer; }
38
49
  textarea { width: 100%; min-height: 72px; font: 12px ui-monospace, monospace; background: var(--panel);
39
50
  color: var(--fg); border: 1px solid var(--border); border-radius: 6px; padding: 6px; resize: vertical; }
40
- button { font: inherit; padding: 2px 10px; border: 1px solid var(--border); border-radius: 6px;
51
+ button, select { font: inherit; padding: 2px 10px; border: 1px solid var(--border); border-radius: 6px;
41
52
  background: var(--panel); color: var(--fg); cursor: pointer; }
53
+ .toolbar { display: flex; justify-content: flex-end; flex-wrap: wrap; gap: 8px; }
54
+ .toolbar label { display: flex; align-items: center; gap: 6px; color: var(--muted); }
42
55
  .muted { color: var(--muted); }
43
56
  .bad { color: var(--bad); }
44
57
  .warn { color: var(--warn); }
@@ -68,21 +81,36 @@
68
81
  </head>
69
82
  <body>
70
83
  <main x-data="releaseScope" x-cloak>
71
- <p x-show="failure" class="bad" x-text="failure"></p>
84
+ <div class="toolbar">
85
+ <label><span x-text="t('language')"></span>
86
+ <select :value="lang" @change="setLang($event.target.value)">
87
+ <option value="en">English</option>
88
+ <option value="ru">Русский</option>
89
+ </select>
90
+ </label>
91
+ <label><span x-text="t('theme')"></span>
92
+ <select :value="theme" @change="setTheme($event.target.value)">
93
+ <option value="auto" x-text="t('themeAuto')"></option>
94
+ <option value="light" x-text="t('themeLight')"></option>
95
+ <option value="dark" x-text="t('themeDark')"></option>
96
+ </select>
97
+ </label>
98
+ </div>
99
+ <p x-show="failure" class="bad" x-text="t('loadFailed', { error: failure })"></p>
72
100
  <template x-if="report">
73
101
  <div>
74
- <h1 x-text="report.jira_scope.length ? 'Release scope: ' + report.jira_scope.join(', ') : 'Release scope'"></h1>
102
+ <h1 x-text="report.jira_scope.length ? t('titleIssues', { keys: report.jira_scope.join(', ') }) : t('title')"></h1>
75
103
  <p class="muted">
76
- Collected <span x-text="collectedAt"></span> UTC. Changes run from the commit on
77
- <code x-text="report.production_environment"></code> to the head of the default branch<span
78
- x-text="report.jira_scope.length ? '; rows above the latest merge request or commit linked to the issues are out of scope.' : '.'"></span>
104
+ <span x-text="t('rangeStart', { at: collectedAt })"></span>
105
+ <code x-text="report.production_environment"></code>
106
+ <span x-text="report.jira_scope.length ? t('rangeEndIssues') : t('rangeEnd')"></span>
79
107
  </p>
80
108
  <ul x-show="report.jira_scope.length">
81
109
  <template x-for="key in report.jira_scope" :key="key">
82
110
  <li>
83
111
  <a :href="issue(key) && issue(key).url" x-text="key"></a>
84
112
  <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>
113
+ <span x-text="issue(key) ? issue(key).summary : t('notInJira')"></span>
86
114
  </li>
87
115
  </template>
88
116
  </ul>
@@ -90,14 +118,14 @@
90
118
  <p class="bad" x-text="message"></p>
91
119
  </template>
92
120
 
93
- <h2>Services</h2>
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>
121
+ <h2 x-text="t('services')"></h2>
122
+ <p x-show="pickable.length" class="muted" x-text="t('servicesIntro')"></p>
123
+ <p x-show="ignored.length" class="warn" x-text="t('ignored', { picks: ignored.join(', ') })"></p>
96
124
  <template x-for="service in listed" :key="service.project">
97
125
  <details class="service" name="services">
98
126
  <summary>
99
127
  <span class="name" x-text="service.project"></span>
100
- <span x-show="service.error" class="bad">failed to collect</span>
128
+ <span x-show="service.error" class="bad" x-text="t('failedToCollect')"></span>
101
129
  <span x-show="!service.error">
102
130
  <span class="muted" x-text="productionRef(service) + ' →'"></span>
103
131
  <span x-show="pickOf(service)" class="picked-tag" x-text="picks[service.project]"></span>
@@ -105,7 +133,7 @@
105
133
  </span>
106
134
  <span class="muted" x-text="lineDetails(service)"></span>
107
135
  <span x-text="failureCounts(service)"></span>
108
- <span x-show="service.truncated" class="warn" title="Older merge requests and commits were not collected; open for details">range cut short</span>
136
+ <span x-show="service.truncated" class="warn" :title="t('rangeCutTitle')" x-text="t('rangeCut')"></span>
109
137
  </summary>
110
138
  <div class="body">
111
139
  <p>
@@ -119,7 +147,7 @@
119
147
  <p class="warn" x-text="warning"></p>
120
148
  </template>
121
149
  <p x-show="service.release && service.release.pending_merge_requests.length">
122
- Not merged:
150
+ <span x-text="t('notMerged')"></span>
123
151
  <template x-for="item in service.release ? service.release.pending_merge_requests : []" :key="item.iid">
124
152
  <a :href="item.url" x-text="'!' + item.iid + ' ' + item.title + ' '"></a>
125
153
  </template>
@@ -127,7 +155,10 @@
127
155
  <div class="scroll" x-show="service.rows.length">
128
156
  <table>
129
157
  <thead>
130
- <tr><th>Tag</th><th>Change</th><th>Jira</th><th>Deployed to</th><th>Failed jobs</th></tr>
158
+ <tr>
159
+ <th x-text="t('columnTag')"></th><th x-text="t('columnChange')"></th><th>Jira</th>
160
+ <th x-text="t('columnDeployed')"></th><th x-text="t('columnFailed')"></th>
161
+ </tr>
131
162
  </thead>
132
163
  <tbody>
133
164
  <template x-for="(row, index) in service.rows" :key="index">
@@ -137,14 +168,14 @@
137
168
  <div class="tag">
138
169
  <button type="button" :class="{ picked: picks[service.project] === tag.name }"
139
170
  @click="pick(service, tag.name, $el)"
140
- x-text="picks[service.project] === tag.name ? 'picked' : 'pick'"></button>
171
+ x-text="picks[service.project] === tag.name ? t('picked') : t('pick')"></button>
141
172
  <a :href="tag.pipeline ? tag.pipeline.url : tag.url" x-text="tag.name"></a>
142
173
  <span x-text="pipelineIcon(tag.pipeline)"></span>
143
174
  </div>
144
175
  </template>
145
176
  </td>
146
177
  <td>
147
- <span x-show="row.linked" title="Linked to the issues">🎯</span>
178
+ <span x-show="row.linked" :title="t('linked')">🎯</span>
148
179
  <template x-for="item in row.merge_requests" :key="item.iid">
149
180
  <div>
150
181
  <a :href="item.url" x-text="'!' + item.iid"></a>
@@ -157,7 +188,7 @@
157
188
  <span x-text="item.title + (item.author ? ' · ' + item.author : '')"></span>
158
189
  </div>
159
190
  </template>
160
- <span x-show="!row.in_scope" class="muted">out of scope</span>
191
+ <span x-show="!row.in_scope" class="muted" x-text="t('outOfScope')"></span>
161
192
  </td>
162
193
  <td>
163
194
  <template x-for="key in row.jira_keys" :key="key.key">
@@ -174,7 +205,7 @@
174
205
  <div>
175
206
  <span x-text="line.label"></span>
176
207
  <a :href="line.url" x-text="line.name"></a>
177
- <span x-show="line.allowed" class="muted">(allowed to fail)</span>
208
+ <span x-show="line.allowed" class="muted" x-text="t('allowedToFail')"></span>
178
209
  </div>
179
210
  </template>
180
211
  </td>
@@ -184,24 +215,24 @@
184
215
  </table>
185
216
  </div>
186
217
  <p x-show="pickOf(service)">
187
- <button type="button" @click="pick(service, '', $el)">Clear pick</button>
218
+ <button type="button" @click="pick(service, '', $el)" x-text="t('clearPick')"></button>
188
219
  </p>
189
220
  </div>
190
221
  </details>
191
222
  </template>
192
223
 
193
- <h2>Release</h2>
194
- <p x-show="!pickable.length" class="muted">No service has a tag between production and the head of the default branch.</p>
195
- <p x-show="pickable.length && !picked.length" class="muted">Pick a tag in a service above to fill the lists.</p>
196
- <p x-show="notice" class="warn" x-text="notice"></p>
224
+ <h2 x-text="t('release')"></h2>
225
+ <p x-show="!pickable.length" class="muted" x-text="t('noCandidates')"></p>
226
+ <p x-show="pickable.length && !picked.length" class="muted" x-text="t('pickToFill')"></p>
227
+ <p x-show="copyFailed" class="warn" x-text="t('copyFailed')"></p>
197
228
 
198
229
  <div class="outputs" x-show="picked.length">
199
230
  <section>
200
231
  <div class="out-head">
201
- <h3 x-text="'Jira tasks (' + tasks.length + ')'"></h3>
232
+ <h3 x-text="t('tasks', { count: tasks.length })"></h3>
202
233
  <span>
203
- <button type="button" @click="copy(taskKeys)">Copy keys</button>
204
- <button type="button" @click="copy(taskJql)">Copy JQL</button>
234
+ <button type="button" @click="copy(taskKeys)" x-text="t('copyKeys')"></button>
235
+ <button type="button" @click="copy(taskJql)" x-text="t('copyJql')"></button>
205
236
  </span>
206
237
  </div>
207
238
  <ul>
@@ -210,7 +241,7 @@
210
241
  <a :href="task.url" x-text="task.key"></a>
211
242
  <span x-show="task.issue" :class="statusClass(task.issue)" x-text="task.issue && task.issue.status"></span>
212
243
  <span x-text="task.issue ? task.issue.summary : ''"></span>
213
- <span x-show="isMissing(task.key)" class="warn">not found in Jira</span>
244
+ <span x-show="isMissing(task.key)" class="warn" x-text="t('notInJira')"></span>
214
245
  </li>
215
246
  </template>
216
247
  </ul>
@@ -219,8 +250,8 @@
219
250
  </section>
220
251
  <section>
221
252
  <div class="out-head">
222
- <h3>Tag pipelines</h3>
223
- <button type="button" @click="copy(pipelinesMarkdown)">Copy</button>
253
+ <h3 x-text="t('tagPipelines')"></h3>
254
+ <button type="button" @click="copy(pipelinesMarkdown)" x-text="t('copy')"></button>
224
255
  </div>
225
256
  <ul>
226
257
  <template x-for="item in picked" :key="item.service.project">
@@ -230,7 +261,7 @@
230
261
  <a :href="item.candidate.tag.pipeline.url"
231
262
  x-text="pipelineIcon(item.candidate.tag.pipeline) + ' #' + item.candidate.tag.pipeline.id"></a>
232
263
  </template>
233
- <span x-show="!item.candidate.tag.pipeline" class="warn">no pipeline</span>
264
+ <span x-show="!item.candidate.tag.pipeline" class="warn" x-text="t('noPipeline')"></span>
234
265
  </li>
235
266
  </template>
236
267
  </ul>
@@ -238,8 +269,8 @@
238
269
  </section>
239
270
  <section>
240
271
  <div class="out-head">
241
- <h3>Compare</h3>
242
- <button type="button" @click="copy(compareMarkdown)">Copy</button>
272
+ <h3 x-text="t('compare')"></h3>
273
+ <button type="button" @click="copy(compareMarkdown)" x-text="t('copy')"></button>
243
274
  </div>
244
275
  <ul>
245
276
  <template x-for="item in picked" :key="item.service.project">
@@ -253,14 +284,14 @@
253
284
  </section>
254
285
  <section class="wide">
255
286
  <div class="out-head">
256
- <h3>Release post</h3>
257
- <button type="button" @click="copy(releasePost)">Copy</button>
287
+ <h3 x-text="t('releasePost')"></h3>
288
+ <button type="button" @click="copy(releasePost)" x-text="t('copy')"></button>
258
289
  </div>
259
290
  <textarea readonly rows="12" x-text="releasePost"></textarea>
260
291
  </section>
261
292
  </div>
262
293
 
263
- <p class="muted">Legend: ✅ success · ❌ failed · 🔄 running · ⏭ canceled or skipped · ⚠️ no pipeline</p>
294
+ <p class="muted" x-text="t('legend')"></p>
264
295
  </div>
265
296
  </template>
266
297
  </main>
@@ -270,15 +301,160 @@
270
301
  running: "🔄", scheduled: "🔄", canceled: "⏭", skipped: "⏭", manual: "⏭",
271
302
  };
272
303
 
304
+ const STRINGS = {
305
+ en: {
306
+ language: "Language",
307
+ theme: "Theme",
308
+ themeAuto: "Auto",
309
+ themeLight: "Light",
310
+ themeDark: "Dark",
311
+ loadFailed: "Cannot load the report: {error}.",
312
+ title: "Release scope",
313
+ titleIssues: "Release scope: {keys}",
314
+ rangeStart: "Collected {at} UTC. Changes run from the commit on",
315
+ rangeEnd: "to the head of the default branch.",
316
+ rangeEndIssues:
317
+ "to the head of the default branch; rows above the latest merge request or commit linked to the issues are out of scope.",
318
+ notInJira: "not found in Jira",
319
+ services: "Services",
320
+ servicesIntro:
321
+ "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.",
322
+ ignored: "Ignored picks from the address that are not in this report: {picks}.",
323
+ failedToCollect: "failed to collect",
324
+ notInProduction: "not in production",
325
+ pickTag: "pick a tag",
326
+ noTagYet: "no tag yet",
327
+ upToDate: "up to date",
328
+ rows: { one: "{count} merge request or commit", other: "{count} merge requests or commits" },
329
+ taskCount: { one: "{count} task", other: "{count} tasks" },
330
+ shipsOf: "{shipped} of {rows}",
331
+ failedCount: "❌ {count} failed",
332
+ allowedCount: "{count} failed, allowed to fail",
333
+ rangeCut: "range cut short",
334
+ rangeCutTitle: "Older merge requests and commits were not collected; open for details",
335
+ notMerged: "Not merged:",
336
+ columnTag: "Tag",
337
+ columnChange: "Change",
338
+ columnDeployed: "Deployed to",
339
+ columnFailed: "Failed jobs",
340
+ pick: "pick",
341
+ picked: "picked",
342
+ linked: "Linked to the issues",
343
+ outOfScope: "out of scope",
344
+ tagJobs: "tag {name}",
345
+ allowedToFail: "(allowed to fail)",
346
+ clearPick: "Clear pick",
347
+ release: "Release",
348
+ noCandidates: "No service has a tag between production and the head of the default branch.",
349
+ pickToFill: "Pick a tag in a service above to fill the lists.",
350
+ copyFailed: "Copying needs HTTPS; select the text in the box instead.",
351
+ tasks: "Jira tasks ({count})",
352
+ copyKeys: "Copy keys",
353
+ copyJql: "Copy JQL",
354
+ copy: "Copy",
355
+ tagPipelines: "Tag pipelines",
356
+ noPipeline: "no pipeline",
357
+ compare: "Compare",
358
+ commitsTo: "commits to {tag}",
359
+ releasePost: "Release post",
360
+ postServices: "**Services**",
361
+ postTasks: "**Jira tasks ({count})**",
362
+ postNone: "- none",
363
+ postPipeline: "pipeline #{id}",
364
+ legend: "Legend: ✅ success · ❌ failed · 🔄 running · ⏭ canceled or skipped · ⚠️ no pipeline",
365
+ },
366
+ ru: {
367
+ language: "Язык",
368
+ theme: "Тема",
369
+ themeAuto: "Как в системе",
370
+ themeLight: "Светлая",
371
+ themeDark: "Тёмная",
372
+ loadFailed: "Не удалось загрузить отчёт: {error}.",
373
+ title: "Состав релиза",
374
+ titleIssues: "Состав релиза: {keys}",
375
+ rangeStart: "Собрано {at} UTC. Изменения считаются от коммита на",
376
+ rangeEnd: "до головы основной ветки.",
377
+ rangeEndIssues:
378
+ "до головы основной ветки; строки выше последнего MR или коммита, связанного с задачами, вне скоупа.",
379
+ notInJira: "нет в Jira",
380
+ services: "Сервисы",
381
+ servicesIntro:
382
+ "Откройте сервис и выберите тег для релиза: в него войдут все MR и коммиты от его строки до прода. Выбор хранится в адресе страницы, так что им можно поделиться ссылкой.",
383
+ ignored: "Из адреса пропущены теги, которых нет в этом отчёте: {picks}.",
384
+ failedToCollect: "не удалось собрать",
385
+ notInProduction: "нет на проде",
386
+ pickTag: "выберите тег",
387
+ noTagYet: "тега ещё нет",
388
+ upToDate: "всё на проде",
389
+ rows: { one: "{count} MR или коммит", few: "{count} MR или коммита", many: "{count} MR или коммитов" },
390
+ taskCount: { one: "{count} задача", few: "{count} задачи", many: "{count} задач" },
391
+ shipsOf: "{shipped} из {rows}",
392
+ failedCount: "❌ упало: {count}",
393
+ allowedCount: "упало, но падать разрешено: {count}",
394
+ rangeCut: "диапазон обрезан",
395
+ rangeCutTitle: "Старые MR и коммиты не собраны; подробности внутри",
396
+ notMerged: "Не влиты:",
397
+ columnTag: "Тег",
398
+ columnChange: "Изменение",
399
+ columnDeployed: "Задеплоено на",
400
+ columnFailed: "Упавшие джобы",
401
+ pick: "выбрать",
402
+ picked: "выбран",
403
+ linked: "Связано с задачами",
404
+ outOfScope: "вне скоупа",
405
+ tagJobs: "тег {name}",
406
+ allowedToFail: "(падать разрешено)",
407
+ clearPick: "Сбросить выбор",
408
+ release: "Релиз",
409
+ noCandidates: "Ни у одного сервиса нет тега между продом и головой основной ветки.",
410
+ pickToFill: "Выберите тег в сервисе выше, чтобы заполнить списки.",
411
+ copyFailed: "Кнопка копирования работает только по HTTPS; выделите текст в поле.",
412
+ tasks: "Задачи Jira ({count})",
413
+ copyKeys: "Копировать ключи",
414
+ copyJql: "Копировать JQL",
415
+ copy: "Копировать",
416
+ tagPipelines: "Пайплайны тегов",
417
+ noPipeline: "нет пайплайна",
418
+ compare: "Сравнение",
419
+ commitsTo: "коммиты до {tag}",
420
+ releasePost: "Пост о релизе",
421
+ postServices: "**Сервисы**",
422
+ postTasks: "**Задачи Jira ({count})**",
423
+ postNone: "- нет",
424
+ postPipeline: "пайплайн #{id}",
425
+ legend: "Обозначения: ✅ успешно · ❌ упал · 🔄 выполняется · ⏭ отменён или пропущен · ⚠️ нет пайплайна",
426
+ },
427
+ };
428
+
429
+ const PLURALS = Object.fromEntries(Object.keys(STRINGS).map((lang) => [lang, new Intl.PluralRules(lang)]));
430
+
431
+ const stored = (key) => {
432
+ try {
433
+ return localStorage.getItem(key);
434
+ } catch {
435
+ return null;
436
+ }
437
+ };
438
+ const store = (key, value) => {
439
+ try {
440
+ localStorage.setItem(key, value);
441
+ } catch {}
442
+ };
443
+
273
444
  document.addEventListener("alpine:init", () => {
274
445
  Alpine.data("releaseScope", () => ({
275
446
  report: null,
276
447
  failure: "",
277
- notice: "",
278
- ignored: "",
448
+ copyFailed: false,
449
+ ignored: [],
279
450
  picks: {},
451
+ lang: "en",
452
+ theme: "auto",
280
453
 
281
454
  async init() {
455
+ const lang = stored("release-scope-lang");
456
+ this.setLang(lang in STRINGS ? lang : navigator.language.toLowerCase().startsWith("ru") ? "ru" : "en");
457
+ this.theme = document.documentElement.dataset.theme || "auto";
282
458
  try {
283
459
  const response = await fetch("report.json", { cache: "no-store" });
284
460
  if (!response.ok) throw new Error(`report.json returned ${response.status}`);
@@ -291,10 +467,31 @@
291
467
  this.readHash();
292
468
  window.addEventListener("hashchange", () => this.readHash());
293
469
  } catch (error) {
294
- this.failure = `Cannot load the report: ${error.message}.`;
470
+ this.failure = error.message;
295
471
  }
296
472
  },
297
473
 
474
+ t(key, params = {}) {
475
+ let text = STRINGS[this.lang][key] ?? STRINGS.en[key];
476
+ if (typeof text === "object") {
477
+ const form = PLURALS[this.lang].select(params.count);
478
+ text = text[form] ?? text.many ?? text.other;
479
+ }
480
+ return text.replace(/\{(\w+)\}/g, (match, name) => (name in params ? params[name] : match));
481
+ },
482
+ setLang(lang) {
483
+ this.lang = lang;
484
+ document.documentElement.lang = lang;
485
+ document.title = STRINGS[lang].title;
486
+ store("release-scope-lang", lang);
487
+ },
488
+ setTheme(theme) {
489
+ this.theme = theme;
490
+ if (theme === "auto") delete document.documentElement.dataset.theme;
491
+ else document.documentElement.dataset.theme = theme;
492
+ store("release-scope-theme", theme);
493
+ },
494
+
298
495
  get collectedAt() {
299
496
  return this.report.collected_at.slice(0, 16).replace("T", " ");
300
497
  },
@@ -336,19 +533,27 @@
336
533
  .map(({ service, candidate }) => {
337
534
  const pipeline = candidate.tag.pipeline;
338
535
  const label = `${service.project} ${candidate.tag.name}`;
339
- return pipeline ? `- [${label}](${pipeline.url}) ${pipeline.status}` : `- ${label}: no pipeline`;
536
+ return pipeline ? `- [${label}](${pipeline.url}) ${pipeline.status}` : `- ${label}: ${this.t("noPipeline")}`;
340
537
  })
341
538
  .join("\n");
342
539
  },
343
540
  get releasePost() {
344
541
  const services = this.picked.map((item) => {
345
542
  const pipeline = item.candidate.tag.pipeline;
346
- const build = pipeline ? `[pipeline #${pipeline.id}](${pipeline.url}) ${pipeline.status}` : "no pipeline";
543
+ const build = pipeline
544
+ ? `[${this.t("postPipeline", { id: pipeline.id })}](${pipeline.url}) ${pipeline.status}`
545
+ : this.t("noPipeline");
347
546
  const compare = `[${this.compareLabel(item)}](${item.candidate.compare_url})`;
348
547
  return `- ${item.service.project} ${item.candidate.tag.name}: ${compare} · ${build}`;
349
548
  });
350
549
  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");
550
+ return [
551
+ this.t("postServices"),
552
+ ...services,
553
+ "",
554
+ this.t("postTasks", { count: tasks.length }),
555
+ ...(tasks.length ? tasks : [this.t("postNone")]),
556
+ ].join("\n");
352
557
  },
353
558
  get compareMarkdown() {
354
559
  return this.picked
@@ -381,8 +586,7 @@
381
586
  if (known) wanted.delete(service.project);
382
587
  }
383
588
  }
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(", ")}.` : "";
589
+ this.ignored = [...wanted].map(([project, tag]) => (tag ? `${project} ${tag}` : project));
386
590
  this.writeHash();
387
591
  },
388
592
  writeHash() {
@@ -394,20 +598,19 @@
394
598
  return service.candidates.find((item) => item.tag.name === this.picks[service.project]);
395
599
  },
396
600
  pickHint(service) {
397
- if (service.candidates.length) return "pick a tag";
398
- return service.rows.length ? "no tag yet" : "up to date";
601
+ if (service.candidates.length) return this.t("pickTag");
602
+ return service.rows.length ? this.t("noTagYet") : this.t("upToDate");
399
603
  },
400
604
  lineDetails(service) {
401
605
  const candidate = this.pickOf(service);
606
+ const rows = this.t("rows", { count: service.rows.length });
402
607
  if (candidate) {
403
- return `${candidate.rows} of ${this.rowsLabel(service.rows.length)} · ${this.plural(candidate.jira_keys.length, "task")}`;
608
+ const shipped = this.t("shipsOf", { shipped: candidate.rows, rows });
609
+ return `${shipped} · ${this.t("taskCount", { count: candidate.jira_keys.length })}`;
404
610
  }
405
611
  if (!service.rows.length) return "";
406
612
  const keys = new Set(service.rows.filter((row) => row.in_scope).flatMap((row) => row.jira_keys.map((key) => key.key)));
407
- return `${this.rowsLabel(service.rows.length)} · ${this.plural(keys.size, "task")}`;
408
- },
409
- rowsLabel(count) {
410
- return count === 1 ? "1 merge request or commit" : `${count} merge requests or commits`;
613
+ return `${rows} · ${this.t("taskCount", { count: keys.size })}`;
411
614
  },
412
615
  ships(service, index) {
413
616
  const tag = this.picks[service.project];
@@ -432,25 +635,25 @@
432
635
  },
433
636
  productionRef(service) {
434
637
  const production = this.production(service);
435
- if (!production) return "not in production";
638
+ if (!production) return this.t("notInProduction");
436
639
  return production.tag ? production.ref : production.sha.slice(0, 8);
437
640
  },
438
641
  compareLabel({ service, candidate }) {
439
- if (!this.production(service)) return `commits to ${candidate.tag.name}`;
642
+ if (!this.production(service)) return this.t("commitsTo", { tag: candidate.tag.name });
440
643
  return `${this.productionRef(service)}...${candidate.tag.name}`;
441
644
  },
442
645
  pipelineIcon(pipeline) {
443
646
  return pipeline ? ICONS[pipeline.status] || pipeline.status : "⚠️";
444
647
  },
445
- plural(count, noun) {
446
- return `${count} ${noun}${count === 1 ? "" : "s"}`;
447
- },
448
648
  failureCounts(service) {
449
649
  const pipelines = service.rows.flatMap((row) => [row.main_pipeline, ...row.tags.map((tag) => tag.pipeline)]);
450
650
  const jobs = pipelines.filter(Boolean).flatMap((pipeline) => pipeline.failed_jobs);
451
651
  const allowed = jobs.filter((job) => job.allow_failure).length;
452
652
  const blocking = jobs.length - allowed;
453
- return [blocking ? `❌ ${blocking} failed` : "", allowed ? `${allowed} failed, allowed to fail` : ""].filter(Boolean).join(" · ");
653
+ return [
654
+ blocking ? this.t("failedCount", { count: blocking }) : "",
655
+ allowed ? this.t("allowedCount", { count: allowed }) : "",
656
+ ].filter(Boolean).join(" · ");
454
657
  },
455
658
  failedJobs(row) {
456
659
  const lines = [];
@@ -460,15 +663,15 @@
460
663
  }
461
664
  };
462
665
  if (row.main_pipeline) add(`main ${this.pipelineIcon(row.main_pipeline)}`, row.main_pipeline);
463
- for (const tag of row.tags) if (tag.pipeline) add(`tag ${tag.name}`, tag.pipeline);
666
+ for (const tag of row.tags) if (tag.pipeline) add(this.t("tagJobs", { name: tag.name }), tag.pipeline);
464
667
  return lines;
465
668
  },
466
669
  async copy(text) {
467
670
  try {
468
671
  await navigator.clipboard.writeText(text);
469
- this.notice = "";
672
+ this.copyFailed = false;
470
673
  } catch {
471
- this.notice = "Copying needs HTTPS; select the text in the box instead.";
674
+ this.copyFailed = true;
472
675
  }
473
676
  },
474
677
  }));