From e7f8ee2d6e22d55148627aa35f9841f0c15bf9b4 Mon Sep 17 00:00:00 2001 From: Railly Hugo Date: Tue, 29 Sep 2026 20:48:19 -0300 Subject: [PATCH 1/6] feat(plan): add a sweep lane for work one decision resolves plan now returns sweep groups: an open issue with several open closing PRs, an open PR whose modified files are all gone from the default branch, and each open PR paired with the PR it shares the most rarely edited source files with. Groups where every PR is stale sort first, since they need verification on the current release rather than new code. PR file queries now read changeType so only modified and deleted paths are checked against the default branch. The core skill gains a sweep workflow that verifies each group before drafting close comments. --- apps/docs/content/docs/backlog.mdx | 12 + skill-data/core/SKILL.md | 1 + skill-data/core/references/workflows.md | 22 ++ src/cli.ts | 3 + src/github.test.ts | 25 ++ src/github.ts | 14 +- src/overlaps.ts | 2 +- src/plan.ts | 6 + src/schema.ts | 2 +- src/sweep.test.ts | 233 ++++++++++++++++++ src/sweep.ts | 307 ++++++++++++++++++++++++ src/types.ts | 5 + 12 files changed, 628 insertions(+), 4 deletions(-) create mode 100644 src/sweep.test.ts create mode 100644 src/sweep.ts diff --git a/apps/docs/content/docs/backlog.mdx b/apps/docs/content/docs/backlog.mdx index e20039b..0733069 100644 --- a/apps/docs/content/docs/backlog.mdx +++ b/apps/docs/content/docs/backlog.mdx @@ -69,4 +69,16 @@ For competing PRs, `decision` compares draft state, reviews, mergeability, diff `blockedBy` uses visible GitHub relationships. Re-run after a merge or closure to refresh the queue. +## Find work one fix resolves + +The plan's `sweep` groups list open work that one decision can resolve together: + +- `competing`: an issue with two or more open closing PRs +- `stale`: an open PR whose modified files are all gone from the default branch +- `overlap`: each open PR paired with the PR it shares the most source files with, when they close no common issue, ranked by how rarely other PRs touch those files + +Each PR reports `staleBase`: whether every file it modifies or deletes is gone from the default branch. Groups where every PR is stale come first, because they usually need verification on the current release, not new code. Use `--budget 1000` so older backlog items are included. + +A group is a lead, not a verdict. Reproduce the issue on the current release before closing anything. + Plan saves no snapshots. Use `--format json` for structured output; HTML export is available in graph mode. See [Command reference](/docs/reference) for output formats. diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index 1f66abf..312bafc 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -31,6 +31,7 @@ If a command or asset is missing, report the CLI/skill mismatch and observed err | Inspect effective scoring defaults | `issue-graph config show --provider github --scope owner/repo --json` | | Backlog verification queue | `issue-graph reconcile owner/repo` | | Next backlog action | `issue-graph plan owner/repo` | +| Find work one fix resolves together | `issue-graph plan owner/repo --budget 1000 --format json`, then read `sweep` | For counts, skip graph discovery. Resolve repositories and authors from the request and available context; never silently enumerate an organization or guess members. Status accepts repeated repositories and repeated/comma-separated authors, with case-insensitive matching. Ask only if scope remains unresolved. diff --git a/skill-data/core/references/workflows.md b/skill-data/core/references/workflows.md index 8f8c3fa..5dd6a80 100644 --- a/skill-data/core/references/workflows.md +++ b/skill-data/core/references/workflows.md @@ -13,6 +13,7 @@ Use bounded reference graphs to find related work and review candidates. Classif - Status mode and capture comparison - Reconcile mode - Plan mode +- Sweep mode - Flags - How it reads the graph - Guardrails @@ -216,6 +217,27 @@ Use `issue-graph plan owner/repo --format json` when a human or agent needs the The plan does not infer semantic dependencies from issue prose. Treat `blockedBy` as visible graph evidence only, and re-run after each merge or closure. +## Sweep mode + +Use the plan's `sweep` array to find open work that one decision resolves together. Run `issue-graph plan owner/repo --budget 1000 --format json` so older backlog items are in scope; the default budget covers only the newest 80 items. + +Each group is one of three kinds: + +- `competing`: an open issue with two or more open closing PRs, found from closing links whatever reconcile decided. `resolves` counts the issue plus every PR. +- `stale`: an open PR whose modified or deleted files are all gone from the default branch, with the open issue it closes when there is one. +- `overlap`: each open PR paired with the PR it shares the most weighted source files with, when they close no common issue. A file weighs less the more open PRs touch it, and `overlapScore` sums the shared weights. In repositories with a few large files, overlap is weak evidence; read both PRs before calling them duplicates. + +For each PR, `staleBase` is `true` when every file it modifies or deletes is gone from the default branch, `false` when some remain (listed in `missingOnBase`), and `null` when that could not be checked. Groups where every PR is stale sort first, then competing, stale, and overlap groups, each by larger `resolves`, higher `overlapScore`, and the smallest live change. + +Work the groups in order: + +1. **Verify before any claim.** A stale or competing group is a lead, not a verdict. Reproduce the issue's reported behavior on the current release and look for the commit that fixed it (`git log -S`, the handler on the default branch). Record the command and output. +2. **Fixed on the default branch:** draft one short comment per item, naming the fixing PR or release and the check you ran, and thank each PR author. Hand the drafts to the human; do not post or close. +3. **Not fixed:** inspect the group with `issue-graph graph `, choose one implementation path, and credit every superseded contributor as a co-author. +4. **Partially stale** (`missingOnBase` non-empty): the PR targets code that moved. Treat it as a design reference, not a mergeable patch. + +Re-run the plan after each merge or closure; groups change as work lands. + ## Flags - Scope: `owner/repo`, `github:owner/repo`, items (`123`, `#123`, `owner/repo#123`, URL), or nothing for the current directory's GitHub remote. `--repo owner/repo` remains an alias. diff --git a/src/cli.ts b/src/cli.ts index 88ced6f..a9255db 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -48,6 +48,7 @@ import { writeSnapshot, } from "./snapshot.js"; import { runStatus } from "./status-cli.js"; +import { buildSweep, missingBasePaths, sweepBasePaths } from "./sweep.js"; import type { GhTransport } from "./transport.js"; import { shellTransport } from "./transports/shell.js"; import type { NodeKey, Seed } from "./types.js"; @@ -522,10 +523,12 @@ export async function runCli(argv = process.argv.slice(2)): Promise { : "json" : args.format; if (args.command === "plan") { + const missing = await missingBasePaths(transport, sweepBasePaths(nodes)); const plan = buildPlanReport( report, nodes, prioritize(nodes, new Date(), resolveWeights(readConfig(), "github", report.repo)), + buildSweep(nodes, missing), ); console.log( format === "json" diff --git a/src/github.test.ts b/src/github.test.ts index 73c9990..393ab6f 100644 --- a/src/github.test.ts +++ b/src/github.test.ts @@ -86,6 +86,31 @@ describe("parseNodeResponse", () => { expect(node.claimsClose).toEqual(["o/r#42"]); }); + test("a PR records only the files its base must already have", () => { + const node = parseNodeResponse( + { + __typename: "PullRequest", + title: "t", + state: "OPEN", + files: { + nodes: [ + { path: "src/edited.ts", changeType: "MODIFIED" }, + { path: "src/removed.ts", changeType: "DELETED" }, + { path: "src/new.ts", changeType: "ADDED" }, + { path: "src/moved.ts", changeType: "RENAMED" }, + { path: "src/unknown.ts" }, + ], + }, + }, + "o", + "r", + 51, + 0, + ); + expect(node.pr?.baseFiles).toEqual(["src/edited.ts", "src/removed.ts"]); + expect(node.pr?.files).toHaveLength(5); + }); + test("a missing item is NOT_FOUND, not a silently empty node", () => { const node = parseNodeResponse(undefined, "o", "r", 1, 0); expect(node.state).toBe("NOT_FOUND"); diff --git a/src/github.ts b/src/github.ts index 68a3189..8057163 100644 --- a/src/github.ts +++ b/src/github.ts @@ -70,7 +70,7 @@ export const NODE_QUERY = `query($owner:String!,$repo:String!,$n:Int!){ isDraft reviewDecision mergeable createdAt mergedAt updatedAt additions deletions changedFiles reactions{ totalCount } participants(first:1){ totalCount } - files(first:100){ nodes{ path } } + files(first:100){ nodes{ path changeType } } comments(first:100){ totalCount nodes{ body author{login} } } closingIssuesReferences(first:50){ nodes{ number repository{owner{login} name} } } timelineItems(first:100, itemTypes:[CROSS_REFERENCED_EVENT,CONNECTED_EVENT]){ nodes{ __typename @@ -103,7 +103,7 @@ export interface RawNodeItem { additions?: number; deletions?: number; changedFiles?: number; - files?: { nodes?: Array<{ path?: string }> }; + files?: { nodes?: Array<{ path?: string; changeType?: string }> }; reactions?: { totalCount?: number }; participants?: { totalCount?: number }; comments?: { totalCount?: number; nodes?: Array<{ body?: string; author?: { login?: string } }> }; @@ -143,6 +143,12 @@ function blankNode(owner: string, repo: string, number: number, depth: number): }; } +/** + * Change types whose path must already exist on the base branch. Added, + * copied, and renamed files report a path the base does not have yet. + */ +const BASE_CHANGE_TYPES = new Set(["MODIFIED", "CHANGED", "DELETED"]); + /** * Turn one GraphQL node payload into a GraphNode. Pure — no I/O, no transport, * so the parsing is testable against a fixture and shared by every transport. @@ -187,6 +193,10 @@ export function parseNodeResponse( files: (item.files?.nodes ?? []) .map((f) => f.path ?? "") .filter((p): p is string => p.length > 0), + baseFiles: (item.files?.nodes ?? []) + .filter((f) => BASE_CHANGE_TYPES.has(f.changeType ?? "")) + .map((f) => f.path ?? "") + .filter((p): p is string => p.length > 0), }; // A closing keyword only auto-closes from the PR body — a "fixes #N" in the // title (a common mistake) does not. Scan both so we can flag the title-only diff --git a/src/overlaps.ts b/src/overlaps.ts index d99a769..9353518 100644 --- a/src/overlaps.ts +++ b/src/overlaps.ts @@ -8,7 +8,7 @@ import type { GraphNode, NodeKey } from "./types.js"; const INCIDENTAL = /(?:^|\/)(?:README|CHANGELOG|LICENSE|CONTRIBUTING)[^/]*$|\.mdx?$|(?:^|\/)(?:[^/]+\.lock|package-lock\.json|pnpm-lock\.yaml)$/i; -const isSignificant = (file: string): boolean => !INCIDENTAL.test(file); +export const isSignificant = (file: string): boolean => !INCIDENTAL.test(file); /** Two open PRs that touch at least one significant (source) file in common. */ export interface Overlap { diff --git a/src/plan.ts b/src/plan.ts index a6f488b..702ff7c 100644 --- a/src/plan.ts +++ b/src/plan.ts @@ -1,5 +1,6 @@ import type { PriorityRow } from "./priority.js"; import type { ReconcileAction, ReconcileItem, ReconcileReport } from "./reconcile.js"; +import { renderSweep, type SweepGroup } from "./sweep.js"; import type { GraphNode, NodeKey } from "./types.js"; export const PLAN_SCHEMA_VERSION = 1 as const; @@ -73,6 +74,8 @@ export interface PlanReport { queue: PlanItem[]; investigation: PlanItem[]; blocked: PlanItem[]; + /** Groups one decision can resolve together. See `buildSweep`. */ + sweep: SweepGroup[]; guardrails: string[]; } @@ -322,6 +325,7 @@ export function buildPlanReport( reconcile: ReconcileReport, nodes: Map, priorities: PriorityRow[], + sweep: SweepGroup[] = [], ): PlanReport { const ranked = priorityMap(priorities); const unresolved = new Set([...reconcile.limits.fetchFailures, ...reconcile.limits.cappedOut]); @@ -371,6 +375,7 @@ export function buildPlanReport( queue, investigation, blocked, + sweep, guardrails, }; } @@ -421,6 +426,7 @@ export function renderPlan(report: PlanReport): string { out.push("- No ready action. Investigate or unblock the remaining items."); } if (report.decision) out.push("", ...renderDecision(report.decision)); + if (report.sweep.length) out.push("", ...renderSweep(report.sweep)); if (!report.coverageComplete) { out.push( "", diff --git a/src/schema.ts b/src/schema.ts index 32e8313..2844773 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -183,7 +183,7 @@ export const ISSUE_GRAPH_SCHEMA = { defaultFormat: { tty: "text", pipe: "json" }, textFormat: "Monochrome human output; styling only on TTY without NO_COLOR, CI, or TERM=dumb", description: - "Turn a live repository reconciliation into a deterministic execution, investigation, and blocked queue.", + "Turn a live repository reconciliation into a deterministic execution, investigation, and blocked queue, plus sweep groups of work one decision resolves together.", }, status: { githubMutations: false, diff --git a/src/sweep.test.ts b/src/sweep.test.ts new file mode 100644 index 0000000..f894e83 --- /dev/null +++ b/src/sweep.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, test } from "vitest"; +import { buildPlanReport, renderPlan } from "./plan.js"; +import { prioritize } from "./priority.js"; +import { buildReconcileReport } from "./reconcile.js"; +import { buildSweep, missingBasePaths, sweepBasePaths } from "./sweep.js"; +import type { GhTransport } from "./transport.js"; +import type { GraphNode, NodeKey } from "./types.js"; + +const NOW = new Date("2026-09-29T00:00:00.000Z"); + +function issue(key: NodeKey): GraphNode { + return base(key, "Issue"); +} + +function pr( + key: NodeKey, + opts: { closes?: NodeKey; files?: string[]; baseFiles?: string[]; size?: number } = {}, +): GraphNode { + const node = base(key, "PullRequest"); + if (opts.closes) node.edges = [{ to: opts.closes, via: "closes" }]; + node.pr = { + isDraft: false, + reviewDecision: "REVIEW_REQUIRED", + mergeable: "MERGEABLE", + createdAt: "2026-09-01T00:00:00.000Z", + mergedAt: "", + updatedAt: "2026-09-01T00:00:00.000Z", + additions: opts.size ?? 10, + deletions: 0, + changedFiles: (opts.files ?? []).length, + files: opts.files ?? [], + baseFiles: opts.baseFiles ?? opts.files ?? [], + }; + return node; +} + +function base(key: NodeKey, kind: "Issue" | "PullRequest"): GraphNode { + const [repoKey, rawNumber] = key.split("#"); + const [owner, repo] = repoKey.split("/"); + return { + key, + owner, + repo, + number: Number(rawNumber), + kind, + title: `${kind} ${rawNumber}`, + state: "OPEN", + url: `https://github.com/${repoKey}/issues/${rawNumber}`, + depth: 0, + edges: [], + externalLinks: [], + fetched: true, + heat: { createdAt: "2026-09-01T00:00:00.000Z", comments: 0, participants: 0, reactions: 0 }, + }; +} + +function reconcileOf(values: GraphNode[]) { + const nodes = new Map(values.map((value) => [value.key, value])); + const reconcile = buildReconcileReport(nodes, { + repo: "o/r", + seeds: values.map((value) => value.key), + seedLimit: 80, + nodeCap: 80, + cappedOut: [], + generatedAt: NOW.toISOString(), + }); + return { nodes, reconcile }; +} + +describe("buildSweep", () => { + test("puts groups whose PRs only edit removed files before live competing work", () => { + const { nodes } = reconcileOf([ + issue("o/r#1"), + pr("o/r#2", { closes: "o/r#1", files: ["src/a.rs"], size: 11 }), + pr("o/r#3", { closes: "o/r#1", files: ["src/a.rs"], size: 38 }), + issue("o/r#10"), + pr("o/r#11", { closes: "o/r#10", files: ["src/old.ts"] }), + pr("o/r#12", { closes: "o/r#10", files: ["src/old.ts", "src/gone.ts"] }), + ]); + const groups = buildSweep(nodes, new Map([["o/r", new Set(["src/old.ts", "src/gone.ts"])]])); + + expect(groups.map((group) => group.issue)).toEqual(["o/r#10", "o/r#1"]); + expect(groups[0].allStale).toBe(true); + expect(groups[0].resolves).toBe(3); + expect(groups[1].allStale).toBe(false); + expect(groups[1].smallestChange).toBe(11); + }); + + test("groups PRs that change the same source files without a shared issue", () => { + const { nodes } = reconcileOf([ + issue("o/r#1"), + pr("o/r#2", { closes: "o/r#1", files: ["src/certs.ts", "README.md"] }), + pr("o/r#3", { files: ["src/certs.ts", "src/certs.test.ts"] }), + pr("o/r#4", { files: ["README.md"] }), + ]); + const groups = buildSweep(nodes, null); + + expect(groups).toHaveLength(1); + expect(groups[0].kind).toBe("overlap"); + expect(groups[0].pullRequests.map((item) => item.key)).toEqual(["o/r#2", "o/r#3"]); + expect(groups[0].sharedFiles).toEqual(["src/certs.ts"]); + expect(groups[0].overlapScore).toBeGreaterThan(0); + }); + + test("ranks PRs editing the same few files above large PRs sharing one hot file", () => { + const hot = ["src/cli.ts"]; + const { nodes } = reconcileOf([ + pr("o/r#20", { files: [...hot, "src/a.ts", "src/b.ts", "src/c.ts", "src/d.ts"] }), + pr("o/r#21", { files: [...hot, "src/e.ts", "src/f.ts", "src/g.ts", "src/h.ts"] }), + pr("o/r#30", { files: ["src/certs.ts", "src/certs.test.ts"] }), + pr("o/r#31", { files: ["src/certs.ts", "src/certs.test.ts", "src/cli.ts"] }), + ]); + const groups = buildSweep(nodes, null); + expect(groups[0].pullRequests.map((item) => item.key)).toEqual(["o/r#30", "o/r#31"]); + expect(groups.every((group) => group.pullRequests.length === 2)).toBe(true); + }); + + test("does not repeat a competing pair as an overlap", () => { + const { nodes } = reconcileOf([ + issue("o/r#1"), + pr("o/r#2", { closes: "o/r#1", files: ["src/a.ts"] }), + pr("o/r#3", { closes: "o/r#1", files: ["src/a.ts"] }), + ]); + expect(buildSweep(nodes, null).map((group) => group.kind)).toEqual(["competing"]); + }); + + test("reports stale as unknown when the base was not checked, and partial when some files remain", () => { + const { nodes } = reconcileOf([ + issue("o/r#1"), + pr("o/r#2", { closes: "o/r#1", files: ["src/a.ts", "src/b.ts"] }), + pr("o/r#3", { closes: "o/r#1", files: ["src/a.ts"] }), + ]); + expect(buildSweep(nodes, null)[0].pullRequests[0].staleBase).toBeNull(); + + const [group] = buildSweep(nodes, new Map([["o/r", new Set(["src/a.ts"])]])); + expect(group.pullRequests[0]).toMatchObject({ staleBase: false, missingOnBase: ["src/a.ts"] }); + expect(group.pullRequests[1].staleBase).toBe(true); + expect(group.allStale).toBe(false); + }); + + test("lists a PR whose files are all gone as its own stale group", () => { + const { nodes } = reconcileOf([ + issue("o/r#1"), + pr("o/r#2", { closes: "o/r#1", files: ["src/old.ts"] }), + pr("o/r#3", { files: ["src/live.rs"] }), + ]); + const groups = buildSweep(nodes, new Map([["o/r", new Set(["src/old.ts"])]])); + expect(groups).toHaveLength(1); + expect(groups[0]).toMatchObject({ kind: "stale", issue: "o/r#1", resolves: 2, allStale: true }); + }); + + test("finds competing PRs from closing links, whatever reconcile decided", () => { + const merged = pr("o/r#9", { closes: "o/r#1" }); + merged.state = "MERGED"; + const { nodes } = reconcileOf([ + issue("o/r#1"), + merged, + pr("o/r#2", { closes: "o/r#1", files: ["src/a.ts"] }), + pr("o/r#3", { closes: "o/r#1", files: ["src/b.ts"] }), + ]); + const [group] = buildSweep(nodes, null); + expect(group.kind).toBe("competing"); + expect(group.pullRequests.map((item) => item.key)).toEqual(["o/r#2", "o/r#3"]); + }); + + test("never marks a PR stale for files it adds", () => { + const { nodes } = reconcileOf([ + issue("o/r#1"), + pr("o/r#2", { closes: "o/r#1", files: ["src/new.ts"], baseFiles: [] }), + pr("o/r#3", { closes: "o/r#1", files: ["src/new.ts"], baseFiles: [] }), + ]); + const [group] = buildSweep(nodes, new Map([["o/r", new Set(["src/new.ts"])]])); + expect(group.pullRequests.map((item) => item.staleBase)).toEqual([null, null]); + }); +}); + +describe("missingBasePaths", () => { + test("asks the default branch for each path and returns the absent ones", async () => { + const { nodes } = reconcileOf([ + issue("o/r#1"), + pr("o/r#2", { closes: "o/r#1", files: ["src/live.rs"] }), + pr("o/r#3", { closes: "o/r#1", files: ["src/gone.ts"] }), + ]); + const seen: Array> = []; + const transport: GhTransport = { + async graphql(_query, variables = {}) { + seen.push(variables); + const repository: Record = {}; + for (const [name, value] of Object.entries(variables)) { + if (!name.startsWith("e")) continue; + repository[`f${name.slice(1)}`] = + value === "HEAD:src/live.rs" ? { __typename: "Blob" } : null; + } + return { data: { repository } }; + }, + async search() { + return []; + }, + }; + const missing = await missingBasePaths(transport, sweepBasePaths(nodes)); + expect([...(missing.get("o/r") ?? [])]).toEqual(["src/gone.ts"]); + expect(seen[0]).toMatchObject({ o: "o", r: "r" }); + }); + + test("leaves a repository unchecked when the query fails", async () => { + const transport: GhTransport = { + async graphql() { + return { errors: [{ message: "rate limited" }] }; + }, + async search() { + return []; + }, + }; + const missing = await missingBasePaths(transport, new Map([["o/r", new Set(["src/a.ts"])]])); + expect(missing.has("o/r")).toBe(false); + }); +}); + +describe("plan sweep lane", () => { + test("renders the sweep groups in the plan", () => { + const { nodes, reconcile } = reconcileOf([ + issue("o/r#1"), + pr("o/r#2", { closes: "o/r#1", files: ["src/old.ts"] }), + pr("o/r#3", { closes: "o/r#1", files: ["src/old.ts"] }), + ]); + const sweep = buildSweep(nodes, new Map([["o/r", new Set(["src/old.ts"])]])); + const plan = buildPlanReport(reconcile, nodes, prioritize(nodes, NOW), sweep); + expect(plan.sweep).toHaveLength(1); + const text = renderPlan(plan); + expect(text).toContain("## Sweep"); + expect(text).toContain("o/r#1 + o/r#2, o/r#3 · resolves 3 · stale base"); + }); +}); diff --git a/src/sweep.ts b/src/sweep.ts new file mode 100644 index 0000000..76f6497 --- /dev/null +++ b/src/sweep.ts @@ -0,0 +1,307 @@ +import { fileOverlaps, isSignificant } from "./overlaps.js"; +import type { GhTransport } from "./transport.js"; +import type { GraphNode, NodeKey } from "./types.js"; + +/** + * Groups of open work that one decision can resolve together: + * + * - `competing`: an open issue with two or more open closing PRs; + * - `stale`: an open PR whose modified files are all gone from the base + * branch, with the issue it closes; + * - `overlap`: each open PR paired with the PR it shares the most weighted + * source files with, when they close no common issue. + * + * Groups where every PR is stale come first, since they need verification on + * the current release rather than new code. + */ + +export interface SweepPullRequest { + key: NodeKey; + title: string; + url: string; + isDraft: boolean; + mergeable: string; + additions: number; + deletions: number; + /** + * True when every file the PR modifies or deletes is gone from the base + * branch, false when some remain, null when that could not be checked. + */ + staleBase: boolean | null; + missingOnBase: string[]; +} + +export interface SweepGroup { + kind: "competing" | "stale" | "overlap"; + /** The open issue the PRs close, when there is one. */ + issue: NodeKey | null; + pullRequests: SweepPullRequest[]; + /** Source files the PRs share, for overlap groups. */ + sharedFiles: string[]; + /** + * For overlap pairs, the summed weight of the shared source files, where a + * file weighs less the more open PRs touch it (log of open PRs over PRs + * touching it). Null for other kinds. + */ + overlapScore: number | null; + /** Open items one decision resolves: the issue plus every PR. */ + resolves: number; + /** Every PR only edits files the base branch no longer has. */ + allStale: boolean; + /** Smallest additions + deletions among PRs that are not stale, if any. */ + smallestChange: number | null; + summary: string; +} + +const KIND_ORDER: Record = { competing: 0, stale: 1, overlap: 2 }; + +function significantFiles(node: GraphNode): string[] { + return (node.pr?.files ?? []).filter(isSignificant); +} + +function isOpen(node: GraphNode | undefined, kind: GraphNode["kind"]): node is GraphNode { + return node?.kind === kind && node.state === "OPEN"; +} + +function sweepPullRequest( + node: GraphNode, + missing: Map> | null, +): SweepPullRequest { + const baseFiles = node.pr?.baseFiles; + const gone = missing?.get(`${node.owner}/${node.repo}`); + const missingOnBase = baseFiles && gone ? baseFiles.filter((file) => gone.has(file)) : []; + const staleBase = baseFiles?.length && gone ? missingOnBase.length === baseFiles.length : null; + return { + key: node.key, + title: node.title, + url: node.url, + isDraft: node.pr?.isDraft ?? false, + mergeable: node.pr?.mergeable ?? "UNKNOWN", + additions: node.pr?.additions ?? 0, + deletions: node.pr?.deletions ?? 0, + staleBase, + missingOnBase, + }; +} + +function finishGroup( + kind: SweepGroup["kind"], + issue: NodeKey | null, + pullRequests: SweepPullRequest[], + sharedFiles: string[] = [], + overlapScore: number | null = null, +): SweepGroup { + const allStale = pullRequests.every((pr) => pr.staleBase === true); + const live = pullRequests.filter((pr) => pr.staleBase !== true && !pr.isDraft); + const smallestChange = live.length + ? Math.min(...live.map((pr) => pr.additions + pr.deletions)) + : null; + const keys = pullRequests.map((pr) => pr.key).join(", "); + const summary = + kind === "overlap" + ? `Both PRs change ${sharedFiles.slice(0, 3).join(", ")} without a shared closing issue.` + : allStale + ? `Every PR edits files the base branch no longer has (${keys}). Verify on the current release before closing.` + : `${pullRequests.length} open PRs close ${issue}. One fix resolves ${pullRequests.length + 1} items.`; + return { + kind, + issue, + pullRequests, + sharedFiles, + overlapScore, + resolves: pullRequests.length + (issue ? 1 : 0), + allStale, + smallestChange, + summary, + }; +} + +function bySweepOrder(a: SweepGroup, b: SweepGroup): number { + return ( + Number(b.allStale) - Number(a.allStale) || + KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || + b.resolves - a.resolves || + (b.overlapScore ?? 0) - (a.overlapScore ?? 0) || + (a.smallestChange ?? Number.POSITIVE_INFINITY) - + (b.smallestChange ?? Number.POSITIVE_INFINITY) || + (a.issue ?? a.pullRequests[0].key).localeCompare(b.issue ?? b.pullRequests[0].key) + ); +} + +function closedIssues(node: GraphNode, nodes: Map): NodeKey[] { + return node.edges + .filter((edge) => edge.via === "closes" && isOpen(nodes.get(edge.to), "Issue")) + .map((edge) => edge.to); +} + +/** + * Build sweep groups. `missing` maps `owner/repo` to base-branch paths that + * do not exist; pass null when the base branch was not checked. + */ +export function buildSweep( + nodes: Map, + missing: Map> | null, +): SweepGroup[] { + const openPrs = [...nodes.values()] + .filter((node) => isOpen(node, "PullRequest")) + .sort((a, b) => a.key.localeCompare(b.key)); + const groups: SweepGroup[] = []; + const grouped = new Set(); + + const closers = new Map(); + for (const pr of openPrs) { + for (const issue of closedIssues(pr, nodes)) { + closers.set(issue, [...(closers.get(issue) ?? []), pr]); + } + } + for (const [issue, prs] of [...closers].sort(([a], [b]) => a.localeCompare(b))) { + if (prs.length < 2) continue; + for (const pr of prs) grouped.add(pr.key); + groups.push( + finishGroup( + "competing", + issue, + prs.map((node) => sweepPullRequest(node, missing)), + ), + ); + } + + const stale = new Set(); + for (const pr of openPrs) { + const entry = sweepPullRequest(pr, missing); + if (entry.staleBase !== true) continue; + stale.add(pr.key); + if (grouped.has(pr.key)) continue; + groups.push(finishGroup("stale", closedIssues(pr, nodes)[0] ?? null, [entry])); + } + + const touching = new Map(); + for (const node of openPrs) { + for (const file of new Set(significantFiles(node))) { + touching.set(file, (touching.get(file) ?? 0) + 1); + } + } + const weight = (file: string): number => + Math.log((openPrs.length + 1) / (touching.get(file) ?? 1)); + + // Keep each PR's strongest partner only. Every overlapping pair would grow + // quadratically with the backlog; the strongest one is the lead worth reading. + const best = new Map(); + for (const overlap of fileOverlaps(nodes)) { + if (overlap.sharedIssue || stale.has(overlap.a) || stale.has(overlap.b)) continue; + if (grouped.has(overlap.a) && grouped.has(overlap.b)) continue; + const shared = overlap.shared.slice(0, overlap.significant); + const score = shared.reduce((total, file) => total + weight(file), 0); + for (const [self, other] of [ + [overlap.a, overlap.b], + [overlap.b, overlap.a], + ]) { + const current = best.get(self); + if (!current || score > current.score) best.set(self, { score, other, shared }); + } + } + const emitted = new Set(); + for (const [self, { score, other, shared }] of [...best].sort(([a], [b]) => a.localeCompare(b))) { + const [a, b] = [self, other].sort(); + if (emitted.has(`${a} ${b}`)) continue; + emitted.add(`${a} ${b}`); + const pair = [nodes.get(a), nodes.get(b)].filter((node): node is GraphNode => !!node); + groups.push( + finishGroup( + "overlap", + null, + pair.map((node) => sweepPullRequest(node, missing)), + shared, + score, + ), + ); + } + + return groups.sort(bySweepOrder); +} + +/** Paths each repository's open PRs expect on the base branch. */ +export function sweepBasePaths(nodes: Map): Map> { + const paths = new Map>(); + for (const node of nodes.values()) { + if (!isOpen(node, "PullRequest") || !node.pr?.baseFiles?.length) continue; + const repo = `${node.owner}/${node.repo}`; + const set = paths.get(repo) ?? new Set(); + for (const file of node.pr.baseFiles) set.add(file); + paths.set(repo, set); + } + return paths; +} + +const PATHS_PER_QUERY = 50; + +/** + * Return the paths that do not exist on each repository's default branch. + * A repository whose check fails is left out, so its PRs report unknown. + */ +export async function missingBasePaths( + transport: GhTransport, + paths: Map>, +): Promise>> { + const missing = new Map>(); + for (const [repo, set] of paths) { + const [owner, name] = repo.split("/"); + const files = [...set].sort(); + const gone = new Set(); + try { + for (let start = 0; start < files.length; start += PATHS_PER_QUERY) { + const chunk = files.slice(start, start + PATHS_PER_QUERY); + const params = chunk.map((_, i) => `$e${i}:String!`).join(","); + const fields = chunk.map((_, i) => `f${i}: object(expression:$e${i}){ __typename }`); + const query = `query($o:String!,$r:String!,${params}){ repository(owner:$o,name:$r){ ${fields.join(" ")} } }`; + const variables: Record = { o: owner, r: name }; + chunk.forEach((file, i) => { + variables[`e${i}`] = `HEAD:${file}`; + }); + const result = (await transport.graphql(query, variables)) as { + data?: { repository?: Record | null }; + errors?: unknown[]; + }; + const found = result.data?.repository; + if (!found || result.errors?.length) throw new Error("base check failed"); + chunk.forEach((file, i) => { + if (found[`f${i}`] == null) gone.add(file); + }); + } + missing.set(repo, gone); + } catch { + // Unknown beats a false "stale": leave this repository unchecked. + } + } + return missing; +} + +export function renderSweep(groups: SweepGroup[], limit = 10): string[] { + const out = ["## Sweep", ""]; + if (!groups.length) { + out.push("- No issue with competing PRs and no PRs sharing source files."); + return out; + } + for (const [index, group] of groups.slice(0, limit).entries()) { + const head = group.issue ? `${group.issue} + ` : ""; + out.push( + `${index + 1}. ${head}${group.pullRequests.map((pr) => pr.key).join(", ")} · resolves ${group.resolves}${group.allStale ? " · stale base" : ""}`, + ` - ${group.summary}`, + ); + for (const pr of group.pullRequests) { + const flags = [ + `+${pr.additions}/-${pr.deletions}`, + pr.mergeable, + pr.isDraft ? "draft" : "", + pr.staleBase === true + ? "stale base" + : pr.missingOnBase.length + ? `missing on base: ${pr.missingOnBase.slice(0, 3).join(", ")}` + : "", + ].filter(Boolean); + out.push(` - ${pr.key} ${flags.join(" · ")}`); + } + } + if (groups.length > limit) out.push(`- ${groups.length - limit} more in --format json.`); + return out; +} diff --git a/src/types.ts b/src/types.ts index fde17d6..223b7a2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,6 +35,11 @@ export interface PullRequestMeta { changedFiles: number; /** Repo-relative paths the PR touches (first 100). Drives overlap detection. */ files: string[]; + /** + * Paths the PR expects to exist on its base: modified, deleted, or renamed + * files, not added ones. Absent in captures made before it was collected. + */ + baseFiles?: string[]; } /** Discussion-heat signals, fetched in the same node query (no extra request). */ From 43b39ac28bba760d160ec528e75504abeb9366d1 Mon Sep 17 00:00:00 2001 From: Railly Hugo Date: Tue, 29 Sep 2026 21:07:15 -0300 Subject: [PATCH 2/6] feat(dashboard): add a Sweep view for work one decision resolves open now stores the plan's sweep groups on the saved model. A Sweep pill under the cleanup checklist opens a view with stale, competing, and shared-file tabs, one card per group with the issue, its PRs, their size, and stale or conflict chips. --- src/cli.ts | 1 + src/dashboard-style.ts | 35 +++++++++ src/dashboard-view.js | 157 +++++++++++++++++++++++++++++++++++++++++ src/html.ts | 3 + 4 files changed, 196 insertions(+) diff --git a/src/cli.ts b/src/cli.ts index a9255db..d62d853 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -757,6 +757,7 @@ export async function runCli(argv = process.argv.slice(2)): Promise { } catch { process.stderr.write("Repository open totals unavailable; selector count will be unknown.\n"); } + model.sweep = buildSweep(nodes, await missingBasePaths(transport, sweepBasePaths(nodes))); writeNextDashboard(out, [model], modelDefaults([model])); if (args.command !== "open" && args.command !== "cluster") process.stderr.write(`wrote ${out}\n`); diff --git a/src/dashboard-style.ts b/src/dashboard-style.ts index bc46216..cd0c852 100644 --- a/src/dashboard-style.ts +++ b/src/dashboard-style.ts @@ -133,6 +133,7 @@ code{font-family:var(--mono);font-size:12px;background:var(--closed-bg);padding: .filter:focus{outline:2px solid var(--accent);outline-offset:0;border-color:transparent} .cleanup-pill{display:flex;align-items:center;justify-content:space-between;width:100%;height:32px;border:1px solid var(--border2);border-radius:9999px;background:var(--bg);color:var(--fg);font-family:var(--sans);font-size:13px;cursor:pointer;padding:0 6px 0 12px;transition:background 150ms} .cleanup-pill:hover{background:var(--bg2)} +.cleanup-pill+.cleanup-pill{margin-top:6px} .cleanup-pill.active{background:var(--fg);color:var(--bg);border-color:var(--fg)} .cleanup-pill .cnt{min-width:22px;height:20px;padding:0 6px;border-radius:9999px;background:var(--warn-bg);color:var(--warn-fg);font-size:12px;font-weight:500;display:inline-flex;align-items:center;justify-content:center;font-variant-numeric:tabular-nums} .tree{overflow-y:auto;padding:8px;flex:1;min-height:0} @@ -286,6 +287,40 @@ svg .center{font-weight:600} .view-in{animation:view-in 320ms cubic-bezier(.2,.8,.2,1) both} .view-in:has(.filter-menu:not([hidden])){animation:none} @keyframes view-in{from{opacity:0;transform:translateY(4px)}} +/* sweep: work one decision resolves */ +.sw-tiles{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:4px 0 14px} +.sw-tile{display:flex;flex-direction:column;align-items:flex-start;gap:2px;padding:12px 14px;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--bg);color:var(--fg);font-family:var(--sans);text-align:left;cursor:pointer;transition:border-color 150ms,background 150ms,box-shadow 200ms,transform 200ms cubic-bezier(.2,.8,.2,1)} +.sw-tile:hover:not(:disabled){border-color:var(--border2);transform:translateY(-1px);box-shadow:0 4px 16px rgba(0,0,0,.06)} +.sw-tile.active{border-color:var(--fg);box-shadow:0 0 0 1px var(--fg) inset} +.sw-tile:disabled{opacity:.45;cursor:default} +.sw-tile:focus-visible{outline:2px solid var(--accent);outline-offset:1px} +.sw-tile-n{font-size:24px;font-weight:600;line-height:1.1;font-variant-numeric:tabular-nums;letter-spacing:-.02em} +.sw-tile-l{font-size:13px;color:var(--muted)} +.sw-sub{display:flex;justify-content:space-between;align-items:baseline;gap:16px;margin:0 0 12px;font-size:13px;color:var(--muted)} +.sw-sum{font-family:var(--mono);font-size:12px;white-space:nowrap;font-variant-numeric:tabular-nums} +.sw-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(420px,1fr));gap:12px} +.sw-card{display:flex;flex-direction:column;gap:12px;padding:16px;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--bg);min-width:0;animation:view-in 360ms cubic-bezier(.2,.8,.2,1) both;transition:border-color 150ms,box-shadow 200ms,transform 200ms cubic-bezier(.2,.8,.2,1)} +.sw-card:hover{border-color:var(--border2);box-shadow:0 4px 16px rgba(0,0,0,.06);transform:translateY(-1px)} +.sw-card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:12px} +.sw-issue{display:flex;align-items:baseline;gap:8px;min-width:0;font-size:14px;line-height:1.4} +.sw-issue .dot{flex-shrink:0;transform:translateY(-1px)} +.sw-issue-title{font-weight:500;display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden} +.sw-key{font-family:var(--mono);font-size:12px;flex-shrink:0;color:var(--fg2)} +a.sw-key{cursor:pointer;text-decoration:none}a.sw-key:hover{color:var(--accent);text-decoration:underline} +.sw-resolves{flex-shrink:0;font-size:12px;color:var(--open-fg);background:var(--open-bg);border-radius:9999px;padding:2px 9px;white-space:nowrap} +.sw-resolves b{font-weight:600;font-variant-numeric:tabular-nums} +.sw-prs{list-style:none;margin:0;padding:10px 0 0;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:8px} +.sw-pr{display:flex;align-items:center;gap:8px;min-width:0;font-size:13px} +.sw-pr .dot{flex-shrink:0} +.sw-pr-title{flex:1;min-width:0;color:var(--fg2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.sw-pr.stale .sw-pr-title{color:var(--muted)} +.sw-chip{flex-shrink:0;font-size:11px;font-weight:500;padding:1px 7px;border-radius:9999px;background:var(--closed-bg);color:var(--fg2);white-space:nowrap} +.sw-chip.warn{background:var(--warn-bg);color:var(--warn-fg)} +.sw-chip.danger{background:var(--danger-bg,var(--closed-bg));color:var(--danger-fg,var(--closed-fg))} +.sw-size{flex-shrink:0;font-family:var(--mono);font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums;min-width:74px;text-align:right} +.sw-size em{font-style:normal;opacity:.5;margin:0 1px} +@media(max-width:820px){.sw-tiles{grid-template-columns:1fr}.sw-grid{grid-template-columns:1fr}} +@media (prefers-reduced-motion:reduce){.sw-card,.sw-tile{animation:none;transition:none}.sw-card:hover,.sw-tile:hover{transform:none}} .stagger>*{animation:view-in 360ms cubic-bezier(.2,.8,.2,1) both} /* explore: cluster map */ .ex-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:12px} diff --git a/src/dashboard-view.js b/src/dashboard-view.js index 80b3ec0..ea9ed2f 100644 --- a/src/dashboard-view.js +++ b/src/dashboard-view.js @@ -599,6 +599,7 @@ export function mountDashboardView( else if (view === "rank") rankRender(); else if (view === "impact") impactView(impactSel); else if (view === "cleanup") cleanupView(); + else if (view === "sweep") sweepView(); else exploreView(sel?.startsWith("explore:") ? +sel.slice(8) : undefined); } function emptyFilters(message = "No items match these filters.") { @@ -1076,6 +1077,11 @@ export function mountDashboardView( DATA.cleanup.filter((c, i) => !DONE.has(clId(c, i))).length + "" : ""; + const sweepBtn = sweepGroups().length + ? '" + : ""; const total = mix.reduce((a, m) => a + m[2], 0) || 1; return ( '
' + @@ -1115,6 +1121,7 @@ export function mountDashboardView( "
" + '' + cleanupBtn + + sweepBtn + "
" + '
' + groups + @@ -1584,6 +1591,151 @@ export function mountDashboardView( setHash("cleanup"); sel = "cleanup"; } + // Sweep: open work one decision resolves together, computed by `open` + // (issue-graph plan's sweep lane). Older saved runs carry none. + const sweepGroups = () => DATA.sweep || []; + const SWEEP_SECTIONS = [ + [ + "stale", + "Stale on base", + "Every PR edits files the default branch no longer has. Verify on the current release, then close with credit.", + ], + [ + "competing", + "Competing fixes", + "Several open PRs close the same issue. One decision resolves all of them.", + ], + [ + "overlap", + "Shared files", + "PRs changing the same rarely edited files without a shared issue. Read both before calling them duplicates.", + ], + ]; + let SWEEP_TAB = null; + const sweepSection = (group) => (group.allStale ? "stale" : group.kind); + function sweepKey(k) { + return N[k] + ? '' + esc(short(k)) + "" + : '' + esc(short(k)) + ""; + } + function sweepPr(pr) { + const n = N[pr.key]; + const chips = [ + pr.staleBase === true ? 'stale base' : "", + pr.staleBase !== true && pr.missingOnBase.length + ? 'partly stale' + : "", + pr.mergeable === "CONFLICTING" ? 'conflicts' : "", + pr.isDraft ? 'draft' : "", + ].join(""); + return ( + '
  • ' + + sweepKey(pr.key) + + '' + + esc(n ? n.title : pr.title) + + "" + + chips + + '+' + + pr.additions + + "/\u2212" + + pr.deletions + + "
  • " + ); + } + function sweepCard(g, i) { + const issue = g.issue && N[g.issue]; + const head = g.issue + ? '
    ' + + (issue ? '' : "") + + sweepKey(g.issue) + + '' + + esc(issue ? issue.title : "Outside this graph") + + "
    " + : '
    ' + + (g.sharedFiles.length + ? "Shares " + + esc(g.sharedFiles[0].split("/").pop()) + + "" + + (g.sharedFiles.length > 1 ? " and " + (g.sharedFiles.length - 1) + " more" : "") + : "No open issue linked") + + "
    "; + return ( + '
    ' + + head + + '' + + g.resolves + + " " + + (g.resolves === 1 ? "item" : "items") + + '
      ' + + g.pullRequests.map(sweepPr).join("") + + "
    " + ); + } + function sweepView(tab) { + setView("sweep"); + for (const b of app.querySelectorAll(".item")) b.classList.remove("sel"); + const cb = document.getElementById("cleanup-btn"); + if (cb) cb.classList.remove("active"); + const groups = sweepGroups(); + const counts = SWEEP_SECTIONS.map( + ([id]) => groups.filter((g) => sweepSection(g) === id).length, + ); + if (tab) SWEEP_TAB = tab; + if (!SWEEP_TAB || !counts[SWEEP_SECTIONS.findIndex(([id]) => id === SWEEP_TAB)]) + SWEEP_TAB = + SWEEP_SECTIONS[ + Math.max( + 0, + counts.findIndex((c) => c > 0), + ) + ][0]; + const at = SWEEP_SECTIONS.findIndex(([id]) => id === SWEEP_TAB); + const shown = groups.filter((g) => sweepSection(g) === SWEEP_TAB); + const resolves = shown.reduce((total, g) => total + g.resolves, 0); + const tiles = SWEEP_SECTIONS.map( + ([id, title], j) => + '", + ).join(""); + renderMain( + '
    ' + + '

    Sweep

    Open work one decision can resolve together. Each group is a lead, not a verdict: reproduce it on the current release before closing anything. Nothing is posted to GitHub.
    ' + + '
    ' + + tiles + + "
    " + + '
    ' + + esc(SWEEP_SECTIONS[at][2]) + + '' + + shown.length + + " groups \u00b7 " + + resolves + + " items
    " + + '
    ' + + shown.map(sweepCard).join("") + + "
    ", + ); + for (const b of app.querySelectorAll(".sw-tile")) + b.onclick = () => { + if (b.dataset.tab !== SWEEP_TAB) sweepView(b.dataset.tab); + }; + setHash("sweep"); + sel = "sweep"; + } function select(k) { if (!N[sel]) RESULT_ROUTE = location.hash.slice(1) || "explore"; setView("explore"); @@ -3067,6 +3219,8 @@ export function mountDashboardView( if (swarm) swarm.classList.toggle("active", view === "swarm"); const rank = document.getElementById("rank-view"); if (rank) rank.classList.toggle("active", view === "rank"); + const sweep = document.getElementById("sweep-btn"); + if (sweep) sweep.classList.toggle("active", view === "sweep"); } function wire() { @@ -3109,6 +3263,8 @@ export function mountDashboardView( wireProjectMenu(); const cb = document.getElementById("cleanup-btn"); if (cb) cb.onclick = cleanupView; + const sb = document.getElementById("sweep-btn"); + if (sb) sb.onclick = sweepView; document.getElementById("explore-view").onclick = () => exploreView(); const impact = document.getElementById("impact-view"); if (impact) impact.onclick = () => impactView(); @@ -3171,6 +3327,7 @@ export function mountDashboardView( })(); if (h === lastHash) return; if (h === "cleanup" && DATA.cleanup.length) cleanupView(); + else if (h === "sweep" && sweepGroups().length) sweepView(); else if (h === "explore") exploreView(); else if (h.startsWith("explore:")) exploreView(+h.slice(8)); else if (h === "impact") impactView(null); diff --git a/src/html.ts b/src/html.ts index 2576621..2a30d39 100644 --- a/src/html.ts +++ b/src/html.ts @@ -11,6 +11,7 @@ import type { import { fileOverlaps } from "./overlaps.js"; import { prioritize } from "./priority.js"; import type { Weights } from "./scoring.js"; +import type { SweepGroup } from "./sweep.js"; import type { GraphNode, NodeKey } from "./types.js"; /** Optional semantic clustering supplied by the calling agent (`--clusters`). */ @@ -83,6 +84,8 @@ export interface Model { seeds: NodeKey[]; groups: Group[]; cleanup: CleanupItem[]; + /** Sweep groups from `open`; absent in runs saved before it existed. */ + sweep?: SweepGroup[]; stats: Record; nodes: Record; coverage?: { From 519e502fa5c7cc339153f9e5278d30c8588ddfbc Mon Sep 17 00:00:00 2001 From: Railly Hugo Date: Tue, 29 Sep 2026 21:15:02 -0300 Subject: [PATCH 3/6] fix(open): keep saved clusters when a new run brings none open replaced the saved run for a repository, so re-crawling dropped an agent's clusters and cleanup list. Without new clusters it now carries the previous ones forward: items still in the graph keep their cluster, new items land in Ungrouped, and items that left the graph drop out. --- src/cli.ts | 10 ++++++++-- src/html.test.ts | 48 +++++++++++++++++++++++++++++++++++++++++++++++- src/html.ts | 24 ++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index d62d853..4fc8fe4 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -16,7 +16,13 @@ import { import { modelDefaults, readConfig, resolveWeights } from "./config.js"; import { components, crawl } from "./crawl.js"; import { labelSeeds, makeFetchNode, openBacklogSeeds, repositoryOpenCount } from "./github.js"; -import { applyClusters, type ClustersConfig, dashboardModel, type Model } from "./html.js"; +import { + applyClusters, + type ClustersConfig, + carriedClusters, + dashboardModel, + type Model, +} from "./html.js"; import { renderHumanOutput } from "./human-output.js"; import { writeNextDashboard } from "./next-dashboard.js"; import { fileOverlaps } from "./overlaps.js"; @@ -747,7 +753,7 @@ export async function runCli(argv = process.argv.slice(2)): Promise { if (wantsHtml) { const clusters = args.clustersFile ? (JSON.parse(readFileSync(args.clustersFile, "utf8")) as ClustersConfig) - : agentClusters; + : (agentClusters ?? carriedClusters(readDashboardModel(repoName), nodes)); const out = args.htmlOut || join(tmpdir(), `issue-graph-${primary.owner}-${primary.repo}-${Date.now()}.html`); diff --git a/src/html.test.ts b/src/html.test.ts index b0484e9..6681432 100644 --- a/src/html.test.ts +++ b/src/html.test.ts @@ -1,5 +1,11 @@ import { describe, expect, test } from "vitest"; -import { applyClusters, dashboardModel, renderDashboard, renderHtml } from "./html.js"; +import { + applyClusters, + carriedClusters, + dashboardModel, + renderDashboard, + renderHtml, +} from "./html.js"; import type { GraphNode, NodeKey, PullRequestMeta } from "./types.js"; const meta = (files: string[]): PullRequestMeta => ({ @@ -164,3 +170,43 @@ describe("applyClusters", () => { expect(unknown).toEqual(["o/r#9"]); }); }); + +describe("carriedClusters", () => { + test("keeps clusters for items still in the graph when a new run brings none", () => { + const previous = dashboardModel( + asMap([node("o/r#1"), node("o/r#2"), node("o/r#3")]), + [], + "o/r", + { + clusters: [ + { + label: "Daemon", + root_cause: "sessions", + members: [{ key: "o/r#1" }, { key: "o/r#2" }], + }, + { label: "Gone", members: [{ key: "o/r#3" }] }, + ], + cleanup: [ + { key: "o/r#3", text: "close" }, + { key: "o/r#1", text: "retest" }, + ], + }, + ); + const next = asMap([node("o/r#1"), node("o/r#4")]); + const carried = carriedClusters(previous, next); + const model = dashboardModel(next, [], "o/r", carried); + + expect(model.grouping).toBe("themes"); + expect(model.groups.map((group) => [group.label, group.members])).toEqual([ + ["Daemon", ["o/r#1"]], + ["Ungrouped", ["o/r#4"]], + ]); + expect(model.cleanup).toEqual([{ key: "o/r#1", text: "retest" }]); + }); + + test("carries nothing from an unclustered run", () => { + const previous = dashboardModel(asMap([node("o/r#1")]), [], "o/r"); + expect(carriedClusters(previous, asMap([node("o/r#1")]))).toBeUndefined(); + expect(carriedClusters(undefined, asMap([node("o/r#1")]))).toBeUndefined(); + }); +}); diff --git a/src/html.ts b/src/html.ts index 2a30d39..b5443f1 100644 --- a/src/html.ts +++ b/src/html.ts @@ -332,6 +332,30 @@ export function applyClusters( }; } +/** + * Clusters from a previously saved run, for a new run that brings none of its + * own. Items still in the graph keep their cluster, new items land in + * Ungrouped, and items that left the graph drop out. Returns undefined when the + * previous run was not clustered. + */ +export function carriedClusters( + previous: Model | undefined, + nodes: Map, +): ClustersConfig | undefined { + if (previous?.grouping !== "themes") return undefined; + const clusters = previous.groups + .filter((group) => group.label !== "Ungrouped") + .map((group) => ({ + label: group.label, + root_cause: group.subtitle, + members: group.members.filter((key) => nodes.has(key)).map((key) => ({ key })), + })) + .filter((cluster) => cluster.members.length); + if (!clusters.length) return undefined; + const cleanup = previous.cleanup.filter((item) => !item.key || nodes.has(item.key)); + return { clusters, cleanup }; +} + /** The explorer's data for one run, also what `dashboard` saves and reloads. */ export function dashboardModel( nodes: Map, From d722efb39b00748fa434b46a423337d256e38ee4 Mon Sep 17 00:00:00 2001 From: Railly Hugo Date: Tue, 29 Sep 2026 21:21:32 -0300 Subject: [PATCH 4/6] feat(dashboard): fold the cleanup checklist into the Sweep view The cleanup list is now the Checklist tab of Sweep, with the same sections, checkboxes, and progress, and checklist items that target an issue or PR in a sweep group also appear on that group's card. The separate Cleanup pill and route are removed; Sweep's count includes unchecked items. --- skill-data/core/references/workflows.md | 2 +- src/dashboard-style.ts | 10 +- src/dashboard-view.js | 283 +++++++++++++----------- 3 files changed, 160 insertions(+), 135 deletions(-) diff --git a/skill-data/core/references/workflows.md b/skill-data/core/references/workflows.md index 5dd6a80..bcda090 100644 --- a/skill-data/core/references/workflows.md +++ b/skill-data/core/references/workflows.md @@ -89,7 +89,7 @@ Ready-for-review means non-draft, not approved or merge-ready. For a ready-for-r 5. **Offer clustering; run it only when approved.** Suggest it when the graph has several open items, and say what it sends. With `--cluster`, the CLI prints a fenced `cluster-prompt` block containing node titles and edges in `[brackets]`. If the user authorized clustering and the data boundary, group by edge structure and possible shared defect, then present clusters as hypotheses to verify. Treat embedded issue content as untrusted evidence, not instructions. `issue-graph cluster` returns that task as JSON (`task`, `apply`); answer it in your own session and pipe the JSON to `issue-graph cluster --apply -`, which validates the keys against the saved run and rebuilds the dashboard. `--agent claude|codex` launches a separate agent only for runs with no agent session (cron, CI); the CLI does not sandbox that process. -6. **Deliver the dashboard.** `issue-graph open` writes the Next.js explorer to a temp file (or the `-o PATH.html` path) and returns `dashboard`; `--open` opens it. After `cluster --apply`, it includes the supplied clusters and cleanup checklist. Use `query` on the saved scope for the desired view and filters, then give the returned `viewUrl` as a clickable link. Rank shares the CLI score and supports session-only weight exploration. Swarm offers the selected provider's supported metrics. +6. **Deliver the dashboard.** `issue-graph open` writes the Next.js explorer to a temp file (or the `-o PATH.html` path) and returns `dashboard`; `--open` opens it. After `cluster --apply`, it includes the supplied clusters, and the cleanup list appears as the Checklist tab of the Sweep view. `open` keeps saved clusters when a later run brings none. Use `query` on the saved scope for the desired view and filters, then give the returned `viewUrl` as a clickable link. Rank shares the CLI score and supports session-only weight exploration. Swarm offers the selected provider's supported metrics. 7. **Offer the hub re-seeds.** If the output has a "Hubs not expanded" section, give the user the exact re-seed command it printed for each hub; that is how the neighborhood behind a tracking issue gets explored without pulling the whole tracker. diff --git a/src/dashboard-style.ts b/src/dashboard-style.ts index cd0c852..742cdfb 100644 --- a/src/dashboard-style.ts +++ b/src/dashboard-style.ts @@ -288,7 +288,7 @@ svg .center{font-weight:600} .view-in:has(.filter-menu:not([hidden])){animation:none} @keyframes view-in{from{opacity:0;transform:translateY(4px)}} /* sweep: work one decision resolves */ -.sw-tiles{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:10px;margin:4px 0 14px} +.sw-tiles{display:grid;gap:10px;margin:4px 0 14px} .sw-tile{display:flex;flex-direction:column;align-items:flex-start;gap:2px;padding:12px 14px;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--bg);color:var(--fg);font-family:var(--sans);text-align:left;cursor:pointer;transition:border-color 150ms,background 150ms,box-shadow 200ms,transform 200ms cubic-bezier(.2,.8,.2,1)} .sw-tile:hover:not(:disabled){border-color:var(--border2);transform:translateY(-1px);box-shadow:0 4px 16px rgba(0,0,0,.06)} .sw-tile.active{border-color:var(--fg);box-shadow:0 0 0 1px var(--fg) inset} @@ -319,7 +319,13 @@ a.sw-key{cursor:pointer;text-decoration:none}a.sw-key:hover{color:var(--accent); .sw-chip.danger{background:var(--danger-bg,var(--closed-bg));color:var(--danger-fg,var(--closed-fg))} .sw-size{flex-shrink:0;font-family:var(--mono);font-size:11px;color:var(--muted);font-variant-numeric:tabular-nums;min-width:74px;text-align:right} .sw-size em{font-style:normal;opacity:.5;margin:0 1px} -@media(max-width:820px){.sw-tiles{grid-template-columns:1fr}.sw-grid{grid-template-columns:1fr}} +.sw-todos{display:flex;flex-direction:column;gap:6px;padding:10px 12px;margin-top:-2px;border-radius:10px;background:var(--warn-bg)} +.sw-todo{display:flex;align-items:baseline;gap:8px;font-size:13px;color:var(--fg);cursor:pointer;transition:opacity 200ms} +.sw-todo input{margin:0;accent-color:var(--open-fg);transform:translateY(1px)} +.sw-todo.done{opacity:.55}.sw-todo.done span:last-child{text-decoration:line-through;color:var(--muted)} +.sw-checklist{animation:view-in 320ms cubic-bezier(.2,.8,.2,1) both} +.sw-checklist .cl-progress{margin-top:0} +@media(max-width:820px){.sw-tiles{grid-template-columns:1fr!important}.sw-grid{grid-template-columns:1fr}} @media (prefers-reduced-motion:reduce){.sw-card,.sw-tile{animation:none;transition:none}.sw-card:hover,.sw-tile:hover{transform:none}} .stagger>*{animation:view-in 360ms cubic-bezier(.2,.8,.2,1) both} /* explore: cluster map */ diff --git a/src/dashboard-view.js b/src/dashboard-view.js index ea9ed2f..1d5d304 100644 --- a/src/dashboard-view.js +++ b/src/dashboard-view.js @@ -89,8 +89,7 @@ export function mountDashboardView( function syncSidebarFilters() { const search = document.getElementById("filter"); if (search && search.value !== F.query) search.value = F.query; - const cleanup = document.querySelector("#cleanup-btn .cnt"); - if (cleanup) cleanup.textContent = DATA.cleanup.filter((c, i) => !DONE.has(clId(c, i))).length; + syncSweepCount(); for (const group of app.querySelectorAll(".grp")) { let count = 0; for (const item of group.querySelectorAll(".item")) { @@ -598,7 +597,6 @@ export function mountDashboardView( else if (view === "swarm") swarmRender(); else if (view === "rank") rankRender(); else if (view === "impact") impactView(impactSel); - else if (view === "cleanup") cleanupView(); else if (view === "sweep") sweepView(); else exploreView(sel?.startsWith("explore:") ? +sel.slice(8) : undefined); } @@ -1072,16 +1070,12 @@ export function mountDashboardView( ); }) .join(""); - const cleanupBtn = DATA.cleanup.length - ? '" - : ""; - const sweepBtn = sweepGroups().length - ? '" - : ""; + const sweepBtn = + sweepGroups().length || DATA.cleanup.length + ? '" + : ""; const total = mix.reduce((a, m) => a + m[2], 0) || 1; return ( '
    ' + @@ -1120,7 +1114,6 @@ export function mountDashboardView( (hasView("rank") ? '' : "") + "
    " + '' + - cleanupBtn + sweepBtn + "
    " + '
    ' + @@ -1501,12 +1494,44 @@ export function mountDashboardView( : m, ); } - function cleanupView() { - setView("cleanup"); - const labels = groupLabels(); - const rows = DATA.cleanup + function clRow(r, labels) { + const n = r.c.key && N[r.c.key]; + const target = n + ? '' + esc(short(r.c.key)) + "" + : '' + + esc(r.c.key ? r.c.key.split("/").slice(1).join("/") : "") + + ""; + return ( + '" + ); + } + function checklistRows() { + return DATA.cleanup .map((c, i) => ({ c, i, id: clId(c, i), a: clAction(c.text) })) .filter((r) => !r.c.key || !N[r.c.key] || matches(r.c.key)); + } + function checklistHtml() { + const labels = groupLabels(); + const rows = checklistRows(); const done = rows.filter((r) => DONE.has(r.id)).length; const sections = CL_ACTIONS.map((a) => { const rs = rows.filter((r) => r.a[0] === a[0]); @@ -1517,79 +1542,39 @@ export function mountDashboardView( " " + rs.length + "" + - rs - .map((r) => { - const n = r.c.key && N[r.c.key]; - const target = n - ? '' + - esc(short(r.c.key)) + - "" - : '' + - esc(r.c.key ? r.c.key.split("/").slice(1).join("/") : "") + - ""; - return ( - '" - ); - }) - .join("") + + rs.map((r) => clRow(r, labels)).join("") + "" ); }).join(""); const pct = rows.length ? Math.round((done / rows.length) * 100) : 0; - renderMain( - '
    ' + - '

    Cleanup

    What to close, supersede, or retest, and who to credit. Check items off as you go; progress stays in this browser and nothing is posted to GitHub.

    ' + - '
    ' + - '
    ' + - done + - " of " + - rows.length + - " done
    " + - sections + - "
    ", + return ( + '
    ' + + done + + " of " + + rows.length + + " done
    " + + sections ); - for (const r of app.querySelectorAll(".cl-row input")) - r.onchange = (e) => { - const id = e.target.closest(".cl-row").dataset.id; + } + function checklistPending() { + return DATA.cleanup.filter((c, i) => !DONE.has(clId(c, i))).length; + } + function wireChecklist(rerender) { + for (const box of app.querySelectorAll("input[data-cl-id]")) + box.onchange = (e) => { + const id = e.target.dataset.clId; if (e.target.checked) DONE.add(id); else DONE.delete(id); saveDone(); - cleanupView(); + syncSweepCount(); + rerender(); }; - document.getElementById("cl-swarm").onclick = () => swarmView(); - for (const b of app.querySelectorAll(".item")) b.classList.remove("sel"); - const cb = document.getElementById("cleanup-btn"); - if (cb) { - cb.classList.add("active"); - const n = cb.querySelector(".cnt"); - if (n) n.textContent = DATA.cleanup.filter((c, i) => !DONE.has(clId(c, i))).length; - } - setHash("cleanup"); - sel = "cleanup"; + } + function syncSweepCount() { + const n = document.querySelector("#sweep-btn .cnt"); + if (n) n.textContent = sweepGroups().length + checklistPending(); } // Sweep: open work one decision resolves together, computed by `open` // (issue-graph plan's sweep lane). Older saved runs carry none. @@ -1611,6 +1596,11 @@ export function mountDashboardView( "PRs changing the same rarely edited files without a shared issue. Read both before calling them duplicates.", ], ]; + const CHECKLIST_TAB = [ + "checklist", + "Checklist", + "What the clustering agent said to close, supersede, or retest, and who to credit. Progress stays in this browser.", + ]; let SWEEP_TAB = null; const sweepSection = (group) => (group.allStale ? "stale" : group.kind); function sweepKey(k) { @@ -1646,6 +1636,31 @@ export function mountDashboardView( "" ); } + function sweepTodos(g) { + const keys = new Set([g.issue, ...g.pullRequests.map((pr) => pr.key)].filter(Boolean)); + const rows = checklistRows().filter((r) => r.c.key && keys.has(r.c.key)); + if (!rows.length) return ""; + return ( + '
    ' + + rows + .map( + (r) => + '", + ) + .join("") + + "
    " + ); + } function sweepCard(g, i) { const issue = g.issue && N[g.issue]; const head = g.issue @@ -1674,66 +1689,76 @@ export function mountDashboardView( (g.resolves === 1 ? "item" : "items") + '
      ' + g.pullRequests.map(sweepPr).join("") + - "
    " + "" + + sweepTodos(g) + + "" ); } function sweepView(tab) { setView("sweep"); for (const b of app.querySelectorAll(".item")) b.classList.remove("sel"); - const cb = document.getElementById("cleanup-btn"); - if (cb) cb.classList.remove("active"); const groups = sweepGroups(); - const counts = SWEEP_SECTIONS.map( - ([id]) => groups.filter((g) => sweepSection(g) === id).length, + const tabs = DATA.cleanup.length ? [...SWEEP_SECTIONS, CHECKLIST_TAB] : SWEEP_SECTIONS; + const counts = tabs.map(([id]) => + id === "checklist" ? checklistPending() : groups.filter((g) => sweepSection(g) === id).length, ); + const has = (id) => { + const j = tabs.findIndex(([t]) => t === id); + return j >= 0 && (id === "checklist" ? DATA.cleanup.length > 0 : counts[j] > 0); + }; if (tab) SWEEP_TAB = tab; - if (!SWEEP_TAB || !counts[SWEEP_SECTIONS.findIndex(([id]) => id === SWEEP_TAB)]) - SWEEP_TAB = - SWEEP_SECTIONS[ - Math.max( - 0, - counts.findIndex((c) => c > 0), - ) - ][0]; - const at = SWEEP_SECTIONS.findIndex(([id]) => id === SWEEP_TAB); - const shown = groups.filter((g) => sweepSection(g) === SWEEP_TAB); - const resolves = shown.reduce((total, g) => total + g.resolves, 0); - const tiles = SWEEP_SECTIONS.map( - ([id, title], j) => - '", - ).join(""); + if (!has(SWEEP_TAB)) SWEEP_TAB = (tabs.find(([id]) => has(id)) || tabs[0])[0]; + const at = tabs.findIndex(([id]) => id === SWEEP_TAB); + const tiles = tabs + .map( + ([id, title], j) => + '", + ) + .join(""); + let body; + let summary; + if (SWEEP_TAB === "checklist") { + body = '
    ' + checklistHtml() + "
    "; + summary = checklistPending() + " of " + checklistRows().length + " left"; + } else { + const shown = groups.filter((g) => sweepSection(g) === SWEEP_TAB); + const resolves = shown.reduce((total, g) => total + g.resolves, 0); + body = '
    ' + shown.map(sweepCard).join("") + "
    "; + summary = shown.length + " groups \u00b7 " + resolves + " items"; + } renderMain( '
    ' + - '

    Sweep

    Open work one decision can resolve together. Each group is a lead, not a verdict: reproduce it on the current release before closing anything. Nothing is posted to GitHub.
    ' + - '
    ' + + '

    Sweep

    Open work one decision can resolve together, and the checklist of what to close. Each group is a lead, not a verdict: reproduce it on the current release before closing anything. Nothing is posted to GitHub.
    ' + + '
    ' + tiles + "
    " + '
    ' + - esc(SWEEP_SECTIONS[at][2]) + + esc(tabs[at][2]) + '' + - shown.length + - " groups \u00b7 " + - resolves + - " items
    " + - '
    ' + - shown.map(sweepCard).join("") + - "
    ", + summary + + "
    " + + body + + "
    ", ); for (const b of app.querySelectorAll(".sw-tile")) b.onclick = () => { if (b.dataset.tab !== SWEEP_TAB) sweepView(b.dataset.tab); }; - setHash("sweep"); + wireChecklist(() => sweepView()); + setHash("sweep:" + SWEEP_TAB); sel = "sweep"; } function select(k) { @@ -1741,8 +1766,6 @@ export function mountDashboardView( setView("explore"); sel = k; setHash(encodeURIComponent(k)); - const cb = document.getElementById("cleanup-btn"); - if (cb) cb.classList.remove("active"); inspector(k); } @@ -2157,8 +2180,6 @@ export function mountDashboardView( function exploreView(gi) { setView("explore"); for (const b of app.querySelectorAll(".item")) b.classList.remove("sel"); - const cb = document.getElementById("cleanup-btn"); - if (cb) cb.classList.remove("active"); if (gi != null && DATA.groups[gi]) return clusterView(+gi); const order = DATA.groups .map((g, i) => ({ g, i, st: clusterStats(g) })) @@ -3261,10 +3282,8 @@ export function mountDashboardView( } function wireSidebar() { wireProjectMenu(); - const cb = document.getElementById("cleanup-btn"); - if (cb) cb.onclick = cleanupView; const sb = document.getElementById("sweep-btn"); - if (sb) sb.onclick = sweepView; + if (sb) sb.onclick = () => sweepView(); document.getElementById("explore-view").onclick = () => exploreView(); const impact = document.getElementById("impact-view"); if (impact) impact.onclick = () => impactView(); @@ -3326,8 +3345,8 @@ export function mountDashboardView( } })(); if (h === lastHash) return; - if (h === "cleanup" && DATA.cleanup.length) cleanupView(); - else if (h === "sweep" && sweepGroups().length) sweepView(); + if (h.startsWith("sweep") && (sweepGroups().length || DATA.cleanup.length)) + sweepView(h.split(":")[1]); else if (h === "explore") exploreView(); else if (h.startsWith("explore:")) exploreView(+h.slice(8)); else if (h === "impact") impactView(null); From b6662f81bc7d028498ca1cae960978b383e6e82b Mon Sep 17 00:00:00 2001 From: Railly Hugo Date: Wed, 30 Sep 2026 00:02:08 -0300 Subject: [PATCH 5/6] feat(plan): name the next step for each sweep group Each sweep group now carries next: verify when every PR is stale, choose for competing PRs, and compare for overlaps. Groups sort verify first, the plan text and dashboard cards show the step, and the skill orders empirical checks before judgment calls. --- apps/docs/content/docs/backlog.mdx | 2 ++ skill-data/core/references/workflows.md | 2 ++ src/dashboard-style.ts | 5 +++++ src/dashboard-view.js | 16 ++++++++++++++-- src/sweep.test.ts | 5 ++++- src/sweep.ts | 19 +++++++++++++++---- 6 files changed, 42 insertions(+), 7 deletions(-) diff --git a/apps/docs/content/docs/backlog.mdx b/apps/docs/content/docs/backlog.mdx index 0733069..5e078d1 100644 --- a/apps/docs/content/docs/backlog.mdx +++ b/apps/docs/content/docs/backlog.mdx @@ -79,6 +79,8 @@ The plan's `sweep` groups list open work that one decision can resolve together: Each PR reports `staleBase`: whether every file it modifies or deletes is gone from the default branch. Groups where every PR is stale come first, because they usually need verification on the current release, not new code. Use `--budget 1000` so older backlog items are included. +Each group names its `next` step: `verify` (run the issue's repro on the current release), `choose` (pick one implementation), or `compare` (read both PRs). Groups to verify come first. + A group is a lead, not a verdict. Reproduce the issue on the current release before closing anything. Plan saves no snapshots. Use `--format json` for structured output; HTML export is available in graph mode. See [Command reference](/docs/reference) for output formats. diff --git a/skill-data/core/references/workflows.md b/skill-data/core/references/workflows.md index bcda090..4fb98c8 100644 --- a/skill-data/core/references/workflows.md +++ b/skill-data/core/references/workflows.md @@ -229,6 +229,8 @@ Each group is one of three kinds: For each PR, `staleBase` is `true` when every file it modifies or deletes is gone from the default branch, `false` when some remain (listed in `missingOnBase`), and `null` when that could not be checked. Groups where every PR is stale sort first, then competing, stale, and overlap groups, each by larger `resolves`, higher `overlapScore`, and the smallest live change. +Each group also carries `next`, the step it needs before anyone acts: `verify` (every PR is stale, so run the issue's repro on the current release), `choose` (pick one implementation among competing PRs), or `compare` (read both PRs of an overlap). Groups sort `verify` first. `verify` is empirical: run it. `choose` and `compare` call for judgment, so do them after the verifiable groups are settled. + Work the groups in order: 1. **Verify before any claim.** A stale or competing group is a lead, not a verdict. Reproduce the issue's reported behavior on the current release and look for the commit that fixed it (`git log -S`, the handler on the default branch). Record the command and output. diff --git a/src/dashboard-style.ts b/src/dashboard-style.ts index 742cdfb..b8c9de5 100644 --- a/src/dashboard-style.ts +++ b/src/dashboard-style.ts @@ -309,6 +309,11 @@ svg .center{font-weight:600} a.sw-key{cursor:pointer;text-decoration:none}a.sw-key:hover{color:var(--accent);text-decoration:underline} .sw-resolves{flex-shrink:0;font-size:12px;color:var(--open-fg);background:var(--open-bg);border-radius:9999px;padding:2px 9px;white-space:nowrap} .sw-resolves b{font-weight:600;font-variant-numeric:tabular-nums} +.sw-badges{display:flex;align-items:center;gap:6px;flex-shrink:0} +.sw-next{font-size:12px;font-weight:500;border-radius:9999px;padding:2px 9px;white-space:nowrap;border:1px solid var(--border)} +.sw-next-verify{color:var(--warn-fg);background:var(--warn-bg);border-color:transparent} +.sw-next-choose{color:var(--merged-fg);background:var(--merged-bg);border-color:transparent} +.sw-next-compare{color:var(--fg2);background:var(--bg2)} .sw-prs{list-style:none;margin:0;padding:10px 0 0;border-top:1px solid var(--border);display:flex;flex-direction:column;gap:8px} .sw-pr{display:flex;align-items:center;gap:8px;min-width:0;font-size:13px} .sw-pr .dot{flex-shrink:0} diff --git a/src/dashboard-view.js b/src/dashboard-view.js index 1d5d304..1f3b306 100644 --- a/src/dashboard-view.js +++ b/src/dashboard-view.js @@ -1661,7 +1661,13 @@ export function mountDashboardView( "
    " ); } + const SWEEP_NEXT = { + verify: ["Verify", "Run the issue's repro on the current release before closing"], + choose: ["Choose one", "Pick one implementation and credit every author"], + compare: ["Compare", "Read both PRs to decide whether they overlap"], + }; function sweepCard(g, i) { + const next = g.next || (g.kind === "overlap" ? "compare" : g.allStale ? "verify" : "choose"); const issue = g.issue && N[g.issue]; const head = g.issue ? '
    ' + @@ -1683,11 +1689,17 @@ export function mountDashboardView( Math.min(i, 12) * 24 + 'ms">
    ' + head + - '' + + '' + + esc(SWEEP_NEXT[next][0]) + + '' + g.resolves + " " + (g.resolves === 1 ? "item" : "items") + - '
      ' + + '
        ' + g.pullRequests.map(sweepPr).join("") + "
      " + sweepTodos(g) + diff --git a/src/sweep.test.ts b/src/sweep.test.ts index f894e83..4cdd611 100644 --- a/src/sweep.test.ts +++ b/src/sweep.test.ts @@ -81,7 +81,9 @@ describe("buildSweep", () => { expect(groups.map((group) => group.issue)).toEqual(["o/r#10", "o/r#1"]); expect(groups[0].allStale).toBe(true); + expect(groups[0].next).toBe("verify"); expect(groups[0].resolves).toBe(3); + expect(groups[1].next).toBe("choose"); expect(groups[1].allStale).toBe(false); expect(groups[1].smallestChange).toBe(11); }); @@ -97,6 +99,7 @@ describe("buildSweep", () => { expect(groups).toHaveLength(1); expect(groups[0].kind).toBe("overlap"); + expect(groups[0].next).toBe("compare"); expect(groups[0].pullRequests.map((item) => item.key)).toEqual(["o/r#2", "o/r#3"]); expect(groups[0].sharedFiles).toEqual(["src/certs.ts"]); expect(groups[0].overlapScore).toBeGreaterThan(0); @@ -228,6 +231,6 @@ describe("plan sweep lane", () => { expect(plan.sweep).toHaveLength(1); const text = renderPlan(plan); expect(text).toContain("## Sweep"); - expect(text).toContain("o/r#1 + o/r#2, o/r#3 · resolves 3 · stale base"); + expect(text).toContain("[verify] o/r#1 + o/r#2, o/r#3 · resolves 3 · stale base"); }); }); diff --git a/src/sweep.ts b/src/sweep.ts index 76f6497..442b7bd 100644 --- a/src/sweep.ts +++ b/src/sweep.ts @@ -31,8 +31,19 @@ export interface SweepPullRequest { missingOnBase: string[]; } +/** + * What a group needs before anyone acts on it: + * + * - `verify`: run the issue's repro on the current release; every PR edits + * files the base branch no longer has, so the fix may already be there; + * - `choose`: pick one implementation among open PRs that close one issue; + * - `compare`: read two PRs that share files to decide whether they overlap. + */ +export type SweepNext = "verify" | "choose" | "compare"; + export interface SweepGroup { kind: "competing" | "stale" | "overlap"; + next: SweepNext; /** The open issue the PRs close, when there is one. */ issue: NodeKey | null; pullRequests: SweepPullRequest[]; @@ -53,7 +64,7 @@ export interface SweepGroup { summary: string; } -const KIND_ORDER: Record = { competing: 0, stale: 1, overlap: 2 }; +const NEXT_ORDER: Record = { verify: 0, choose: 1, compare: 2 }; function significantFiles(node: GraphNode): string[] { return (node.pr?.files ?? []).filter(isSignificant); @@ -105,6 +116,7 @@ function finishGroup( : `${pullRequests.length} open PRs close ${issue}. One fix resolves ${pullRequests.length + 1} items.`; return { kind, + next: kind === "overlap" ? "compare" : allStale ? "verify" : "choose", issue, pullRequests, sharedFiles, @@ -118,8 +130,7 @@ function finishGroup( function bySweepOrder(a: SweepGroup, b: SweepGroup): number { return ( - Number(b.allStale) - Number(a.allStale) || - KIND_ORDER[a.kind] - KIND_ORDER[b.kind] || + NEXT_ORDER[a.next] - NEXT_ORDER[b.next] || b.resolves - a.resolves || (b.overlapScore ?? 0) - (a.overlapScore ?? 0) || (a.smallestChange ?? Number.POSITIVE_INFINITY) - @@ -285,7 +296,7 @@ export function renderSweep(groups: SweepGroup[], limit = 10): string[] { for (const [index, group] of groups.slice(0, limit).entries()) { const head = group.issue ? `${group.issue} + ` : ""; out.push( - `${index + 1}. ${head}${group.pullRequests.map((pr) => pr.key).join(", ")} · resolves ${group.resolves}${group.allStale ? " · stale base" : ""}`, + `${index + 1}. [${group.next}] ${head}${group.pullRequests.map((pr) => pr.key).join(", ")} · resolves ${group.resolves}${group.allStale ? " · stale base" : ""}`, ` - ${group.summary}`, ); for (const pr of group.pullRequests) { From e0af7d2d1cbfc673fdcf8a0ac0672ba6c0b25f18 Mon Sep 17 00:00:00 2001 From: Railly Hugo Date: Wed, 30 Sep 2026 00:05:01 -0300 Subject: [PATCH 6/6] fix(dashboard): validate sweep groups in saved models parseModel now rejects sweep groups whose counts, links, or enums are not plain values, so a tampered or shared model cannot inject markup through the Sweep view. The view also coerces counts to numbers before rendering. --- src/dashboard-view.js | 8 ++++---- src/model-validation.ts | 36 ++++++++++++++++++++++++++++++++++++ src/query.test.ts | 38 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 78 insertions(+), 4 deletions(-) diff --git a/src/dashboard-view.js b/src/dashboard-view.js index 1f3b306..8d8fe00 100644 --- a/src/dashboard-view.js +++ b/src/dashboard-view.js @@ -1630,9 +1630,9 @@ export function mountDashboardView( "" + chips + '+' + - pr.additions + + Number(pr.additions) + "/\u2212" + - pr.deletions + + Number(pr.deletions) + "" ); } @@ -1696,7 +1696,7 @@ export function mountDashboardView( '">' + esc(SWEEP_NEXT[next][0]) + '' + - g.resolves + + Number(g.resolves) + " " + (g.resolves === 1 ? "item" : "items") + '
        ' + @@ -1745,7 +1745,7 @@ export function mountDashboardView( summary = checklistPending() + " of " + checklistRows().length + " left"; } else { const shown = groups.filter((g) => sweepSection(g) === SWEEP_TAB); - const resolves = shown.reduce((total, g) => total + g.resolves, 0); + const resolves = shown.reduce((total, g) => total + Number(g.resolves), 0); body = '
        ' + shown.map(sweepCard).join("") + "
        "; summary = shown.length + " groups \u00b7 " + resolves + " items"; } diff --git a/src/model-validation.ts b/src/model-validation.ts index e1ac358..a6c9a31 100644 --- a/src/model-validation.ts +++ b/src/model-validation.ts @@ -156,5 +156,41 @@ export function parseModel(value: unknown): Model { !g.members.every((k) => typeof k === "string" && Object.hasOwn(m.nodes, k)) ) throw new Error("invalid captured cluster"); + const count = (v: unknown) => typeof v === "number" && Number.isSafeInteger(v) && v >= 0; + const optionalNumber = (v: unknown) => + v === null || (typeof v === "number" && Number.isFinite(v)); + if ( + m.sweep !== undefined && + (!Array.isArray(m.sweep) || + m.sweep.some( + (g) => + !g || + !["competing", "stale", "overlap"].includes(g.kind) || + (g.next !== undefined && !["verify", "choose", "compare"].includes(g.next)) || + (g.issue !== null && (typeof g.issue !== "string" || unsafeKey(g.issue))) || + !strings(g.sharedFiles) || + !optionalNumber(g.overlapScore) || + !count(g.resolves) || + typeof g.allStale !== "boolean" || + !optionalNumber(g.smallestChange) || + typeof g.summary !== "string" || + !Array.isArray(g.pullRequests) || + g.pullRequests.some( + (pr) => + !pr || + typeof pr.key !== "string" || + unsafeKey(pr.key) || + typeof pr.title !== "string" || + !safeUrl(pr.url) || + typeof pr.isDraft !== "boolean" || + typeof pr.mergeable !== "string" || + !count(pr.additions) || + !count(pr.deletions) || + (pr.staleBase !== null && typeof pr.staleBase !== "boolean") || + !strings(pr.missingOnBase), + ), + )) + ) + throw new Error("invalid sweep groups"); return m; } diff --git a/src/query.test.ts b/src/query.test.ts index f5c1c18..39564a8 100644 --- a/src/query.test.ts +++ b/src/query.test.ts @@ -555,4 +555,42 @@ describe("import validation", () => { m.nodes[key(1)].out = [{ to: "toString" as ClientNode["key"], via: "text" }]; expect(() => parseModel(m)).toThrow("relationships"); }); + test("refuses sweep groups whose numbers or links are not plain values", () => { + const group = { + kind: "stale", + next: "verify", + issue: null, + sharedFiles: [], + overlapScore: null, + resolves: 1, + allStale: true, + smallestChange: null, + summary: "", + pullRequests: [ + { + key: key(1), + title: "t", + url: "https://github.com/o/r/pull/1", + isDraft: false, + mergeable: "MERGEABLE", + additions: 1, + deletions: 0, + staleBase: true, + missingOnBase: [], + }, + ], + }; + const m = model(); + m.sweep = [group] as unknown as NonNullable; + expect(() => parseModel(m)).not.toThrow(); + for (const bad of [ + { ...group, resolves: "" }, + { ...group, pullRequests: [{ ...group.pullRequests[0], additions: "" }] }, + { ...group, pullRequests: [{ ...group.pullRequests[0], url: "javascript:alert(1)" }] }, + { ...group, next: "