diff --git a/docs/page.md b/docs/page.md index 628a5b7..b245c7e 100644 --- a/docs/page.md +++ b/docs/page.md @@ -113,7 +113,8 @@ Those issues show their key and summary too, and the services they miss sit unde A service that needs a tag has a **Pick** button for it, which picks the tag without opening the service. `no tag yet` links to create the next tag, and `not merged`, `merged into `, and `in prod or not merged` link to the merge requests or commits. When any line needs a tag, a button next to the title picks every needed tag at once, the newest -one per service. A newer pick can ship more tasks, and the list then shows what those leave behind. +one per service. A needed tag can ship more tasks that need tags of their own, so the button also picks those, and its +count includes them. Changes already in production and closed merge requests are not listed. Other relations, such as `blocks`, `relates to`, or a parent, do not hold a release back; they show only when you hover over the issue's key. @@ -129,4 +130,6 @@ allow the copy button only over HTTPS. - **Compare**: a GitLab compare link per service from production to the picked tag, as a Markdown list. A service never deployed to production gets the tag's commit history instead. - **Release post**: one Markdown text with a line per picked tag, holding its compare link and pipeline, followed by - the Jira tasks with their summaries. + the Jira tasks with their summaries. When the picks leave something behind, a **Left behind** section follows with + the same groups as the list above: each task, the services it misses with the reason and links to the merge + requests, and the issues it depends on. diff --git a/release_scope/_static/index.html b/release_scope/_static/index.html index c819514..4c6a1af 100644 --- a/release_scope/_static/index.html +++ b/release_scope/_static/index.html @@ -583,6 +583,7 @@

postServices: "**Services**", postTasks: "**Jira tasks ({count})**", postNone: "- none", + postGaps: "**Left behind ({count})**", postPipeline: "pipeline #{id}", legend: "Legend: ✅ success · ❌ failed · 🔄 running · ⏭ canceled or skipped · ⚠️ no pipeline", chooseIssues: "Issues", @@ -693,6 +694,7 @@

postServices: "**Сервисы**", postTasks: "**Задачи Jira ({count})**", postNone: "- нет", + postGaps: "**Не войдёт в релиз ({count})**", postPipeline: "пайплайн #{id}", legend: "Обозначения: ✅ прошёл · ❌ упал · 🔄 выполняется · ⏭ отменён или пропущен · ⚠️ нет пайплайна", chooseIssues: "Задачи", @@ -965,16 +967,22 @@

return jira && jira.error ? [jira.error] : []; }, get picked() { + return this.pickedFor(this.picks); + }, + pickedFor(picks) { return this.pickable .map((service) => ({ service, - candidate: this.pickOf(service), + candidate: service.candidates.find((item) => item.tag.name === picks[service.project]), })) .filter((item) => item.candidate); }, get tasks() { + return this.tasksOf(this.picked); + }, + tasksOf(picked) { const tasks = new Map(); - for (const { service, candidate } of this.picked) { + for (const { service, candidate } of picked) { const shipped = service.rows.slice(this.tagRow(service, candidate.tag.name)).filter((row) => row.in_scope); for (const key of candidate.jira_keys) { if (!tasks.has(key.key)) tasks.set(key.key, { key: key.key, url: key.url, issue: this.issue(key.key), sources: [] }); @@ -988,10 +996,13 @@

return [...tasks.values()]; }, get gaps() { - const starts = new Map(this.picked.map(({ service, candidate }) => [service.project, this.tagRow(service, candidate.tag.name)])); + return this.gapsOf(this.picked); + }, + gapsOf(picked) { + const starts = new Map(picked.map(({ service, candidate }) => [service.project, this.tagRow(service, candidate.tag.name)])); const lines = (key) => (this.reach[key] || []).map((item) => ({ ...item, key, reason: this.gapReason(item, starts) })).filter((item) => item.reason); - return this.tasks + return this.tasksOf(picked) .map((task) => ({ ...task, lines: lines(task.key), @@ -1015,14 +1026,24 @@

.map((link) => ({ url: link.url, label: link.iid != null ? "!" + link.iid : link.sha.slice(0, 8) })); }, get neededPicks() { + const picks = { ...this.picks }; const needed = new Map(); - for (const task of this.gaps) { - for (const line of [...task.lines, ...task.deps.flatMap((dep) => dep.lines)]) { - const known = needed.get(line.project); - if (line.state === "tag" && (!known || line.tagIndex < known.tagIndex)) needed.set(line.project, line); + for (;;) { + const round = new Map(); + for (const task of this.gapsOf(this.pickedFor(picks))) { + for (const line of [...task.lines, ...task.deps.flatMap((dep) => dep.lines)]) { + const known = round.get(line.project); + if (line.state === "tag" && picks[line.project] !== line.tag && (!known || line.tagIndex < known.tagIndex)) { + round.set(line.project, line); + } + } + } + if (!round.size) return [...needed.values()]; + for (const line of round.values()) { + picks[line.project] = line.tag; + needed.set(line.project, line); } } - return [...needed.values()]; }, pickGap(line) { this.pick(this.serviceOf(line.project), line.tag); @@ -1081,14 +1102,28 @@

return `- ${item.service.project} ${item.candidate.tag.name}: ${compare} · ${build}`; }); const tasks = this.tasks.map((task) => `- [${task.key}](${task.url})${task.issue ? " " + task.issue.summary : ""}`); + const gaps = this.gaps; return [ this.t("postServices"), ...services, "", this.t("postTasks", { count: tasks.length }), ...(tasks.length ? tasks : [this.t("postNone")]), + ...(gaps.length ? ["", this.t("postGaps", { count: gaps.length }), ...gaps.flatMap((task) => this.postGap(task))] : []), ].join("\n"); }, + postGap(task) { + const line = (item, indent) => + `${indent}- ${item.project}: ${[item.reason, ...this.gapChanges(item).map((change) => `[${change.label}](${change.url})`)].join(" ")}`; + return [ + `- [${task.key}](${task.url})${task.issue ? " " + task.issue.summary : ""}`, + ...task.lines.map((item) => line(item, " ")), + ...task.deps.flatMap((dep) => [ + ` - ${this.relationLabel(dep.relation)} [${dep.key}](${dep.issue.url}) ${dep.issue.summary}${dep.lines.length ? "" : ": " + dep.issue.status}`, + ...dep.lines.map((item) => line(item, " ")), + ]), + ]; + }, get compareMarkdown() { return this.picked .map((item) => `- ${item.service.project}: [${this.compareLabel(item)}](${item.candidate.compare_url})`) diff --git a/tests/test_browser.py b/tests/test_browser.py index af0b3d3..e74c870 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -417,6 +417,47 @@ def test_demo_picking_every_needed_tag_at_once(page: Page, demo_site: str, error assert errors == [] +def test_demo_picking_every_needed_tag_also_picks_what_those_tags_need( + page: Page, demo_site: str, errors: list[str] +) -> None: + page.goto(demo_site) + _pick_by_hand(page, "shop/web", "1.9.0") + + page.get_by_role("button", name="Pick 2 needed tags").click() + + expect(_demo_service(page, "shop/api").locator(".chip > span")).to_have_text("2.5.0") + expect(_demo_service(page, "shop/worker").locator(".chip > span")).to_have_text("0.15.0") + expect(page.locator(".gaps > p button")).to_be_hidden() + assert errors == [] + + +def test_demo_release_post_lists_what_the_picks_leave_behind(page: Page, demo_site: str, errors: list[str]) -> None: + page.goto(demo_site) + _pick_by_hand(page, "shop/api", "2.5.0") + _pick_by_hand(page, "shop/web", "1.9.0") + + page.get_by_role("button", name="Release post").click() + + post: typing.Final = page.locator("section", has=page.get_by_role("button", name="Copy")).locator("textarea").last + expect(post).to_have_value( + re.compile( + re.escape( + "\n\n**Left behind (3)**\n" + "- [SHOP-38](https://jira.example.com/browse/SHOP-38) Export orders as CSV\n" + " - partners/mobile: not in this report\n" + " - is blocked by [SHOP-44](https://jira.example.com/browse/SHOP-44) Only managers may export orders\n" + " - shop/admin: not merged [!15](https://gitlab.example.com/shop/admin/-/merge_requests/15)\n" + " - legacy/admin: not in this report\n" + "- [SHOP-35]" + ) + ) + ) + expect(post).to_have_value( + re.compile(r" - is blocked by \[SHOP-45\]\(\S+\) Pick the dark palette with design: To Do\n") + ) + assert errors == [] + + def test_demo_tasks_name_the_changes_that_bring_them(page: Page, demo_site: str, errors: list[str]) -> None: page.goto(demo_site)