diff --git a/apps/docs/content/docs/graph.mdx b/apps/docs/content/docs/graph.mdx index 8aaa93a..21cb6f4 100644 --- a/apps/docs/content/docs/graph.mdx +++ b/apps/docs/content/docs/graph.mdx @@ -27,7 +27,7 @@ Replace `owner/repo` and the seed numbers with your repository. Multi-seed resul ## Read the graph -Nodes represent issues and PRs. Edges come from body and comment mentions, timeline cross-references, connected events, and closing references. Each node includes its fetched state and available author, discussion, and PR metadata. Edges retain their source and available actor/date information. +Nodes represent issues and PRs. Edges come from body and comment mentions, timeline cross-references, connected events, closing references, and sub-issue links. A sub-issue edge points from the sub-issue to its parent issue, and the crawl follows parents to their sub-issues. It is never treated as a closing link. Each node includes its fetched state and available author, discussion, and PR metadata. Edges retain their source and available actor/date information. The report flags competing PRs, shared changed files, possible superseded work, and closing claims missing a GitHub closing link. Use those signals to choose what to inspect. Check the implementations and scope before recommending a closure. @@ -42,9 +42,9 @@ The report flags competing PRs, shared changed files, possible superseded work, | `--hub-threshold N` | `12` | Fetch a non-seed node but stop expanding it above this reference count | | `--concurrency N` | `4` | Concurrent node requests, from 1 to 32 | -Cross-repository references are fetched one hop. Explicit seeds are exempt from the hub guard; use the report's suggested re-seed command to inspect a hub. +Cross-repository references are fetched one hop. The hub guard counts a node's references and its sub-issues together, so an epic with more than 12 sub-issues is fetched but not expanded. Explicit seeds are exempt from the hub guard; use the report's suggested re-seed command to inspect a hub. -Each node query reads up to 100 comments, 100 timeline items, 100 PR files, and 50 closing references. These connections are not fully paginated. Search-based seed discovery is limited by the node budget and GitHub's 1000-result search ceiling. +Each node query reads up to 100 comments, 100 timeline items, 100 PR files, 50 closing references, and 50 sub-issues. These connections are not fully paginated. A parent with more sub-issues than that gets a `subIssueTotal` in JSON and a "read N of M" line in the report. Search-based seed discovery is limited by the node budget and GitHub's 1000-result search ceiling. Check `cappedOut`, unfetched nodes, and unexpanded hubs when interpreting the graph. Increasing the node cap leaves the per-node limits unchanged. diff --git a/apps/docs/scripts/prepare-example.ts b/apps/docs/scripts/prepare-example.ts index debae8a..96b9440 100644 --- a/apps/docs/scripts/prepare-example.ts +++ b/apps/docs/scripts/prepare-example.ts @@ -85,7 +85,7 @@ export function prepareExample(graph: Export, stdout: string, capture: Capture) assert(nodes.some((node) => node.kind === "PullRequest")); const allEdges = graph.nodes.flatMap((node) => node.edges.map((edge) => { - assert(["text", "cross-ref", "connected", "closes"].includes(edge.via)); + assert(["text", "cross-ref", "connected", "closes", "sub-issue"].includes(edge.via)); return { from: node.key, to: edge.to, via: edge.via }; }), ); diff --git a/scripts/verify-transports.ts b/scripts/verify-transports.ts index de05a62..324326b 100644 --- a/scripts/verify-transports.ts +++ b/scripts/verify-transports.ts @@ -49,6 +49,8 @@ function normalize(nodes: Map) { edges: [...n.edges].sort((a, b) => a.to.localeCompare(b.to)).map((e) => `${e.via} ${e.to}`), files: n.pr?.files?.slice().sort(), claimsClose: n.claimsClose?.slice().sort(), + subIssues: n.subIssues?.slice().sort(), + subIssueTotal: n.subIssueTotal, })); } diff --git a/skill-data/core/SKILL.md b/skill-data/core/SKILL.md index 312bafc..94ab0d6 100644 --- a/skill-data/core/SKILL.md +++ b/skill-data/core/SKILL.md @@ -78,6 +78,7 @@ After reporting results, offer a useful next command from the printed next steps - Status exit 1 means incomplete evidence or runtime failure; exit 2 means invalid usage. Complete sibling repositories remain useful. A zero exit from graph/reconcile/plan alone does not certify coverage. - Timestamps describe a collection window. A vanished PR is MERGED/CLOSED only after an explicit lookup. Incomplete captures cannot prove additions or transitions. Preserve reconstructed provenance and unknowns; do not backfill historical facts or attribute reviewer actions without evidence. - Report failed nodes, node caps, unexpanded hubs, and per-node API limits. Cross-repository references are fetched one hop. Use printed hub re-seed commands to investigate omissions. +- A `sub-issue` edge (child to parent) is hierarchy, not a closing link or solution; do not cite it as a fix. - Superseded, competing, shared-file overlap, and missing closing-link flags identify inspection candidates. Verify implementation, scope, and current behavior before recommending closure. `reviewFirst` orders inspection; `blockedBy` uses visible links. ## Output and storage diff --git a/skill-data/core/references/workflows.md b/skill-data/core/references/workflows.md index 4fb98c8..5c4f2e4 100644 --- a/skill-data/core/references/workflows.md +++ b/skill-data/core/references/workflows.md @@ -256,7 +256,7 @@ Re-run the plan after each merge or closure; groups change as work lands. ## How it reads the graph -- **Two edge sources.** Text mentions (body + comments) and structural links (timeline cross-references, connected events, closing refs) via GraphQL. Structural links catch attached PRs that never appear in the body text, the reason a text grep alone misses orphans. +- **Two edge sources.** Text mentions (body + comments) and structural links (timeline cross-references, connected events, closing refs, sub-issue parents) via GraphQL. Structural links catch attached PRs that never appear in the body text, the reason a text grep alone misses orphans. A `sub-issue` edge points from a sub-issue to its parent; the crawl also follows a parent to its sub-issues. It is hierarchy only: it never counts as a closing link, a solution, or an inbound reference for Heat. - **PR triage metadata.** Each PR node carries `review`, draft/mergeable state, `+adds/-dels across Nf`, `updated ` (staleness), and the file paths it touches, all in the one node query, no extra requests. - **Heat signals.** Every node also carries comment count, distinct participants, reactions, and createdAt in the same query, the inputs to `issue-graph rank`. - **File-overlap detection.** Open PRs whose changed-file sets intersect are paired as possible duplicates/conflicts; a shared closing issue promotes the pair to a likely duplicate. @@ -264,7 +264,7 @@ Re-run the plan after each merge or closure; groups change as work lands. - **Attribution.** Each node carries its author and who mentioned it; each edge carries the actor and date. - **State is fetched live per node** (OPEN/CLOSED/MERGED), never trusted from a cross-reference event, which can be stale. - **Snapshot + diff.** Graph runs compare the same seed list; keep seed order consistent. Reconcile runs compare the repository even when its open seed set changes, including a transition to zero open items; incomplete reconciliation coverage suppresses unsafe new or resolved claims. Graph/reconcile save under `~/.issue-graph/` unless `--no-save` is set, and may still read prior history with that flag. Status saves only with `--save`; plan never saves snapshots. `ISSUE_GRAPH_HOME` overrides the shared state root, including config, captures, query history, saved dashboards and graph/status/reconcile history. -- **Bounded coverage.** Graph queries read up to 100 comments, 100 timeline items, 100 PR files, and 50 closing references per node without fully paginating those connections. Search-based seeds also face the node budget and 1000-result ceiling. Increasing `--budget` cannot remove every limit. A zero exit from graph/reconcile/plan alone does not certify complete coverage. +- **Bounded coverage.** Graph queries read up to 100 comments, 100 timeline items, 100 PR files, 50 closing references, and 50 sub-issues per node without fully paginating those connections. A parent past the sub-issue limit carries `subIssueTotal` and a "read N of M" line. The hub guard counts sub-issues with references, so a large epic is fetched but not expanded. Search-based seeds also face the node budget and 1000-result ceiling. Increasing `--budget` cannot remove every limit. A zero exit from graph/reconcile/plan alone does not certify complete coverage. ## Guardrails diff --git a/src/classify.test.ts b/src/classify.test.ts index 3a153ce..1d7036b 100644 --- a/src/classify.test.ts +++ b/src/classify.test.ts @@ -163,6 +163,20 @@ describe("classify", () => { expect(nodes.get("o/r#1117")?.verdict).toBe("OPEN PR — untriaged"); }); + test("a sub-issue link is not a closing link", () => { + const subIssueOf = (to: NodeKey): Edge => ({ to, via: "sub-issue" }); + const nodes = asMap([ + node("o/r#7848", { state: "CLOSED" }), + node("o/r#8343", { kind: "PullRequest", state: "MERGED", edges: [subIssueOf("o/r#7848")] }), + node("o/r#8344", { kind: "PullRequest", state: "OPEN", edges: [subIssueOf("o/r#7848")] }), + node("o/r#8345", { kind: "PullRequest", state: "OPEN", edges: [subIssueOf("o/r#7848")] }), + ]); + classify(nodes); + expect(nodes.get("o/r#8344")?.verdict).toBe("OPEN PR — untriaged"); + expect(nodes.get("o/r#8344")?.flags).toBeUndefined(); + expect(nodes.get("o/r#8345")?.flags).toBeUndefined(); + }); + test("depth-0 nodes are marked as seeds", () => { const nodes = asMap([node("o/r#1", { depth: 0 })]); classify(nodes); diff --git a/src/cli.ts b/src/cli.ts index 4fc8fe4..06961fc 100755 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,7 +14,7 @@ import { runAgent, } from "./cluster.js"; import { modelDefaults, readConfig, resolveWeights } from "./config.js"; -import { components, crawl } from "./crawl.js"; +import { components, crawl, crawlTargets } from "./crawl.js"; import { labelSeeds, makeFetchNode, openBacklogSeeds, repositoryOpenCount } from "./github.js"; import { applyClusters, @@ -577,7 +577,7 @@ export async function runCli(argv = process.argv.slice(2)): Promise { if (hubs.length) { md += "\n## Hubs not expanded — re-seed to explore\n\n"; for (const h of hubs) { - md += `- ${h.key} (${h.edges.length} refs) → \`issue-graph graph ${h.owner}/${h.repo}#${h.number} --depth 1\`\n`; + md += `- ${h.key} (${crawlTargets(h).size} refs) → \`issue-graph graph ${h.owner}/${h.repo}#${h.number} --depth 1\`\n`; } } diff --git a/src/cluster.ts b/src/cluster.ts index 190e8f9..17197e3 100644 --- a/src/cluster.ts +++ b/src/cluster.ts @@ -32,7 +32,7 @@ export function clusterPrompt(repo: string, payload: ClusterNode[]): string { .join("\n"); return [ `You are triaging the GitHub reference graph of ${repo}. Below are the open (and seed) nodes reachable from the seed(s).`, - "Each node lists its edges in [brackets] (closes/mentions/cross-ref/connected → target).", + "Each node lists its edges in [brackets] (closes/mentions/cross-ref/connected/sub-issue → target; sub-issue points from a child to its parent issue).", "Group them into ROOT-CAUSE CLUSTERS: sets that share one underlying defect or theme.", "Cluster by the edge structure and the defect it implies — shared closing targets, mutual references, a common subsystem — NOT by title-keyword overlap.", "", diff --git a/src/crawl.test.ts b/src/crawl.test.ts index c9b3277..a8f61d3 100644 --- a/src/crawl.test.ts +++ b/src/crawl.test.ts @@ -1,14 +1,20 @@ import { setTimeout } from "node:timers/promises"; import { describe, expect, test } from "vitest"; -import { components, crawl } from "./crawl.js"; +import { components, crawl, crawlTargets } from "./crawl.js"; import type { FetchNode } from "./github.js"; import type { Edge, GraphNode, NodeKey } from "./types.js"; /** Build an offline FetchNode from a fixture of key -> outgoing edge targets. */ -function fakeFetch(graph: Record & { to: NodeKey }>>): FetchNode { +function fakeFetch( + graph: Record & { to: NodeKey }>>, + subIssues: Record = {}, + failed: NodeKey[] = [], +): FetchNode { return async (owner, repo, number, depth) => { const key = `${owner}/${repo}#${number}`; - const edges: Edge[] = (graph[key] ?? []).map((e) => ({ via: "text", ...e })); + const edges: Edge[] = failed.includes(key) + ? [] + : (graph[key] ?? []).map((e) => ({ via: "text", ...e })); return { key, owner, @@ -22,6 +28,8 @@ function fakeFetch(graph: Record & { to: NodeKey }> edges, externalLinks: [], fetched: true, + ...(subIssues[key] ? { subIssues: subIssues[key] } : {}), + ...(failed.includes(key) ? { state: "FETCH_ERROR", fetched: false } : {}), } satisfies GraphNode; }; } @@ -81,6 +89,58 @@ describe("crawl", () => { expect(nodes.has("other/x#10")).toBe(false); // not expanded }); + test("reaches sub-issues from the parent and records each link once, on the child", async () => { + const fetch = fakeFetch( + { + "o/r#2": [{ to: "o/r#1", via: "sub-issue" }], + "o/r#3": [{ to: "o/r#1", via: "sub-issue" }], + }, + { "o/r#1": ["o/r#2", "o/r#3"] }, + ); + const { nodes } = await crawl([{ owner: "o", repo: "r", number: 1 }], opts(), fetch); + expect([...nodes.keys()].sort()).toEqual(["o/r#1", "o/r#2", "o/r#3"]); + expect(nodes.get("o/r#1")?.edges).toEqual([]); + expect(nodes.get("o/r#2")?.edges).toEqual([{ to: "o/r#1", via: "sub-issue" }]); + expect(nodes.get("o/r#3")?.edges).toEqual([{ to: "o/r#1", via: "sub-issue" }]); + }); + + test("reaches the parent from a sub-issue seed", async () => { + const fetch = fakeFetch({ "o/r#8343": [{ to: "o/r#7848", via: "sub-issue" }] }); + const { nodes } = await crawl([{ owner: "o", repo: "r", number: 8343 }], opts(), fetch); + expect(nodes.has("o/r#7848")).toBe(true); + expect(components(nodes)).toHaveLength(1); + }); + + test("adds the link for a sub-issue whose own fetch failed", async () => { + const fetch = fakeFetch({}, { "o/r#1": ["o/r#2"] }, ["o/r#2"]); + const { nodes } = await crawl([{ owner: "o", repo: "r", number: 1 }], opts(), fetch); + expect(nodes.get("o/r#2")?.edges).toEqual([{ to: "o/r#1", via: "sub-issue" }]); + }); + + test("counts sub-issues toward the hub guard", async () => { + const children = Array.from({ length: 5 }, (_, i) => `o/r#${100 + i}`); + const fetch = fakeFetch({ "o/r#1": [{ to: "o/r#2" }] }, { "o/r#2": children }); + const { nodes } = await crawl( + [{ owner: "o", repo: "r", number: 1 }], + opts({ hubThreshold: 3 }), + fetch, + ); + expect(nodes.get("o/r#2")?.hub).toBe(true); + expect(nodes.has("o/r#100")).toBe(false); + const hub = nodes.get("o/r#2"); + expect(hub && crawlTargets(hub).size).toBe(5); + }); + + test("fetches a cross-repo sub-issue one hop but does not expand it", async () => { + const fetch = fakeFetch( + { "other/x#9": [{ to: "o/r#1", via: "sub-issue" }, { to: "other/x#10" }] }, + { "o/r#1": ["other/x#9"] }, + ); + const { nodes } = await crawl([{ owner: "o", repo: "r", number: 1 }], opts(), fetch); + expect(nodes.has("other/x#9")).toBe(true); + expect(nodes.has("other/x#10")).toBe(false); + }); + test("bounds node requests in flight", async () => { let active = 0; let peak = 0; diff --git a/src/crawl.ts b/src/crawl.ts index cc4b8b3..8c2f258 100644 --- a/src/crawl.ts +++ b/src/crawl.ts @@ -72,15 +72,17 @@ export async function crawl( nodes.set(cur.key, node); if (cur.depth >= maxDepth) continue; + const targets = crawlTargets(node); + // hub guard: fetch a high-degree non-seed node but do not expand it - if (!seedKeys.has(cur.key) && node.edges.length > hubThreshold) { + if (!seedKeys.has(cur.key) && targets.size > hubThreshold) { node.hub = true; continue; } - for (const edge of node.edges) { - const m = edge.to.match(/^([\w.-]+)\/([\w.-]+)#(\d+)$/); - if (!m || nodes.has(edge.to)) continue; + for (const to of targets) { + const m = to.match(/^([\w.-]+)\/([\w.-]+)#(\d+)$/); + if (!m || nodes.has(to)) continue; const [, o, r, n] = m; next.push({ owner: o, @@ -93,9 +95,35 @@ export async function crawl( frontier = next; } + linkSubIssues(nodes); return { nodes, cappedOut }; } +/** + * Every node the crawl expands into from this one, which is also what the hub + * guard counts. Sub-issues are reached from their parent as well as through + * their own child -> parent edge. + */ +export function crawlTargets(node: GraphNode): Set { + return new Set([...node.edges.map((e) => e.to), ...(node.subIssues ?? [])]); +} + +/** + * Make sure every parent/sub-issue pair seen from either end is one + * `sub-issue` edge on the child. A fetched child already reports its parent; + * this covers a child whose own fetch failed. An existing edge from the child + * to the parent is left alone, so a link is never recorded twice. + */ +function linkSubIssues(nodes: Map): void { + for (const parent of nodes.values()) { + for (const childKey of parent.subIssues ?? []) { + const child = nodes.get(childKey); + if (!child || child.edges.some((e) => e.to === parent.key)) continue; + child.edges.push({ to: parent.key, via: "sub-issue" }); + } + } +} + /** Undirected connected components over the crawled nodes, largest first. */ export function components(nodes: Map): NodeKey[][] { const parent = new Map(); diff --git a/src/dashboard-filters.test.ts b/src/dashboard-filters.test.ts index 58eda28..dc7e3c4 100644 --- a/src/dashboard-filters.test.ts +++ b/src/dashboard-filters.test.ts @@ -272,6 +272,44 @@ test("the shipped view functions share filters while retaining complete relation }); }); +test("the shipped view labels sub-issue links from each end and keeps them out of solutions", () => { + const m = model([ + item(1, 100, { in: [{ from: key(2), via: "sub-issue" }] }), + item(2, 80, { out: [{ to: key(1), via: "sub-issue" }] }), + ]); + const html = renderDashboard([m]); + const script = html.slice(html.lastIndexOf("")); + const result = runInNewContext( + script.replace( + 'app.className = "";', + ` + F={state:'all',kind:'all',heatMode:'all'};computeFilters(); + return ({ + parent:egoSvg(N['${key(1)}']), + child:egoSvg(N['${key(2)}']), + linkedPRs:[...blastRadius('${key(1)}').prs], + });`, + ), + { + document: { + getElementById: (id: string) => + id === "data" ? { textContent: JSON.stringify({ projects: [m] }) } : {}, + }, + location: { href: "https://example.invalid/view.html", search: "" }, + URL, + URLSearchParams, + AbortController, + window: { addEventListener() {} }, + localStorage: { getItem: () => null }, + }, + ); + expect(result.parent).toContain("parent of #2"); + expect(result.child).toContain("sub-issue of #1"); + expect(result.linkedPRs).toEqual([]); + const filters = createDashboardFilterEngine().evaluate(m, { solution: "with" }, score); + expect([...filters.matches]).toEqual([]); +}); + test("combinations agree with an independent intersection of captured item sets", () => { const m = model([item(1, 100), item(2, 80), item(3, 80, { state: "CLOSED" }), pr(4)]); const all = [1, 2, 3, 4]; diff --git a/src/dashboard-view.js b/src/dashboard-view.js index 8d8fe00..21f653c 100644 --- a/src/dashboard-view.js +++ b/src/dashboard-view.js @@ -624,6 +624,10 @@ export function mountDashboardView( [po] = DATA.repo.split("/"); return (o === po ? name : r) + "#" + n; }; + // relationship labels read from the item being viewed: outgoing, then incoming + const outLabel = (via) => (via === "sub-issue" ? "sub-issue of" : via); + const inLabel = (via) => + via === "closes" ? "closed-by" : via === "sub-issue" ? "parent of" : via; function statsOf() { // headline counts cover the project's own repo; referenced items are reported as linked const all = Object.values(N), @@ -1127,8 +1131,8 @@ export function mountDashboardView( const push = (k, via) => { if (N[k] && !nb.find((x) => x.k === k)) nb.push({ k, via }); }; - for (const e of n.out) push(e.to, e.via); - for (const e of n.in) push(e.from, e.via === "closes" ? "closed-by" : e.via); + for (const e of n.out) push(e.to, outLabel(e.via)); + for (const e of n.in) push(e.from, inLabel(e.via)); for (const o of n.overlaps) push(o.with, "overlaps"); const cap = nb.slice(0, 12), W = 560, @@ -1426,12 +1430,12 @@ export function mountDashboardView( relList("overlaps (shared files)", ovl, (x) => x) + relList( "Relationships from this item", - otherOut.map((e) => ({ k: e.to, via: e.via })), + otherOut.map((e) => ({ k: e.to, via: outLabel(e.via) })), rel, ) + relList( "Relationships to this item", - otherIn.map((e) => ({ k: e.from, via: e.via })), + otherIn.map((e) => ({ k: e.from, via: inLabel(e.via) })), rel, ) + ment + @@ -2272,7 +2276,12 @@ export function mountDashboardView( keys.forEach((k) => { N[k].out.forEach((e) => { if (idx.has(e.to) && e.to !== k && (!e.undirected || k < e.to)) - links.push([k, e.to, e.via, e.undirected === false || e.via === "closes"]); + links.push([ + k, + e.to, + e.via, + e.undirected === false || e.via === "closes" || e.via === "sub-issue", + ]); }); }); keys.forEach((k) => { @@ -2364,7 +2373,7 @@ export function mountDashboardView( ";animation-delay:" + (200 + i * 30) + 'ms">' + - esc(short(s) + " " + via + " " + short(t)) + + esc(short(s) + " " + outLabel(via) + " " + short(t)) + "" ); }) diff --git a/src/github.test.ts b/src/github.test.ts index 393ab6f..579e510 100644 --- a/src/github.test.ts +++ b/src/github.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "vitest"; import { labelSeeds, makeFetchNode, + NODE_QUERY, openBacklogSeeds, parseNodeResponse, type RawNodeItem, @@ -118,6 +119,97 @@ describe("parseNodeResponse", () => { }); }); +describe("sub-issues", () => { + const ref = (number: number, owner = "o", name = "r") => ({ + number, + repository: { owner: { login: owner }, name }, + }); + + test("a sub-issue points at its parent, even with no mention in the text", () => { + const node = parseNodeResponse( + { __typename: "Issue", title: "child", state: "OPEN", body: "", parent: ref(7848) }, + "o", + "r", + 8343, + 0, + ); + expect(node.edges).toEqual([{ to: "o/r#7848", via: "sub-issue" }]); + expect(node.subIssues).toBeUndefined(); + }); + + test("a parent lists its sub-issues for the crawl without claiming edges to them", () => { + const node = parseNodeResponse( + { + __typename: "Issue", + title: "parent", + state: "OPEN", + body: "", + parent: null, + subIssues: { nodes: [ref(2), ref(3), ref(2), ref(1), ref(4, "other", "x")] }, + }, + "o", + "r", + 1, + 0, + ); + expect(node.subIssues).toEqual(["o/r#2", "o/r#3", "other/x#4"]); + expect(node.edges).toEqual([]); + }); + + test("the sub-issue link wins over a text mention or cross-reference of the same parent", () => { + const node = parseNodeResponse( + { + __typename: "Issue", + title: "child", + state: "OPEN", + body: "part of #7848", + author: { login: "alice" }, + parent: ref(7848), + timelineItems: { nodes: [{ actor: { login: "bob" }, source: ref(7848) }] }, + }, + "o", + "r", + 8343, + 0, + ); + expect(node.edges).toEqual([{ to: "o/r#7848", via: "sub-issue" }]); + }); + + test("a cross-repo parent keeps its own repository in the key", () => { + const node = parseNodeResponse( + { __typename: "Issue", title: "child", state: "OPEN", parent: ref(5, "other", "x") }, + "o", + "r", + 8, + 0, + ); + expect(node.edges).toEqual([{ to: "other/x#5", via: "sub-issue" }]); + }); + + test("a parent with more sub-issues than the page records the total", () => { + const parse = (totalCount: number) => + parseNodeResponse( + { + __typename: "Issue", + title: "epic", + state: "OPEN", + subIssues: { totalCount, nodes: [ref(2), ref(3)] }, + }, + "o", + "r", + 1, + 0, + ); + expect(parse(75).subIssueTotal).toBe(75); + expect(parse(2).subIssueTotal).toBeUndefined(); + }); + + test("the node query asks for the parent and a bounded page of sub-issues", () => { + expect(NODE_QUERY).toContain("parent{ number repository{owner{login} name} }"); + expect(NODE_QUERY).toContain("subIssues(first:50){ totalCount"); + }); +}); + describe("makeFetchNode", () => { test("parses a well-formed envelope", async () => { const fetch = makeFetchNode( diff --git a/src/github.ts b/src/github.ts index 8057163..174f9c5 100644 --- a/src/github.ts +++ b/src/github.ts @@ -60,6 +60,8 @@ export const NODE_QUERY = `query($owner:String!,$repo:String!,$n:Int!){ reactions{ totalCount } participants(first:1){ totalCount } comments(first:100){ totalCount nodes{ body author{login} } } + parent{ number repository{owner{login} name} } + subIssues(first:50){ totalCount nodes{ number repository{owner{login} name} } } timelineItems(first:100, itemTypes:[CROSS_REFERENCED_EVENT,CONNECTED_EVENT]){ nodes{ __typename ... on CrossReferencedEvent{ createdAt actor{login} source{ __typename ... on Issue{number repository{owner{login} name}} ... on PullRequest{number repository{owner{login} name}} } } ... on ConnectedEvent{ createdAt actor{login} subject{ __typename ... on Issue{number repository{owner{login} name}} ... on PullRequest{number repository{owner{login} name}} } } @@ -108,6 +110,10 @@ export interface RawNodeItem { participants?: { totalCount?: number }; comments?: { totalCount?: number; nodes?: Array<{ body?: string; author?: { login?: string } }> }; closingIssuesReferences?: { nodes?: RefNode[] }; + /** Issue-only: the issue this one is a sub-issue of. */ + parent?: RefNode | null; + /** Issue-only: this issue's direct sub-issues (first 50). */ + subIssues?: { totalCount?: number; nodes?: RefNode[] }; timelineItems?: { nodes?: Array<{ createdAt?: string; @@ -213,6 +219,15 @@ export function parseNodeResponse( // structural edges, attributed to the actor who made the reference for (const n of item.closingIssuesReferences?.nodes ?? []) addEdge(refKey(n), "closes", node.author); + // Sub-issue links point child -> parent. The parent records its children + // separately so the crawl can reach them; the edge itself lives on the child. + addEdge(refKey(item.parent), "sub-issue"); + const subIssues = (item.subIssues?.nodes ?? []) + .map(refKey) + .filter((k): k is NodeKey => !!k && k !== node.key); + if (subIssues.length) node.subIssues = [...new Set(subIssues)]; + const subIssueTotal = item.subIssues?.totalCount ?? 0; + if (subIssueTotal > (item.subIssues?.nodes?.length ?? 0)) node.subIssueTotal = subIssueTotal; for (const tl of item.timelineItems?.nodes ?? []) { if (tl.source) addEdge(refKey(tl.source), "cross-ref", tl.actor?.login, tl.createdAt); if (tl.subject) addEdge(refKey(tl.subject), "connected", tl.actor?.login, tl.createdAt); diff --git a/src/priority.test.ts b/src/priority.test.ts index 33f13e6..f0279ca 100644 --- a/src/priority.test.ts +++ b/src/priority.test.ts @@ -70,6 +70,15 @@ describe("prioritize", () => { expect(target?.key).toBe(prioritize(nodes, NOW)[0].key); // inbound heat wins }); + test("sub-issues do not add inbound heat to their parent", () => { + const nodes = asMap([ + node("o/r#1"), + node("o/r#2", {}, { edges: [{ to: "o/r#1", via: "sub-issue" }] }), + node("o/r#3", {}, { edges: [mentions("o/r#1")] }), + ]); + expect(prioritize(nodes, NOW).find((r) => r.key === "o/r#1")?.inboundRefs).toBe(1); + }); + test("computes whole days open and caps the age boost", () => { const nodes = asMap([ node("o/r#1", { createdAt: "2026-07-06T00:00:00Z" }), diff --git a/src/priority.ts b/src/priority.ts index c56a39a..12d512d 100644 --- a/src/priority.ts +++ b/src/priority.ts @@ -42,10 +42,12 @@ export function prioritize( now: Date, weights: Weights = scoring.defaults, ): PriorityRow[] { - // distinct referencing nodes per target + // distinct referencing nodes per target; a sub-issue link is hierarchy, not + // a reference, so a parent with many children does not gain heat from them const inbound = new Map(); for (const n of nodes.values()) - for (const e of n.edges) inbound.set(e.to, (inbound.get(e.to) ?? 0) + 1); + for (const e of n.edges) + if (e.via !== "sub-issue") inbound.set(e.to, (inbound.get(e.to) ?? 0) + 1); const rows: PriorityRow[] = []; for (const n of nodes.values()) { diff --git a/src/render.test.ts b/src/render.test.ts index 2a9cb4d..3f287ea 100644 --- a/src/render.test.ts +++ b/src/render.test.ts @@ -43,6 +43,7 @@ describe("display helpers", () => { expect(kindTag("PullRequest")).toBe("PR"); expect(viaTag("cross-ref")).toBe("cross-ref"); expect(viaTag("text")).toBe("mentions"); + expect(viaTag("sub-issue")).toBe("sub-issue of"); }); }); @@ -84,6 +85,24 @@ describe("render", () => { expect(render(nodes, ["o/r#1"], false)).toContain("beyond depth"); }); + test("lists sub-issues that were not crawled as beyond depth", () => { + const nodes = new Map([ + ["o/r#1", node("o/r#1", { depth: 0, hub: true, subIssues: ["o/r#2", "o/r#3"] })], + ["o/r#2", node("o/r#2", { edges: [{ to: "o/r#1", via: "sub-issue" }] })], + ]); + const out = render(nodes, ["o/r#1"], false); + expect(out).toContain("sub-issue of → o/r#1"); + expect(out).toContain("Beyond depth limit (1 refs not crawled)"); + expect(out).toContain("- o/r#3"); + }); + + test("says when a parent's sub-issue list was cut off", () => { + const nodes = new Map([ + ["o/r#1", node("o/r#1", { depth: 0, subIssues: ["o/r#2"], subIssueTotal: 60 })], + ]); + expect(render(nodes, ["o/r#1"], false)).toContain("sub-issues: read 1 of 60 (per-node limit)"); + }); + test("shows PR triage summary and derived flags", () => { const nodes = new Map([ [ diff --git a/src/render.ts b/src/render.ts index 494079b..ec5b6ff 100644 --- a/src/render.ts +++ b/src/render.ts @@ -23,7 +23,13 @@ export const kindTag = (k: string): string => k === "PullRequest" ? "PR" : k === "Issue" ? "issue" : k; export const viaTag = (v: Via | string): string => - ({ closes: "closes", "cross-ref": "cross-ref", connected: "linked", text: "mentions" })[v] ?? v; + ({ + closes: "closes", + "cross-ref": "cross-ref", + connected: "linked", + text: "mentions", + "sub-issue": "sub-issue of", + })[v] ?? v; /** Render the graph body: header, components (multi-seed), nodes, beyond-depth, orphan checklist. */ export function render( @@ -69,11 +75,17 @@ export function render( ); } for (const x of n.externalLinks) out.push(` - external → ${x}`); + if (n.subIssueTotal) + out.push( + ` - sub-issues: read ${n.subIssues?.length ?? 0} of ${n.subIssueTotal} (per-node limit)`, + ); } const uncrawled = new Set(); - for (const n of nodes.values()) + for (const n of nodes.values()) { for (const e of n.edges) if (!nodes.has(e.to)) uncrawled.add(e.to); + for (const k of n.subIssues ?? []) if (!nodes.has(k)) uncrawled.add(k); + } if (uncrawled.size) { out.push(`\n## Beyond depth limit (${uncrawled.size} refs not crawled)\n`); for (const k of [...uncrawled].sort()) out.push(`- ${k}`); diff --git a/src/types.ts b/src/types.ts index 223b7a2..ba4c372 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,8 +4,11 @@ export type Kind = "Issue" | "PullRequest" | "Unknown"; /** Canonical node identity: `${owner}/${repo}#${number}`. */ export type NodeKey = string; -/** How one node came to reference another. */ -export type Via = "text" | "cross-ref" | "connected" | "closes"; +/** + * How one node came to reference another. `sub-issue` points from a sub-issue + * to its parent issue; it is a hierarchy link, never a closing link. + */ +export type Via = "text" | "cross-ref" | "connected" | "closes" | "sub-issue"; /** A directed reference from one node to another, with attribution. */ export interface Edge { @@ -91,6 +94,17 @@ export interface GraphNode { * links to catch the silent "says it fixes X but won't auto-close" gotcha. */ claimsClose?: NodeKey[]; + /** + * Issue-only: direct sub-issues (first 50). The link itself is recorded as a + * `sub-issue` edge on the child; this list lets the crawl reach children + * from the parent. + */ + subIssues?: NodeKey[]; + /** + * Set only when the parent has more sub-issues than the query read, so the + * report can say the list is incomplete. + */ + subIssueTotal?: number; } /** A single seed to start crawling from. */