diff --git a/pm-stack/aggregator/src/epic-graph.ts b/pm-stack/aggregator/src/epic-graph.ts new file mode 100644 index 000000000..1c9d1c696 --- /dev/null +++ b/pm-stack/aggregator/src/epic-graph.ts @@ -0,0 +1,360 @@ +// Per-epic dependency graph for Project-pool items (#3973; design +// doc/2026-11-01T21-05-00Z_design_aggregator_dependency_graph_1257.md, +// merged in PR #3980). Slice A: the data layer — epic selector data +// plus one selected epic's graph. Slice B (render.ts) consumes this. +// +// Pure module: no I/O, no feed access. The graph source is the native +// relation contract from #2399 — parentRef / blockedBy / blocks / +// epicRef on ProjectPoolItem — never prose. Honesty rules, mirroring +// graph.ts: +// - dependency edges stay distinct from hierarchy (parent) edges; +// - a one-hop ref outside the pool is an unresolved external node: +// canonical ref + a link built from the ref, but no fabricated +// title or state, and it never counts as resolved; +// - a capped graph reports its true total and the truncated count; +// - an epic with no pool items, or an epic ref the pool does not +// hold, is "unavailable" — never an empty success. + +import type { ProjectPoolItem, RefKey } from "./model.ts"; +import { buildEpicRollups } from "./epics.ts"; +import { modeOf } from "./weights.ts"; + +export const EPIC_GRAPH_DEFAULT_CAP = 120; + +export interface EpicSelectorEntry { + ref: RefKey; + number: number; + title: string; + issueUrl: string; + state: string; + /** Pool items whose epicRef names this epic (the epic itself excluded). */ + itemCount: number; + /** Open member items waiting on at least one unresolved blocker. */ + blockedCount: number; + /** Member items done under the one epic done-rule (#3954). */ + doneCount: number; +} + +/** Selector data for the per-epic graph: every epic in the pool that + * has at least one member item, with the rollup counts from + * buildEpicRollups so the numbers cannot drift from the other epic + * surfaces. Ordered by blocked count, then item count, then ref — + * the epics with the most blocked work surface first, ties stable. */ +export function epicSelector(pool: readonly ProjectPoolItem[]): EpicSelectorEntry[] { + const rollups = buildEpicRollups(pool); + const entries: EpicSelectorEntry[] = []; + for (const item of pool) { + if (item.kind !== "Epic") continue; + const rollup = rollups.get(item.ref); + if (!rollup || rollup.total === 0) continue; + entries.push({ + ref: item.ref, + number: item.number, + title: item.title, + issueUrl: item.issueUrl, + state: item.state, + itemCount: rollup.total, + blockedCount: rollup.blocked, + doneCount: rollup.done, + }); + } + entries.sort( + (a, b) => + b.blockedCount - a.blockedCount || b.itemCount - a.itemCount || a.ref.localeCompare(b.ref), + ); + return entries; +} + +export type EpicGraphEdgeType = "dependency" | "hierarchy"; + +export interface EpicGraphNode { + ref: RefKey; + number: number; + /** Null for an out-of-pool ref: the pool has no title for it and + * none is fabricated. */ + title: string | null; + issueUrl: string; + /** Pool state ("open" / "closed" / "merged"); null when the ref is + * outside the pool and its state is unknown. */ + state: string | null; + /** The node is a pool item (member, the epic, or a one-hop pool ref). */ + inPool: boolean; + /** The node's epicRef names the selected epic (or it is the epic). */ + member: boolean; + /** Out-of-pool ref: not in this pool, status unknown, unresolved. */ + unresolved: boolean; + epic: boolean; + /** mode/* label value for pool items, else null. */ + mode: string | null; + /** Native blockedBy refs of the pool item (all of them, including + * any the cap left unshown); empty for external nodes. */ + blockers: RefKey[]; + /** Native blocks refs of the pool item; empty for external nodes. */ + dependents: RefKey[]; + /** Depth in the hierarchy layout (roots are 0). */ + depth: number; + /** Row in the laid-out graph, DFS order. */ + row: number; +} + +export interface EpicGraphEdge { + from: RefKey; + to: RefKey; + type: EpicGraphEdgeType; +} + +export interface EpicGraph { + status: "ok" | "unavailable"; + /** Why the graph is unavailable; null when status is "ok". */ + reason: string | null; + epic: { ref: RefKey; number: number; title: string; issueUrl: string; state: string } | null; + nodes: EpicGraphNode[]; + edges: EpicGraphEdge[]; + /** Nodes in the full (uncapped) graph. */ + totalNodes: number; + /** Nodes left out by the cap (0 when everything is shown). */ + truncated: number; + /** Out-of-pool unresolved refs in the full one-hop set, counted + * before the node cap: external refs sort last in the node order, + * so a capped graph would otherwise report zero of them. */ + externalCount: number; + /** Pool items in the graph whose epicRef names the epic. */ + memberCount: number; + /** Open member items waiting on at least one unresolved blocker. */ + blockedCount: number; + /** Every open member waiting on unresolved blockers, computed over + * the full member set BEFORE the node cap, worst-blocked first. The + * ranked summary renders from this, never from the capped nodes: + * a member the cap hid is still blocked. */ + rankedBlocked: { ref: RefKey; issueUrl: string; blockers: RefKey[] }[]; + maxDepth: number; +} + +function unavailable(reason: string): EpicGraph { + return { + status: "unavailable", + reason, + epic: null, + nodes: [], + edges: [], + totalNodes: 0, + truncated: 0, + externalCount: 0, + memberCount: 0, + blockedCount: 0, + rankedBlocked: [], + maxDepth: 0, + }; +} + +/** Canonical issue URL for a ref ("owner/repo#123"). */ +function refIssueUrl(ref: RefKey): string { + const hash = ref.lastIndexOf("#"); + const slug = hash > 0 ? ref.slice(0, hash) : ref; + const number = hash > 0 ? ref.slice(hash + 1) : ""; + return `https://github.com/${slug}/issues/${number}`; +} + +function refNumber(ref: RefKey): number { + const hash = ref.lastIndexOf("#"); + const n = hash > 0 ? Number(ref.slice(hash + 1)) : NaN; + return Number.isFinite(n) ? n : 0; +} + +/** The selected epic's graph: the epic, its member items, and the + * one-hop native references of those items (parent, blockedBy, + * blocks) — pool items when the pool holds them, unresolved external + * stubs when it does not. No second hop is followed. */ +export function epicGraph( + pool: readonly ProjectPoolItem[], + epicRef: RefKey, + cap: number = EPIC_GRAPH_DEFAULT_CAP, +): EpicGraph { + const byRef = new Map(pool.map((p) => [p.ref, p])); + const epicItem = byRef.get(epicRef); + if (!epicItem || epicItem.kind !== "Epic") { + return unavailable(`epic ${epicRef} is not in this pool, so its dependency graph is unavailable`); + } + const members = pool.filter((p) => p.epicRef === epicRef); + if (members.length === 0) { + return unavailable(`no pool items belong to epic ${epicRef} yet — it is not yet graphed`); + } + + // Deterministic node order: the epic, members by ref, one-hop pool + // refs by ref, external refs by ref. The cap keeps the head of this + // order; the tail is reported as truncated, never silently dropped. + const memberSet = new Set([epicRef, ...members.map((m) => m.ref)]); + const oneHopPool = new Map(); + const externalRefs = new Set(); + const collectRefs = (item: ProjectPoolItem) => { + const targets = [ + ...(item.parentRef !== undefined ? [item.parentRef] : []), + ...(item.blockedBy ?? []), + ...(item.blocks ?? []), + ]; + for (const target of targets) { + if (memberSet.has(target)) continue; + const held = byRef.get(target); + if (held) oneHopPool.set(target, held); + else externalRefs.add(target); + } + }; + collectRefs(epicItem); + for (const m of members) collectRefs(m); + + const byRefOrder = (a: RefKey, b: RefKey) => a.localeCompare(b); + const ordered: { item: ProjectPoolItem | null; ref: RefKey }[] = [ + { item: epicItem, ref: epicRef }, + ...[...members].sort((a, b) => byRefOrder(a.ref, b.ref)).map((m) => ({ item: m, ref: m.ref })), + ...[...oneHopPool.values()] + .sort((a, b) => byRefOrder(a.ref, b.ref)) + .map((m) => ({ item: m, ref: m.ref })), + ...[...externalRefs].sort(byRefOrder).map((ref) => ({ item: null, ref })), + ]; + const totalNodes = ordered.length; + const shown = ordered.slice(0, Math.max(0, cap)); + const shownRefs = new Set(shown.map((s) => s.ref)); + + // Edges among shown nodes only. Dependency edges point from the + // blocked item to its blocker (blockedBy) and from the blocked item + // to the item a blocks[] entry names, so both native directions + // land on the same item → blocker orientation; hierarchy edges + // point parent → child. A pair joined by both keeps both edges. + const edgeKeys = new Set(); + const edges: EpicGraphEdge[] = []; + const addEdge = (from: RefKey, to: RefKey, type: EpicGraphEdgeType) => { + if (from === to || !shownRefs.has(from) || !shownRefs.has(to)) return; + const key = `${type}:${from}->${to}`; + if (edgeKeys.has(key)) return; + edgeKeys.add(key); + edges.push({ from, to, type }); + }; + for (const { item } of shown) { + if (!item) continue; + if (item.parentRef !== undefined) addEdge(item.parentRef, item.ref, "hierarchy"); + for (const blocker of item.blockedBy ?? []) addEdge(item.ref, blocker, "dependency"); + for (const blocked of item.blocks ?? []) addEdge(blocked, item.ref, "dependency"); + } + + // Hierarchy layout, same discipline as depthLayout: DFS from roots + // (parent absent or outside the shown set), children in ref order, + // parent cycles degrade to roots instead of looping. + const children = new Map(); + const hasShownParent = new Set(); + for (const e of edges) { + if (e.type !== "hierarchy") continue; + const list = children.get(e.from) ?? []; + list.push(e.to); + children.set(e.from, list); + hasShownParent.add(e.to); + } + for (const list of children.values()) list.sort(byRefOrder); + const roots = shown + .map((s) => s.ref) + .filter((ref) => !hasShownParent.has(ref)) + .sort((a, b) => (a === epicRef ? -1 : b === epicRef ? 1 : byRefOrder(a, b))); + const layout = new Map(); + const visit = (ref: RefKey, depth: number) => { + if (layout.has(ref)) return; + layout.set(ref, { depth, row: layout.size }); + for (const child of children.get(ref) ?? []) visit(child, depth + 1); + }; + for (const root of roots) visit(root, 0); + for (const { ref } of shown) visit(ref, 0); // cycle leftovers become roots + + // Summaries cover the FULL member set, before the node cap: a member + // hidden by the cap is still blocked, still counted, still ranked. + const unresolvedBlockersOf = (item: ProjectPoolItem): RefKey[] => + (item.blockedBy ?? []).filter((b) => { + const blocker = byRef.get(b); + return blocker === undefined || blocker.state === "open"; + }); + const blockedCount = members.filter( + (m) => m.state === "open" && unresolvedBlockersOf(m).length > 0, + ).length; + const rankedBlocked = members + .filter((m) => m.ref !== epicRef && m.state === "open") + .map((m) => ({ ref: m.ref, issueUrl: m.issueUrl, blockers: unresolvedBlockersOf(m) })) + .filter((r) => r.blockers.length > 0) + .sort((a, b) => b.blockers.length - a.blockers.length || byRefOrder(a.ref, b.ref)); + + const nodes: EpicGraphNode[] = shown.map(({ item, ref }) => { + const pos = layout.get(ref) ?? { depth: 0, row: 0 }; + if (!item) { + return { + ref, + number: refNumber(ref), + title: null, + issueUrl: refIssueUrl(ref), + state: null, + inPool: false, + member: false, + unresolved: true, + epic: false, + mode: null, + blockers: [], + dependents: [], + depth: pos.depth, + row: pos.row, + }; + } + return { + ref, + number: item.number, + title: item.title, + issueUrl: item.issueUrl, + state: item.state, + inPool: true, + member: memberSet.has(ref), + unresolved: false, + epic: item.kind === "Epic", + mode: modeOf(item.labels), + blockers: [...(item.blockedBy ?? [])], + dependents: [...(item.blocks ?? [])], + depth: pos.depth, + row: pos.row, + }; + }); + nodes.sort((a, b) => a.row - b.row); + + // External nodes have no pool item to read relations from, but the + // edges already observed name them: a member blocked by an external + // ref means that ref blocks the member, and a member's blocks[] entry + // naming an external ref means the ref is blocked by the member. + // Fill the external nodes' lists from the edges so the text list + // agrees with the graph instead of reporting "none observed". + const nodeByRef = new Map(nodes.map((n) => [n.ref, n])); + for (const e of edges) { + if (e.type !== "dependency") continue; + const from = nodeByRef.get(e.from); + const to = nodeByRef.get(e.to); + if (from?.unresolved && !from.blockers.includes(e.to)) from.blockers.push(e.to); + if (to?.unresolved && !to.dependents.includes(e.from)) to.dependents.push(e.from); + } + for (const n of nodes) { + if (!n.unresolved) continue; + n.blockers.sort(byRefOrder); + n.dependents.sort(byRefOrder); + } + + return { + status: "ok", + reason: null, + epic: { + ref: epicItem.ref, + number: epicItem.number, + title: epicItem.title, + issueUrl: epicItem.issueUrl, + state: epicItem.state, + }, + nodes, + edges, + totalNodes, + truncated: totalNodes - shown.length, + externalCount: externalRefs.size, + memberCount: members.length, + blockedCount, + rankedBlocked, + maxDepth: nodes.reduce((m, n) => Math.max(m, n.depth), 0), + }; +} diff --git a/pm-stack/aggregator/src/render.ts b/pm-stack/aggregator/src/render.ts index a8ef1393a..6098c734a 100644 --- a/pm-stack/aggregator/src/render.ts +++ b/pm-stack/aggregator/src/render.ts @@ -108,6 +108,7 @@ import { buildItemBrief, type ItemBrief } from "./brief.ts"; import { buildEpicRollups, isEpicChildDone, type EpicRollup } from "./epics.ts"; import { effortWeight, handsLine, summarizeWeights, weightedLine } from "./weights.ts"; import { blockedSummary, depthLayout, hasGraphEdges } from "./graph.ts"; +import { EPIC_GRAPH_DEFAULT_CAP, epicGraph, epicSelector } from "./epic-graph.ts"; import { type AuthorIdentity } from "./registry.ts"; import type { SearchResult } from "./search-view.ts"; @@ -429,6 +430,14 @@ body.xp-plan .xpl-costcol { display: none; } .qblock { border-top-color: #d1d9e0; } .cbox { border-top-color: #d1d9e0; } } + .epic-graph-flex { display: flex; gap: 16px; align-items: flex-start; } + .epic-graph-svg { flex: 1 1 auto; min-width: 0; overflow-x: auto; } + .epic-graph-side { flex: 0 0 320px; max-width: 100%; } + .epic-graph-list { margin: 0; padding-left: 18px; } + .epic-graph-list li { margin: 4px 0; } + .epic-graph-selector { position: static; } + .epic-graph-selector a { white-space: normal; display: inline; min-height: 0; padding: 0; } + @media (max-width: 720px) { .epic-graph-flex { flex-direction: column; } .epic-graph-side { flex: none; width: 100%; } } @media (max-width: 720px) { body { font-size: 13.5px; } body { overflow-x: clip; } @@ -6668,6 +6677,152 @@ function poolGraphHtml(items: ProjectPoolItem[], allW: ReturnTypeBlocked + depth graph${blockedHtml}${svg}`; } + +/** The #3973 per-epic dependency graph section of a project page + * (design doc/2026-11-01T21-05-00Z_design_aggregator_dependency_graph_1257.md): + * an epic selector with item/blocked counts, then per epic a summary + + * ranked blocked list beside the graph SVG and a vertical, + * keyboard-readable dependency list. The list is the primary surface + * on narrow screens (the flex column stacks it full-width; the SVG + * may scroll horizontally as a secondary detail). Dependency edges + * are dashed amber, hierarchy edges solid grey, and every node's + * state is written as text — color is never the only signal. Empty + * or unavailable graph data says so; it is never an empty success. */ +/** Section anchor for one epic's graph, derived from the canonical ref + * (owner/repo#number), never the bare issue number: two repositories + * can hold epics with the same number, and bare-number ids collide. */ +function epicGraphAnchor(ref: RefKey): string { + return `epic-graph-${ref.replace(/[^a-zA-Z0-9]+/g, "-")}`; +} + +function epicGraphSectionHtml(items: ProjectPoolItem[], selectorItems: ProjectPoolItem[] = items): string { + // Selector entries and rendered sections are scoped to the project's + // own pool (selectorItems = detail.poolItems): on a normal project + // request the caller passes the entire org pool as `items` for + // membership and blocker resolution, and rendering every eligible + // epic from every Area would bury this project's section in unrelated + // projects (Codex round-2 on #3973). Resolution inside each section + // still runs against the full `items` pool. + // The selector rollups are computed from the full `items` pool too, + // then filtered to this project's epic refs (Codex round-3 on + // #3973): computing them from the scoped pool made a member whose + // blocker lives in another Area count as blocked in the selector + // while its section — resolving against the full pool — correctly + // reported it unblocked. + const projectEpicRefs = new Set( + selectorItems.filter((i) => i.kind === "Epic").map((i) => i.ref), + ); + const selector = epicSelector(items).filter((e) => projectEpicRefs.has(e.ref)); + if (!selector.length) { + return `

Per-epic dependency graph

`; + } + const selectorHtml = ``; + const sections = selector.map((entry) => epicGraphOneHtml(items, entry)).join(""); + return `

Per-epic dependency graph

${selectorHtml}${sections}`; +} + +function epicGraphOneHtml( + items: ProjectPoolItem[], + entry: ReturnType[number], +): string { + const g = epicGraph(items, entry.ref); + const heading = `

${esc(entry.title)} (${esc(entry.ref)})

`; + if (g.status === "unavailable" || !g.epic) { + return `
${heading}
`; + } + const poolByRef = new Map(items.map((p) => [p.ref, p])); + const unresolvedOf = (ref: RefKey): boolean => { + const held = poolByRef.get(ref); + return held === undefined || held.state === "open"; + }; + const summary = `

${g.memberCount} item${g.memberCount === 1 ? "" : "s"} in this epic · ${g.blockedCount} blocked on an unresolved blocker · ${g.externalCount} one-hop reference${g.externalCount === 1 ? "" : "s"} outside this pool (unresolved — status unknown, never counted as resolved).

`; + const truncatedHtml = g.truncated + ? `

Truncated: this graph shows ${g.nodes.length} of ${g.totalNodes} nodes (cap ${EPIC_GRAPH_DEFAULT_CAP}). The counts above cover the full set; a capped graph is not complete.

` + : ""; + + // Ranked summary: open member items by how many unresolved blockers + // they wait on, from the graph's pre-cap ranking (the full transitive + // unblock ranking is #3974; this is the per-epic blocked summary + // beside the graph). Never derived from the capped node list: a + // member the cap hid is still waiting. + const ranked = g.rankedBlocked; + const rankedHtml = ranked.length + ? `
    ${ranked + .map( + (r) => + `
  • ${esc(r.ref)} — waiting on ${r.blockers.length} unresolved blocker${r.blockers.length === 1 ? "" : "s"}: ${r.blockers.map((b) => `${esc(b)}`).join(", ")}
  • `, + ) + .join("")}
` + : `

No open item in this epic is waiting on an unresolved blocker.

`; + + // SVG: hierarchy edges solid grey, dependency edges dashed amber — + // distinct by dash and color, and named in the legend text. + const pos = new Map(g.nodes.map((n) => [n.ref, n])); + const X = (depth: number) => 16 + depth * 180; + const Y = (row: number) => 16 + row * 26; + const width = X(g.maxDepth) + 340; + const height = g.nodes.length ? Y(g.nodes.length - 1) + 16 : 32; + // Dependency edges carry an arrowhead marker pointing at the blocker + // (the `to` node): hierarchy depth alone does not show which sibling + // blocks which (Codex round-2 on #3973). The marker id is per-epic -- + // several epic sections share one page, and duplicate ids would make + // every edge reference the first section's marker. + const depMarkerId = `dep-arrow-${epicGraphAnchor(g.epic.ref)}`; + const depDefs = g.edges.some((e) => e.type === "dependency") + ? `` + : ""; + const edgeLines = g.edges + .map((e) => { + const a = pos.get(e.from)!; + const b = pos.get(e.to)!; + return e.type === "dependency" + ? `` + : ``; + }) + .join(""); + const nodeShapes = g.nodes + .map((n) => { + const label = n.title + ? `#${n.number} ${n.title.length > 34 ? `${n.title.slice(0, 33)}…` : n.title}` + : `${n.ref} (outside this pool)`; + const x = X(n.depth); + const y = Y(n.row); + const shape = n.unresolved + ? `` + : n.epic + ? `` + : ``; + return `${shape}${esc(label)}`; + }) + .join(""); + const svg = g.edges.length + ? `
${depDefs}${edgeLines}${nodeShapes}

Solid grey lines are sub-issue hierarchy (parent → child); dashed amber lines are dependency edges, with an arrowhead pointing at the blocker (item → blocker). Squares are epics; diamonds are references outside this pool whose status is unknown. Every node's state is also written as text in the list — color is never the only signal.

` + : `

Not yet graphed: this epic's items carry no parent / blocked-by edges yet, so there is no graph to draw. The items are listed below.

`; + + const listHtml = `
    ${g.nodes + .map((n) => { + const stateText = n.unresolved + ? "not in this pool / status unknown — unresolved" + : `issue ${esc(n.state ?? "unknown")}`; + const blockedBy = n.blockers.length + ? `blocked by: ${n.blockers.map((b) => `${esc(b)}${unresolvedOf(b) ? " (unresolved)" : " (resolved)"}`).join(", ")}` + : "blocked by: none observed"; + const blocks = n.dependents.length + ? `blocks: ${n.dependents.map((b) => `${esc(b)}`).join(", ")}` + : "blocks: none observed"; + const name = n.title ? `${esc(n.title)}` : "(outside this pool — no title in this feed)"; + return `
  • ${esc(n.ref)} ${name} — ${stateText}; ${blockedBy}; ${blocks}
  • `; + }) + .join("")}
`; + + return `
${heading}${summary}${truncatedHtml}
${svg}
Blocked in this epic
${rankedHtml}
All items — dependency list
${listHtml}
`; +} + export function renderProgramProject( detail: ProjectDetail, items: Map, @@ -6739,7 +6894,7 @@ export function renderProgramProject( const epicHtml = epics.length ? `

Epics — weighted progress

${esc(weightedLine(epicW))}

${epicRows}` : ""; - poolHtml = `

${esc(weightedLine(allW))}
${esc(handsLine(allW))}
${detail.poolItems.length} items with Area ${esc(config.poolArea)} — by status: ${countList(detail.poolByStatus)}. Full pool view: /projects?area=${esc(config.poolArea)}.

${epicHtml}${poolGraphHtml(detail.poolItems, allW)}${cards}${more}`; + poolHtml = `

${esc(weightedLine(allW))}
${esc(handsLine(allW))}
${detail.poolItems.length} items with Area ${esc(config.poolArea)} — by status: ${countList(detail.poolByStatus)}. Full pool view: /projects?area=${esc(config.poolArea)}.

${epicHtml}${poolGraphHtml(detail.poolItems, allW)}${epicGraphSectionHtml(epicPool, detail.poolItems)}${cards}${more}`; } let roadmapHtml: string; diff --git a/pm-stack/aggregator/test/epic-graph.test.ts b/pm-stack/aggregator/test/epic-graph.test.ts new file mode 100644 index 000000000..52a723f93 --- /dev/null +++ b/pm-stack/aggregator/test/epic-graph.test.ts @@ -0,0 +1,373 @@ +import { describe, expect, test } from "bun:test"; +import { epicGraph, epicSelector } from "../src/epic-graph.ts"; +import type { ProjectPoolItem, RefKey } from "../src/model.ts"; + +const R = (n: number): RefKey => `developing-today/code#${n}` as RefKey; + +function item(partial: Partial & { number: number }): ProjectPoolItem { + return { + id: `PVTI_${partial.number}`, + url: "https://github.com/orgs/developing-today/projects/4?pane=issue&itemId=1", + projectUrl: "https://github.com/orgs/developing-today/projects/4", + ref: R(partial.number), + repo: "developing-today/code", + issueUrl: `https://github.com/developing-today/code/issues/${partial.number}`, + title: `item ${partial.number}`, + state: "open", + contentKind: "issue", + status: null, + area: "pm-dashboard", + horizon: null, + kind: null, + ...partial, + }; +} + +function fixturePool(): ProjectPoolItem[] { + return [ + item({ number: 1, title: "Epic one", kind: "Epic" }), + item({ number: 2, epicRef: R(1), parentRef: R(1), blockedBy: [R(99)] }), + item({ number: 3, epicRef: R(1), parentRef: R(2), blockedBy: [R(2)] }), + item({ number: 4, epicRef: R(1), state: "closed" }), + item({ number: 5, title: "Other epic", kind: "Epic" }), + item({ number: 6, epicRef: R(5), blockedBy: [R(2)] }), + item({ number: 7, title: "Epic without children", kind: "Epic" }), + item({ number: 8 }), + ]; +} + +describe("epicSelector", () => { + test("lists eligible epics with item and blocked counts", () => { + const entries = epicSelector(fixturePool()); + expect(entries.map((e) => e.ref)).toEqual([R(1), R(5)]); + const one = entries[0]!; + expect(one.itemCount).toBe(3); + expect(one.blockedCount).toBe(2); // #2 waits on out-of-pool #99; #3 waits on open #2 + expect(one.doneCount).toBe(1); // #4 closed + expect(entries[1]!.itemCount).toBe(1); + expect(entries[1]!.blockedCount).toBe(1); // #6 waits on open #2 + }); + + test("an epic with no member items is not eligible", () => { + const entries = epicSelector(fixturePool()); + expect(entries.some((e) => e.ref === R(7))).toBe(false); + }); + + test("no epics in the pool yields an empty selector", () => { + expect(epicSelector([item({ number: 1 }), item({ number: 2 })])).toEqual([]); + }); +}); + +describe("epicGraph", () => { + test("unknown or non-epic ref is unavailable, never an empty success", () => { + const missing = epicGraph(fixturePool(), R(42)); + expect(missing.status).toBe("unavailable"); + expect(missing.reason).toContain("not in this pool"); + const notEpic = epicGraph(fixturePool(), R(8)); + expect(notEpic.status).toBe("unavailable"); + }); + + test("an epic with no members is unavailable with the reason stated", () => { + const g = epicGraph(fixturePool(), R(7)); + expect(g.status).toBe("unavailable"); + expect(g.reason).toContain("not yet graphed"); + }); + + test("selection yields exactly the epic pool plus one-hop refs", () => { + const g = epicGraph(fixturePool(), R(1)); + expect(g.status).toBe("ok"); + expect(g.memberCount).toBe(3); + const refs = g.nodes.map((n) => n.ref).sort(); + // epic #1, members #2 #3 #4, one-hop pool ref #5? no — #6 belongs + // to epic #5 and blocks nothing here; #2's blocker #99 is external. + expect(refs).toEqual([R(1), R(2), R(3), R(4), R(99)].sort()); + expect(g.totalNodes).toBe(5); + expect(g.truncated).toBe(0); + }); + + test("one-hop pool refs from another epic are included as pool nodes", () => { + const g = epicGraph(fixturePool(), R(5)); + const two = g.nodes.find((n) => n.ref === R(2)); + expect(two).toBeDefined(); + expect(two!.inPool).toBe(true); + expect(two!.member).toBe(false); + expect(two!.unresolved).toBe(false); + }); + + test("out-of-pool refs are unresolved external nodes with no fabricated title or state", () => { + const g = epicGraph(fixturePool(), R(1)); + const ext = g.nodes.find((n) => n.ref === R(99)); + expect(ext).toBeDefined(); + expect(ext!.inPool).toBe(false); + expect(ext!.unresolved).toBe(true); + expect(ext!.title).toBeNull(); + expect(ext!.state).toBeNull(); + expect(ext!.issueUrl).toBe("https://github.com/developing-today/code/issues/99"); + expect(g.externalCount).toBe(1); + }); + + test("dependency edges are distinct from hierarchy edges", () => { + const g = epicGraph(fixturePool(), R(1)); + const kinds = g.edges.map((e) => `${e.type}:${e.from}->${e.to}`).sort(); + expect(kinds).toContain(`hierarchy:${R(1)}->${R(2)}`); + expect(kinds).toContain(`hierarchy:${R(2)}->${R(3)}`); + expect(kinds).toContain(`dependency:${R(2)}->${R(99)}`); + expect(kinds).toContain(`dependency:${R(3)}->${R(2)}`); + }); + + test("blocks[] lands as a dependency edge in item-to-blocker orientation", () => { + const pool = [ + item({ number: 1, title: "Epic", kind: "Epic" }), + item({ number: 2, epicRef: R(1), blocks: [R(3)] }), + item({ number: 3, epicRef: R(1) }), + ]; + const g = epicGraph(pool, R(1)); + expect(g.edges).toContainEqual({ from: R(3), to: R(2), type: "dependency" }); + }); + + test("truncation reports the true total and the cut count", () => { + const g = epicGraph(fixturePool(), R(1), 3); + expect(g.totalNodes).toBe(5); + expect(g.nodes.length).toBe(3); + expect(g.truncated).toBe(2); + // Edges never point at unshown nodes. + const shown = new Set(g.nodes.map((n) => n.ref)); + for (const e of g.edges) { + expect(shown.has(e.from)).toBe(true); + expect(shown.has(e.to)).toBe(true); + } + }); + + test("blocked summaries are computed before the node cap", () => { + const pool = [ + item({ number: 1, title: "Epic", kind: "Epic" }), + item({ number: 2, epicRef: R(1) }), + item({ number: 3, epicRef: R(1) }), + item({ number: 4, epicRef: R(1) }), + item({ number: 5, epicRef: R(1), blockedBy: [R(99)] }), + ]; + const g = epicGraph(pool, R(1), 3); + expect(g.nodes.map((n) => n.ref)).not.toContain(R(5)); + expect(g.blockedCount).toBe(1); // #5 is blocked even though the cap hides it + expect(g.rankedBlocked.map((r) => r.ref)).toEqual([R(5)]); + expect(g.rankedBlocked[0]!.blockers).toEqual([R(99)]); + }); + + test("external count is computed before the node cap (Codex round-3)", () => { + const pool = [ + item({ number: 1, title: "Epic", kind: "Epic" }), + item({ number: 2, epicRef: R(1) }), + item({ number: 3, epicRef: R(1) }), + item({ number: 4, epicRef: R(1), blockedBy: [R(99)] }), + ]; + const g = epicGraph(pool, R(1), 3); + // External refs sort last in the node order, so the cap hides the + // only external node; the summary count must still report it. + expect(g.nodes.map((n) => n.ref)).not.toContain(R(99)); + expect(g.externalCount).toBe(1); + }); + + test("external nodes keep the dependency links observed for them", () => { + const g = epicGraph(fixturePool(), R(1)); + const ext = g.nodes.find((n) => n.ref === R(99))!; + expect(ext.dependents).toEqual([R(2)]); // #2 is blocked by #99, so #99 blocks #2 + expect(ext.blockers).toEqual([]); + const pool = [ + item({ number: 1, title: "Epic", kind: "Epic" }), + item({ number: 2, epicRef: R(1), blocks: [R(98)] }), + ]; + const g2 = epicGraph(pool, R(1)); + const ext2 = g2.nodes.find((n) => n.ref === R(98))!; + expect(ext2.blockers).toEqual([R(2)]); // #2 blocks #98, so #98 is blocked by #2 + expect(ext2.dependents).toEqual([]); + }); + + test("a parent cycle degrades to roots instead of looping", () => { + const pool = [ + item({ number: 1, title: "Epic", kind: "Epic" }), + item({ number: 2, epicRef: R(1), parentRef: R(3) }), + item({ number: 3, epicRef: R(1), parentRef: R(2) }), + ]; + const g = epicGraph(pool, R(1)); + expect(g.status).toBe("ok"); + expect(g.nodes.length).toBe(3); + expect(new Set(g.nodes.map((n) => n.row)).size).toBe(3); + }); + + test("members with no edges still return nodes (absence is data, not an error)", () => { + const pool = [ + item({ number: 1, title: "Epic", kind: "Epic" }), + item({ number: 2, epicRef: R(1) }), + ]; + const g = epicGraph(pool, R(1)); + expect(g.status).toBe("ok"); + expect(g.nodes.length).toBe(2); + expect(g.edges).toEqual([]); + expect(g.blockedCount).toBe(0); + }); +}); + +import type { GhItem, Provenance } from "../src/model.ts"; +import { buildProjectDetail, projectConfigById } from "../src/project-config.ts"; +import { renderProgramProject } from "../src/render.ts"; + +const PROV: Provenance = { + board: "x", + roadmap: "x", + items: "x", + comments: "x", + quota: "x", + usage: "x", + attention: "x", + orchestration: "x", + dns: "x", + registry: "x", + aa: "x", +}; + +function renderWith(pool: ProjectPoolItem[]): string { + const config = projectConfigById("pm-dashboard")!; + const items = new Map(); + return renderProgramProject(buildProjectDetail(config, items, pool, []), items, PROV); +} + +describe("renderProgramProject per-epic dependency graph (#3973)", () => { + test("selector lists the epic with counts and an anchor to its section", () => { + const html = renderWith(fixturePool()); + expect(html).toContain("Per-epic dependency graph"); + expect(html).toContain('href="#epic-graph-developing-today-code-1"'); + expect(html).toContain('id="epic-graph-developing-today-code-1"'); + expect(html).toContain("3 items · 2 blocked · 1 done"); + }); + + test("dependency edges render dashed and hierarchy edges solid, with a text legend", () => { + const html = renderWith(fixturePool()); + expect(html).toContain('stroke-dasharray="5 3"'); + expect(html).toContain("dashed amber lines are dependency edges"); + expect(html).toContain("Solid grey lines are sub-issue hierarchy"); + }); + + test("the vertical list names blockers and states as text, external refs as unknown", () => { + const html = renderWith(fixturePool()); + expect(html).toContain("not in this pool / status unknown — unresolved"); + expect(html).toContain("(unresolved)"); + expect(html).toContain("blocked by:"); + expect(html).toContain("waiting on 1 unresolved blocker"); + }); + + test("a pool with no eligible epic says unavailable, not an empty graph", () => { + const html = renderWith([item({ number: 1 }), item({ number: 2, parentRef: R(1) })]); + expect(html).toContain("Dependency graph unavailable: no epic in this pool has items yet"); + expect(html).not.toContain('id="epic-graph-'); + }); + + test("an epic whose items carry no edges says not yet graphed and still lists items", () => { + const html = renderWith([ + item({ number: 1, title: "Epic", kind: "Epic" }), + item({ number: 2, epicRef: R(1) }), + ]); + expect(html).toContain("Not yet graphed: this epic's items carry no parent / blocked-by edges yet"); + expect(html).toContain("blocked by: none observed"); + }); + + test("epics with the same number in different repos get distinct anchors", () => { + const other = (n: number): RefKey => `dezren39/nix#${n}` as RefKey; + const pool = [ + item({ number: 1, title: "Epic one", kind: "Epic" }), + item({ number: 2, epicRef: R(1) }), + item({ + number: 1, + title: "Nix epic one", + kind: "Epic", + ref: other(1), + repo: "dezren39/nix", + issueUrl: "https://github.com/dezren39/nix/issues/1", + }), + item({ + number: 2, + epicRef: other(1), + ref: other(2), + repo: "dezren39/nix", + issueUrl: "https://github.com/dezren39/nix/issues/2", + }), + ]; + const html = renderWith(pool); + expect(html).toContain('id="epic-graph-developing-today-code-1"'); + expect(html).toContain('id="epic-graph-dezren39-nix-1"'); + expect(html).toContain('href="#epic-graph-dezren39-nix-1"'); + }); + + test("the epic selector nav is isolated from the global nav styles", () => { + const html = renderWith(fixturePool()); + expect(html).toContain('class="epic-graph-selector"'); + expect(html).toContain(".epic-graph-selector { position: static; }"); + expect(html).toContain(".epic-graph-selector a { white-space: normal;"); + }); + + test("the graph resolves against the full pool, not the Area-filtered detail pool", () => { + const pool = [ + item({ number: 1, title: "Epic one", kind: "Epic" }), + item({ number: 2, epicRef: R(1), blockedBy: [R(3)] }), + // The blocker is closed but lives in another Area, so the + // Area-filtered detail pool does not hold it; the full epicPool does. + item({ number: 3, state: "closed", area: "other-area" }), + ]; + const config = projectConfigById("pm-dashboard")!; + const items = new Map(); + const detail = buildProjectDetail(config, items, pool, []); + expect(detail.poolItems.map((p) => p.ref)).not.toContain(R(3)); + const html = renderProgramProject(detail, items, PROV, undefined, pool); + expect(html).toContain("0 blocked on an unresolved blocker"); + expect(html).not.toContain("1 blocked on an unresolved blocker"); + }); + + test("selector counts resolve against the full pool, entries stay project-scoped (Codex round-3)", () => { + const pool = [ + item({ number: 1, title: "Epic one", kind: "Epic" }), + item({ number: 2, epicRef: R(1), blockedBy: [R(3)] }), + // Closed blocker in another Area: only the full pool resolves it. + item({ number: 3, state: "closed", area: "other-area" }), + ]; + const config = projectConfigById("pm-dashboard")!; + const items = new Map(); + const detail = buildProjectDetail(config, items, pool, []); + const html = renderProgramProject(detail, items, PROV, undefined, pool); + // The selector entry agrees with the section: 0 blocked, not 1. + expect(html).toContain("1 item \u00b7 0 blocked \u00b7 0 done"); + expect(html).not.toContain("1 blocked \u00b7"); + }); + + test("dependency edges carry an arrowhead marker pointing at the blocker (Codex round-2)", () => { + const html = renderWith(fixturePool()); + expect(html).toContain(" { + const pool = [ + item({ number: 1, title: "Epic one", kind: "Epic" }), + item({ number: 2, epicRef: R(1) }), + // Another Area's epic, present only in the full epicPool the + // server passes for membership/blocker resolution. + item({ number: 9, title: "Other area epic", kind: "Epic", area: "other-area" }), + item({ number: 10, epicRef: R(9), area: "other-area" }), + ]; + const config = projectConfigById("pm-dashboard")!; + const items = new Map(); + const detail = buildProjectDetail(config, items, pool, []); + expect(detail.poolItems.map((p) => p.ref)).not.toContain(R(9)); + const html = renderProgramProject(detail, items, PROV, undefined, pool); + expect(html).toContain('id="epic-graph-developing-today-code-1"'); + expect(html).not.toContain('id="epic-graph-developing-today-code-9"'); + expect(html).not.toContain("Other area epic"); + }); + + test("the epic-graph flex layout and its narrow-screen stacking ship in the stylesheet", () => { + const html = renderWith(fixturePool()); + expect(html).toContain(".epic-graph-flex { display: flex;"); + expect(html).toContain(".epic-graph-flex { flex-direction: column; }"); + expect(html).toContain('class="epic-graph-flex"'); + expect(html).toContain('class="epic-graph-list"'); + }); +});