diff --git a/demo/scenario.py b/demo/scenario.py index d09c2a5..6938681 100644 --- a/demo/scenario.py +++ b/demo/scenario.py @@ -221,7 +221,7 @@ class DemoIssue: links=( f"{ENDPOINT}/shop/api/-/merge_requests/57", f"{ENDPOINT}/shop/web/-/merge_requests/120", - f"{ENDPOINT}/shop/mobile/-/merge_requests/7", + f"{ENDPOINT}/partners/mobile/-/merge_requests/7", ), blocked_by=("SHOP-44",), ), @@ -244,7 +244,7 @@ class DemoIssue: summary="Only managers may export orders", status="In Progress", category="indeterminate", - links=(f"{ENDPOINT}/shop/admin/-/merge_requests/15",), + links=(f"{ENDPOINT}/shop/admin/-/merge_requests/15", f"{ENDPOINT}/legacy/admin/-/merge_requests/3"), ), DemoIssue(key="SHOP-45", summary="Pick the dark palette with design", status="To Do", category="new"), DemoIssue(key="SHOP-46", summary="Dark mode in emails", status="To Do", category="new", parent="SHOP-36"), diff --git a/docs/page.md b/docs/page.md index ac35061..628a5b7 100644 --- a/docs/page.md +++ b/docs/page.md @@ -16,7 +16,8 @@ Times show in the browser's time zone. Hover over one to see the value the repor ## Services -Each service is one line, named without the group path all services share, such as `api` for `shop/api`. +Each service is one line, named by its repository, such as `api` for `shop/api`. When two projects on the page share +a name, each keeps as many parent groups as it takes to tell them apart, such as `shop/admin` and `legacy/admin`. Hover over a name to see the full path. It shows: - what production runs and the picked tag; @@ -99,14 +100,21 @@ The **Release** column sits next to the services and stays in view while they sc screen. While a service is open, the column folds into a strip with the number of picked tags and tasks, so the rows get the full width. Clicking the strip closes the service. -Above the tabs, **Left behind by these picks** lists what the picked tags leave out for every Jira task they ship, -and for every issue a task depends on: one that blocks it through Jira's `Blocks` link type, and its subtasks. +Above the tabs, **Left behind by these picks** lists what the picked tags leave out, grouped by each Jira task they +ship. A group opens with the task's key, linked to Jira, and its summary. Under it come the services the task still +misses, then each issue the task depends on: one that blocks it through Jira's `Blocks` link type, and its subtasks. +Those issues show their key and summary too, and the services they miss sit under them. - a service where the task or an issue it depends on has changes that no pick covers, with the tag it needs or why there is none: `no tag yet`, `not merged`, `merged into `, `in prod or not merged`, `not in this report`, or `failed to collect`; - an issue the task depends on with no changes at all that is not done in Jira, with its status. +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. + 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. diff --git a/release_scope/_static/index.html b/release_scope/_static/index.html index 6447798..c819514 100644 --- a/release_scope/_static/index.html +++ b/release_scope/_static/index.html @@ -126,7 +126,14 @@ .issue-row:hover { background: color-mix(in srgb, var(--accent) 5%, transparent); } .issue-row.chosen { background: color-mix(in srgb, var(--accent) 10%, transparent); } .issue-row code { font-weight: 500; } - .gaps ul { margin: 4px 0 12px; padding-left: 18px; } + .gaps { margin: 4px 0 12px; } + .gaps > p { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; justify-content: space-between; } + .gap-task { padding: 6px 0; border-top: 1px solid var(--border); } + .gap-head, .gap-dep-head, .gap-line { display: flex; flex-wrap: wrap; gap: 4px 8px; align-items: center; min-height: 24px; } + .gap-head .dot, .gap-dep-head .dot { align-self: center; flex: none; } + .gap-dep-head, .gap-line { padding-left: 16px; } + .gap-line.nested { padding-left: 32px; } + .gap-line button { padding: 0 8px; font-size: 12px; } .row-key.via-jira > a { text-decoration: underline dashed; text-underline-offset: 3px; } .link-icon { width: 13px; height: 13px; margin-right: 2px; vertical-align: -2px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; } .issue-row .summary { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } @@ -380,10 +387,48 @@

-

-
    - -
+

+

+
@@ -569,6 +614,8 @@

taskFrom: "from {items}", gapsTitle: "Left behind by these picks:", gapNeeds: "needs {tag}", + gapPick: "Pick {tag}", + gapPickAll: { one: "Pick {count} needed tag", other: "Pick {count} needed tags" }, relationParent: "has parent", relationSubtask: "has subtask", }, @@ -677,6 +724,8 @@

taskFrom: "из {items}", gapsTitle: "Не войдёт в релиз с выбранными тегами:", gapNeeds: "нужен тег {tag}", + gapPick: "Выбрать {tag}", + gapPickAll: { one: "Выбрать {count} нужный тег", few: "Выбрать {count} нужных тега", many: "Выбрать {count} нужных тегов" }, relationParent: "входит в", relationSubtask: "включает подзадачу", }, @@ -760,6 +809,23 @@

return { state: "elsewhere" }; }; + const namesOf = (report) => { + const issues = report.jira ? Object.values(report.jira.issues) : []; + const projects = new Set([ + ...report.services.map((service) => service.project), + ...issues.flatMap((issue) => issue.links.map((link) => link.project)), + ]); + const paths = [...projects].map((project) => project.split("/")); + const suffix = (parts, size) => parts.slice(-size).join("/"); + return Object.fromEntries( + paths.map((parts) => { + let size = 1; + while (size < parts.length && paths.some((other) => other !== parts && suffix(other, size) === suffix(parts, size))) size++; + return [parts.join("/"), suffix(parts, size)]; + }), + ); + }; + const reachOf = (report) => { const issues = report.jira ? report.jira.issues : {}; const reach = Object.fromEntries(Object.keys(issues).map((key) => [key, []])); @@ -814,6 +880,7 @@

lang: "en", theme: "auto", reach: {}, + names: {}, chosen: [], autoPicks: {}, issueFilter: "", @@ -833,6 +900,7 @@

this.picks[service.project] = service.candidates.some((item) => item.tag.name === tag) ? tag : ""; } this.reach = reachOf(report); + this.names = namesOf(report); this.report = report; this.readHash(); window.addEventListener("hashchange", () => this.readHash()); @@ -883,17 +951,11 @@

get listed() { return this.report.services.filter((service) => service.error || this.production(service) || service.rows.length); }, - get sharedPrefix() { - const paths = this.listed.map((service) => service.project.split("/")); - if (paths.length < 2) return ""; - const shared = paths.reduce((prefix, parts) => { - const end = prefix.findIndex((part, index) => part !== parts[index] || index >= parts.length - 1); - return end === -1 ? prefix : prefix.slice(0, end); - }); - return shared.length ? shared.join("/") + "/" : ""; - }, shortName(project) { - return this.sharedPrefix && project.startsWith(this.sharedPrefix) ? project.slice(this.sharedPrefix.length) : project; + return this.names[project] || project; + }, + serviceOf(project) { + return this.report.services.find((service) => service.project === project); }, get pickable() { return this.listed.filter((service) => service.candidates.length); @@ -927,22 +989,46 @@

}, get gaps() { const starts = new Map(this.picked.map(({ service, candidate }) => [service.project, this.tagRow(service, candidate.tag.name)])); - const gaps = new Set(); - const check = (source, relation, key) => { - const via = relation ? ` ${this.relationLabel(relation)} ${key}` : ""; - const reach = this.reach[key] || []; - for (const item of reach) { - const reason = this.gapReason(item, starts); - if (reason) gaps.add(`${source}${via} → ${this.shortName(item.project)}: ${reason}`); + const lines = (key) => + (this.reach[key] || []).map((item) => ({ ...item, key, reason: this.gapReason(item, starts) })).filter((item) => item.reason); + return this.tasks + .map((task) => ({ + ...task, + lines: lines(task.key), + deps: (task.issue ? task.issue.relations.filter(dependsOn) : []) + .map((relation) => ({ relation, key: relation.key, issue: this.issue(relation.key), lines: lines(relation.key) })) + .filter((dep) => + dep.lines.length || (dep.issue && !(this.reach[dep.key] || []).length && dep.issue.status_category !== "done")), + })) + .filter((task) => task.lines.length || task.deps.length); + }, + gapRows(task) { + return [ + ...task.lines.map((line) => ({ line })), + ...task.deps.flatMap((dep) => [{ dep }, ...dep.lines.map((line) => ({ line, nested: true }))]), + ]; + }, + gapChanges(line) { + if (!["open", "branch", "elsewhere"].includes(line.state)) return []; + return this.issue(line.key).links + .filter((link) => link.project === line.project) + .map((link) => ({ url: link.url, label: link.iid != null ? "!" + link.iid : link.sha.slice(0, 8) })); + }, + get neededPicks() { + 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); } - const issue = this.issue(key); - if (relation && !reach.length && issue && issue.status_category !== "done") gaps.add(`${source}${via}: ${issue.status}`); - }; - for (const task of this.tasks) { - check(task.key, null, task.key); - for (const relation of task.issue ? task.issue.relations.filter(dependsOn) : []) check(task.key, relation, relation.key); } - return [...gaps]; + return [...needed.values()]; + }, + pickGap(line) { + this.pick(this.serviceOf(line.project), line.tag); + }, + pickNeeded() { + for (const line of this.neededPicks) this.pickGap(line); }, gapReason(item, starts) { if (item.state === "tag" || item.state === "untagged") { @@ -1076,7 +1162,7 @@

return entry.best || entry.untagged.length ? items : this.t("planElsewhere", { items }); }, reachText(item) { - return `${item.project.split("/").pop()} ${this.reachLabel(item)}`; + return `${this.shortName(item.project)} ${this.reachLabel(item)}`; }, showIssues(view) { this.issueView = view; @@ -1102,7 +1188,7 @@

pick(service, tag, element) { this.picks[service.project] = tag; this.untickMissed(service); - element.closest("details").open = false; + if (element) element.closest("details").open = false; this.writeHash(); }, untickMissed(service) { diff --git a/tests/test_browser.py b/tests/test_browser.py index 504637f..af0b3d3 100644 --- a/tests/test_browser.py +++ b/tests/test_browser.py @@ -66,7 +66,7 @@ def _issue(page: Page, key: str) -> Locator: def test_page_lists_the_services_without_errors(page: Page, site: str, errors: list[str]) -> None: page.goto(site) - expect(page.locator("details.service .name")).to_have_text(["team/svc"]) + expect(page.locator("details.service .name")).to_have_text(["svc"]) expect(page.locator(".issue-row code")).to_have_text(["SHOP-9", "SHOP-12"]) assert errors == [] @@ -115,8 +115,8 @@ def test_demo_lists_its_services_and_picks_tags_for_an_issue(page: Page, demo_si page.goto(demo_site) names = page.locator("details.service .name") - expect(names).to_have_text(["admin", "api", "web", "worker"]) - expect(names.first).to_have_attribute("title", "shop/admin") + expect(names).to_have_text(["shop/admin", "api", "web", "worker"]) + expect(names.nth(1)).to_have_attribute("title", "shop/api") expect(page.locator(".issue-row code")).to_have_count(9) _issue(page, "SHOP-38").check() expect(page).to_have_url(re.compile(r"#issues=SHOP-38&shop/api=2\.5\.0&shop/web=1\.9\.0$")) @@ -330,37 +330,93 @@ def test_demo_only_a_key_the_change_does_not_mention_is_marked_as_via_jira( assert errors == [] -def test_demo_a_hand_pick_lists_the_changes_it_leaves_behind(page: Page, demo_site: str, errors: list[str]) -> None: +def test_demo_names_a_service_by_its_repo_and_keeps_the_group_only_when_two_share_a_name( + page: Page, demo_site: str, errors: list[str] +) -> None: page.goto(demo_site) - gaps: typing.Final = page.locator(".gaps li") - api: typing.Final = _demo_service(page, "shop/api") - api.locator("summary").click() - api.locator(".tag", has_text="2.5.0").get_by_role("button").click() + _issue(page, "SHOP-38").check() + + outside: typing.Final = page.locator(".service.unlisted b") + expect(outside).to_have_text(["mobile"]) + expect(outside).to_have_attribute("title", "partners/mobile") + expect(page.locator(".issue-row", has=page.locator("code", has_text="SHOP-44")).locator(".cell")).to_have_text( + ["shop/admin not merged", "legacy/admin not in this report"] + ) + assert errors == [] + + +def _pick_by_hand(page: Page, project: str, tag: str) -> None: + service: typing.Final = _demo_service(page, project) + service.locator("summary").click() + service.locator(".tag", has_text=tag).get_by_role("button").click() + - expect(gaps).to_have_text( +def test_demo_a_hand_pick_lists_what_it_leaves_behind_under_each_task( + page: Page, demo_site: str, errors: list[str] +) -> None: + page.goto(demo_site) + tasks: typing.Final = page.locator(".gap-task") + + _pick_by_hand(page, "shop/api", "2.5.0") + + expect(tasks.locator(".gap-head")).to_have_text( + ["SHOP-38 Export orders as CSV", "SHOP-35 VAT is off by a cent on mixed carts"] + ) + export: typing.Final = tasks.first + expect(export.locator(".gap-head a")).to_have_attribute("href", "https://jira.example.com/browse/SHOP-38") + expect(export.locator(".gap-line")).to_have_text( [ - "SHOP-38 → web: needs 1.9.0", - "SHOP-38 → mobile: not in this report", - "SHOP-38 is blocked by SHOP-44 → admin: not merged", - "SHOP-35 → worker: needs 0.15.0", + "web needs 1.9.0 Pick 1.9.0", + "mobile not in this report", + "shop/admin not merged !15", + "legacy/admin not in this report", ] ) - web: typing.Final = _demo_service(page, "shop/web") - web.locator("summary").click() - web.locator(".tag", has_text="1.9.0").get_by_role("button").click() - expect(gaps).to_have_text( + expect(export.locator(".gap-dep-head")).to_have_text(["is blocked by SHOP-44 Only managers may export orders"]) + expect(export.get_by_role("link", name="!15")).to_have_attribute( + "href", "https://gitlab.example.com/shop/admin/-/merge_requests/15" + ) + + _pick_by_hand(page, "shop/web", "1.9.0") + + expect(tasks.locator(".gap-head")).to_have_text( + ["SHOP-38 Export orders as CSV", "SHOP-35 VAT is off by a cent on mixed carts", "SHOP-36 Dark mode"] + ) + expect(tasks.nth(2).locator(".gap-dep-head")).to_have_text( [ - "SHOP-38 → mobile: not in this report", - "SHOP-38 is blocked by SHOP-44 → admin: not merged", - "SHOP-35 → worker: needs 0.15.0", - "SHOP-36 is blocked by SHOP-45: To Do", - "SHOP-36 has subtask SHOP-46: To Do", + "is blocked by SHOP-45 Pick the dark palette with design To Do", + "has subtask SHOP-46 Dark mode in emails To Do", ] ) assert errors == [] +def test_demo_picking_the_tag_a_left_behind_line_needs_keeps_the_service_closed( + page: Page, demo_site: str, errors: list[str] +) -> None: + page.goto(demo_site) + _pick_by_hand(page, "shop/api", "2.5.0") + + page.locator(".gap-line", has_text="web").get_by_role("button", name="Pick 1.9.0").click() + + expect(_demo_service(page, "shop/web").locator(".chip > span")).to_have_text("1.9.0") + expect(page.locator(".gap-line", has_text="web")).to_have_count(0) + expect(page).to_have_url(re.compile(r"#shop/api=2\.5\.0&shop/web=1\.9\.0$")) + assert errors == [] + + +def test_demo_picking_every_needed_tag_at_once(page: Page, demo_site: str, errors: list[str]) -> None: + page.goto(demo_site) + _pick_by_hand(page, "shop/api", "2.5.0") + + page.get_by_role("button", name="Pick 2 needed tags").click() + + expect(_demo_service(page, "shop/web").locator(".chip > span")).to_have_text("1.9.0") + expect(_demo_service(page, "shop/worker").locator(".chip > span")).to_have_text("0.15.0") + 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) @@ -386,18 +442,13 @@ def test_demo_related_issues_have_their_own_group_and_relations_show_on_hover( def test_demo_left_behind_list_reads_in_russian(page: Page, demo_site: str, errors: list[str]) -> None: page.goto(demo_site) - for project, tag in (("shop/api", "2.5.0"), ("shop/web", "1.9.0")): - service = _demo_service(page, project) - service.locator("summary").click() - service.locator(".tag", has_text=tag).get_by_role("button").click() + _pick_by_hand(page, "shop/api", "2.5.0") + _pick_by_hand(page, "shop/web", "1.9.0") page.get_by_role("combobox").first.select_option("ru") - expect(page.locator(".gaps p")).to_have_text("Не войдёт в релиз с выбранными тегами:") # noqa: RUF001 - expect(page.locator(".gaps li")).to_contain_text( - [ - "SHOP-35 → worker: нужен тег 0.15.0", - "SHOP-36 включает подзадачу SHOP-46: To Do", - ] - ) + expect(page.locator(".gaps > p > span")).to_have_text("Не войдёт в релиз с выбранными тегами:") # noqa: RUF001 + expect(page.locator(".gap-line", has_text="worker")).to_have_text("worker нужен тег 0.15.0 Выбрать 0.15.0") + expect(page.locator(".gap-dep-head").last).to_have_text("включает подзадачу SHOP-46 Dark mode in emails To Do") + expect(page.get_by_role("button", name="Выбрать 1 нужный тег")).to_be_visible() assert errors == []