Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions apps/docs/content/docs/graph.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/scripts/prepare-example.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
}),
);
Expand Down
2 changes: 2 additions & 0 deletions scripts/verify-transports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ function normalize(nodes: Map<string, GraphNode>) {
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,
}));
}

Expand Down
1 change: 1 addition & 0 deletions skill-data/core/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions skill-data/core/references/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,15 +256,15 @@ 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 <date>` (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.
- **Derived triage.** Open PRs are marked superseded when they share a closing target with merged work, or possibly superseded when they are structurally linked to an issue closed by a later merged PR. `competing` (>1 open PR closes an issue) and `claims-close-no-link` (a `fixes #N` that won't auto-close) are computed and attached per node.
- **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

Expand Down
14 changes: 14 additions & 0 deletions src/classify.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
4 changes: 2 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -577,7 +577,7 @@ export async function runCli(argv = process.argv.slice(2)): Promise<void> {
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`;
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/cluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
"",
Expand Down
66 changes: 63 additions & 3 deletions src/crawl.test.ts
Original file line number Diff line number Diff line change
@@ -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<NodeKey, Array<Partial<Edge> & { to: NodeKey }>>): FetchNode {
function fakeFetch(
graph: Record<NodeKey, Array<Partial<Edge> & { to: NodeKey }>>,
subIssues: Record<NodeKey, NodeKey[]> = {},
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,
Expand All @@ -22,6 +28,8 @@ function fakeFetch(graph: Record<NodeKey, Array<Partial<Edge> & { to: NodeKey }>
edges,
externalLinks: [],
fetched: true,
...(subIssues[key] ? { subIssues: subIssues[key] } : {}),
...(failed.includes(key) ? { state: "FETCH_ERROR", fetched: false } : {}),
} satisfies GraphNode;
};
}
Expand Down Expand Up @@ -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;
Expand Down
36 changes: 32 additions & 4 deletions src/crawl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<NodeKey> {
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<NodeKey, GraphNode>): 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, GraphNode>): NodeKey[][] {
const parent = new Map<NodeKey, NodeKey>();
Expand Down
38 changes: 38 additions & 0 deletions src/dashboard-filters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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("<script>") + 8, html.lastIndexOf("</script>"));
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];
Expand Down
Loading