diff --git a/docs/work/_state.json b/docs/work/_state.json index 4e9b00f..35c5fd9 100644 --- a/docs/work/_state.json +++ b/docs/work/_state.json @@ -1,5 +1,5 @@ { - "updated_at": "2026-05-13T05:56:35.878Z", + "updated_at": "2026-05-13T06:06:23.176Z", "epics": { "agent-workflow-docs-v1": { "status": "done", @@ -9,7 +9,9 @@ "status": "done", "title": "Surface conformance system across top-level docs", "ac_total": 8, - "ac_completed": 8 + "ac_completed": 8, + "depends_on": [], + "blocks": [] } } }, @@ -21,13 +23,21 @@ "status": "done", "title": "Replace regex manifest source parser with AST", "ac_total": 5, - "ac_completed": 5 + "ac_completed": 5, + "depends_on": [], + "blocks": [ + "02-dev-seed-assertion" + ] }, "02-dev-seed-assertion": { "status": "done", "title": "Extend assertFeatureConformance to all bind-dev-seed paths", "ac_total": 6, - "ac_completed": 6 + "ac_completed": 6, + "depends_on": [ + "01-ast-manifest-source" + ], + "blocks": [] } } }, @@ -39,43 +49,119 @@ "status": "done", "title": "defineFeature helper + Instrumented/Captured/Audited brands", "ac_total": 9, - "ac_completed": 9 + "ac_completed": 9, + "depends_on": [], + "blocks": [ + "02-boot-assertions" + ] }, "02-boot-assertions": { "status": "done", "title": "assertFeatureConformance + boot wiring", "ac_total": 11, - "ac_completed": 11 + "ac_completed": 11, + "depends_on": [ + "01-define-feature-helper" + ], + "blocks": [ + "03-eslint-rules" + ] }, "03-a-structural-eslint-rules": { "status": "done", "title": "Structural ESLint rules (feature-must-have-manifest, usecase-must-have-test-file, required-cores-installed)", "ac_total": 10, - "ac_completed": 10 + "ac_completed": 10, + "depends_on": [ + "02-boot-assertions" + ], + "blocks": [ + "03-b-ast-aware-eslint-rules" + ] }, "03-b-ast-eslint-rules": { "status": "done", "title": "AST-aware ESLint rules (no-undeclared-event-publish, no-undeclared-audit)", "ac_total": 8, - "ac_completed": 8 + "ac_completed": 8, + "depends_on": [ + "03-a-structural-eslint-rules" + ], + "blocks": [ + "04-ci-drift-gate" + ] }, "04-ci-drift-gate": { "status": "done", "title": "CI drift gate — pnpm conformance with cross-feature event closure", "ac_total": 6, - "ac_completed": 6 + "ac_completed": 6, + "depends_on": [ + "03-b-ast-eslint-rules" + ], + "blocks": [ + "05-generator-updates" + ] }, "05-generator-updates": { "status": "done", "title": "Generator updates — emit feature.manifest.ts + self-asserting bind-production", "ac_total": 7, - "ac_completed": 7 + "ac_completed": 7, + "depends_on": [ + "04-ci-drift-gate" + ], + "blocks": [ + "06-feature-migrations" + ] }, "06-feature-migrations": { "status": "done", "title": "Migrate blog/media/navigation/marketing-pages to conformance pattern", "ac_total": 7, - "ac_completed": 7 + "ac_completed": 7, + "depends_on": [ + "05-generator-updates" + ], + "blocks": [] + } + } + }, + "dag-and-readiness-v1": { + "status": "done", + "title": "DAG + readiness — depends-on / blocks parsing", + "stories": { + "01-extended-state": { + "status": "done", + "title": "Frontmatter array parsing + extended state shape", + "ac_total": 4, + "ac_completed": 4, + "depends_on": [], + "blocks": [ + "02-dag-computation" + ] + }, + "02-dag-computation": { + "status": "done", + "title": "DAG computation — ready + blocked story sets", + "ac_total": 3, + "ac_completed": 3, + "depends_on": [ + "01-extended-state" + ], + "blocks": [ + "03-cli-subcommands" + ] + }, + "03-cli-subcommands": { + "status": "done", + "title": "CLI ready / blocked subcommands", + "ac_total": 5, + "ac_completed": 5, + "depends_on": [ + "02-dag-computation" + ], + "blocks": [] } } }, @@ -87,7 +173,9 @@ "status": "done", "title": "Three structural frontend conformance ESLint rules", "ac_total": 6, - "ac_completed": 6 + "ac_completed": 6, + "depends_on": [], + "blocks": [] } } }, @@ -99,19 +187,33 @@ "status": "done", "title": "Install husky + lint-staged", "ac_total": 3, - "ac_completed": 3 + "ac_completed": 3, + "depends_on": [], + "blocks": [ + "02-pre-commit-hook" + ] }, "02-pre-commit-hook": { "status": "done", "title": "Pre-commit hook script", "ac_total": 2, - "ac_completed": 2 + "ac_completed": 2, + "depends_on": [ + "01-husky-install" + ], + "blocks": [ + "03-state-sync-guard" + ] }, "03-state-sync-guard": { "status": "done", "title": "State-sync guard + tests", "ac_total": 4, - "ac_completed": 4 + "ac_completed": 4, + "depends_on": [ + "02-pre-commit-hook" + ], + "blocks": [] } } }, @@ -123,9 +225,13 @@ "status": "done", "title": "State builder + pnpm work CLI", "ac_total": 5, - "ac_completed": 5 + "ac_completed": 5, + "depends_on": [], + "blocks": [] } } } - } + }, + "ready": [], + "blocked": [] } diff --git a/docs/work/dag-and-readiness-v1/01-extended-state/_story.md b/docs/work/dag-and-readiness-v1/01-extended-state/_story.md new file mode 100644 index 0000000..6fcae89 --- /dev/null +++ b/docs/work/dag-and-readiness-v1/01-extended-state/_story.md @@ -0,0 +1,17 @@ +--- +id: 01-extended-state +epic: dag-and-readiness-v1 +title: Frontmatter array parsing + extended state shape +type: technical-story +status: done +feature: scripts +depends-on: [] +blocks: [02-dag-computation] +--- + +## Tasks + +- [x] Story scaffold +- [x] Extend parseFrontmatter to parse array values like `depends-on: [a, b]` +- [x] Extend buildState to include depends_on + blocks per story entry +- [x] Update existing tests; add new array-parsing tests diff --git a/docs/work/dag-and-readiness-v1/02-dag-computation/_story.md b/docs/work/dag-and-readiness-v1/02-dag-computation/_story.md new file mode 100644 index 0000000..1a100d0 --- /dev/null +++ b/docs/work/dag-and-readiness-v1/02-dag-computation/_story.md @@ -0,0 +1,16 @@ +--- +id: 02-dag-computation +epic: dag-and-readiness-v1 +title: DAG computation — ready + blocked story sets +type: technical-story +status: done +feature: scripts +depends-on: [01-extended-state] +blocks: [03-cli-subcommands] +--- + +## Tasks + +- [x] computeReadyBlocked(state) — pure function +- [x] Tests +- [x] Wire into buildState output diff --git a/docs/work/dag-and-readiness-v1/03-cli-subcommands/_story.md b/docs/work/dag-and-readiness-v1/03-cli-subcommands/_story.md new file mode 100644 index 0000000..9d69491 --- /dev/null +++ b/docs/work/dag-and-readiness-v1/03-cli-subcommands/_story.md @@ -0,0 +1,18 @@ +--- +id: 03-cli-subcommands +epic: dag-and-readiness-v1 +title: CLI ready / blocked subcommands +type: technical-story +status: done +feature: scripts +depends-on: [02-dag-computation] +blocks: [] +--- + +## Tasks + +- [x] `pnpm work ready` — prints ready stories +- [x] `pnpm work blocked` — prints blocked stories with waiting_on +- [x] Update `pnpm work next` to use the ready set +- [x] CLI smoke tests +- [x] Final verification + closeout diff --git a/docs/work/dag-and-readiness-v1/_epic.md b/docs/work/dag-and-readiness-v1/_epic.md new file mode 100644 index 0000000..70902e5 --- /dev/null +++ b/docs/work/dag-and-readiness-v1/_epic.md @@ -0,0 +1,21 @@ +--- +id: dag-and-readiness-v1 +prd: null +title: DAG + readiness — depends-on / blocks parsing +type: epic +status: done +features: [scripts] +created: 2026-05-13 +--- + +## Goal + +Extend the state builder + CLI so `pnpm work next` / `pnpm work ready` / +`pnpm work blocked` answer "what's actually unblocked right now?" +from the depends-on graph instead of just the first non-done story. + +## Stories + +- [x] [01 — Frontmatter array parsing + extended state shape](01-extended-state/_story.md) +- [x] [02 — DAG computation: ready + blocked](02-dag-computation/_story.md) +- [x] [03 — CLI ready / blocked subcommands](03-cli-subcommands/_story.md) diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..bfe4305 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,33 @@ +/** + * Root ESLint config — covers scripts/ and any other plain-JS files at the + * repo root. Package-level configs (packages/*, apps/*) define their own + * eslint.config.js and take precedence for files inside those directories. + */ +import js from "@eslint/js"; + +// Minimal Node.js globals for scripts — avoids a peer-dep on `globals`. +const nodeGlobals = { + process: "readonly", + console: "readonly", + URL: "readonly", + Buffer: "readonly", + __dirname: "readonly", + __filename: "readonly", + setTimeout: "readonly", + clearTimeout: "readonly", + setInterval: "readonly", + clearInterval: "readonly", +}; + +export default [ + { ignores: ["node_modules/**", "packages/**", "apps/**", "turbo/**"] }, + js.configs.recommended, + { + files: ["scripts/**/*.{mjs,cjs,js}"], + languageOptions: { + ecmaVersion: 2022, + sourceType: "module", + globals: nodeGlobals, + }, + }, +]; diff --git a/scripts/work/cli.mjs b/scripts/work/cli.mjs index 6440349..504088a 100644 --- a/scripts/work/cli.mjs +++ b/scripts/work/cli.mjs @@ -20,7 +20,9 @@ const STATE_FILE = path.join(WORK_ROOT, "_state.json"); function rebuildState() { const state = buildState(WORK_ROOT); fs.writeFileSync(STATE_FILE, JSON.stringify(state, null, 2) + "\n"); - console.log(`Rebuilt ${path.relative(REPO_ROOT, STATE_FILE)} with ${Object.keys(state.epics).length} epic(s).`); + console.log( + `Rebuilt ${path.relative(REPO_ROOT, STATE_FILE)} with ${Object.keys(state.epics).length} epic(s).`, + ); } function printStatus() { @@ -41,31 +43,67 @@ function printStatus() { { total: 0, done: 0 }, ); const epicMark = mark(epic.status); - console.log(`${epicMark} ${epicId} — ${epic.title} (${storyTotals.done}/${storyTotals.total} tasks done)`); + console.log( + `${epicMark} ${epicId} — ${epic.title} (${storyTotals.done}/${storyTotals.total} tasks done)`, + ); for (const sid of storyIds) { const s = epic.stories[sid]; - console.log(` ${mark(s.status)} ${sid} (${s.ac_completed}/${s.ac_total}) — ${s.title}`); + console.log( + ` ${mark(s.status)} ${sid} (${s.ac_completed}/${s.ac_total}) — ${s.title}`, + ); } } } function printNext() { const state = buildState(WORK_ROOT); - const epicIds = Object.keys(state.epics).sort(); - for (const epicId of epicIds) { - const epic = state.epics[epicId]; - if (epic.status === "done") continue; - const storyIds = Object.keys(epic.stories).sort(); - for (const sid of storyIds) { - const s = epic.stories[sid]; - if (s.status !== "done") { - console.log(`${epicId} / ${sid} — ${s.title}`); - console.log(` status: ${s.status}, tasks: ${s.ac_completed}/${s.ac_total}`); - return; + if (state.ready.length === 0) { + if (state.blocked.length > 0) { + console.log("No ready stories. Blocked:"); + for (const b of state.blocked) { + console.log( + ` ${b.epic} / ${b.story} — waiting on: ${b.waiting_on.join(", ")}`, + ); } + } else { + console.log("All epics + stories are done. ✓"); } + return; + } + const r = state.ready[0]; + console.log(`${r.epic} / ${r.story} — ${r.title}`); + console.log(` (use \`pnpm work ready\` to see all ready stories)`); +} + +function printReady() { + const state = buildState(WORK_ROOT); + if (state.ready.length === 0) { + console.log( + "No ready stories. Run `pnpm work blocked` to see what's waiting on what.", + ); + return; + } + console.log( + `${state.ready.length} ready stor${state.ready.length === 1 ? "y" : "ies"}:`, + ); + for (const r of state.ready) { + console.log(` ${r.epic} / ${r.story} — ${r.title}`); + } +} + +function printBlocked() { + const state = buildState(WORK_ROOT); + if (state.blocked.length === 0) { + console.log("No blocked stories."); + return; + } + console.log( + `${state.blocked.length} blocked stor${state.blocked.length === 1 ? "y" : "ies"}:`, + ); + for (const b of state.blocked) { + console.log(` ${b.epic} / ${b.story} — ${b.title}`); + console.log(` waiting on: ${b.waiting_on.join(", ")}`); } - console.log("All epics + stories are done. ✓"); } function mark(status) { @@ -76,7 +114,7 @@ function mark(status) { } function usage() { - console.log("Usage: pnpm work "); + console.log("Usage: pnpm work "); process.exit(2); } @@ -84,4 +122,6 @@ const cmd = process.argv[2]; if (cmd === "rebuild-state") rebuildState(); else if (cmd === "status") printStatus(); else if (cmd === "next") printNext(); +else if (cmd === "ready") printReady(); +else if (cmd === "blocked") printBlocked(); else usage(); diff --git a/scripts/work/cli.test.mjs b/scripts/work/cli.test.mjs index 901289f..e41a037 100644 --- a/scripts/work/cli.test.mjs +++ b/scripts/work/cli.test.mjs @@ -21,6 +21,8 @@ describe("pnpm work cli", () => { expect(out).toContain("rebuild-state"); expect(out).toContain("status"); expect(out).toContain("next"); + expect(out).toContain("ready"); + expect(out).toContain("blocked"); }); it("rebuild-state writes _state.json", () => { @@ -39,4 +41,14 @@ describe("pnpm work cli", () => { const out = run("next"); expect(out.length).toBeGreaterThan(0); }); + + it("ready prints something", () => { + const out = run("ready"); + expect(out.length).toBeGreaterThan(0); + }); + + it("blocked prints something", () => { + const out = run("blocked"); + expect(out.length).toBeGreaterThan(0); + }); }); diff --git a/scripts/work/state-builder.mjs b/scripts/work/state-builder.mjs index 5a7f42b..5c5158f 100644 --- a/scripts/work/state-builder.mjs +++ b/scripts/work/state-builder.mjs @@ -63,19 +63,84 @@ export function buildState(workRoot) { title: storyMeta.title ?? sub, ac_total: total, ac_completed: completed, + depends_on: Array.isArray(storyMeta["depends-on"]) + ? storyMeta["depends-on"] + : [], + blocks: Array.isArray(storyMeta.blocks) ? storyMeta.blocks : [], }; } state.epics[epicMeta.id ?? entry] = epicEntry; } + const { ready, blocked } = computeReadyBlocked(state); + state.ready = ready; + state.blocked = blocked; + return state; } +/** + * Given a built state object, compute the ready + blocked story sets. + * + * "ready" = not done AND all depends_on stories are done + * "blocked" = not done AND at least one depends_on story is not done + * + * depends_on references are either same-epic (just story id) or cross-epic + * (`/`). + * + * Returns { ready: [...], blocked: [...] } where each entry is: + * { epic, story, title, [waiting_on?] } + */ +export function computeReadyBlocked(state) { + // Build a flat map: "/" => { status, title, depends_on } + const flat = new Map(); + for (const [epic, epicEntry] of Object.entries(state.epics)) { + for (const [story, storyEntry] of Object.entries(epicEntry.stories)) { + flat.set(`${epic}/${story}`, { + epic, + story, + status: storyEntry.status, + title: storyEntry.title, + depends_on: storyEntry.depends_on, + }); + } + } + + // Resolve a depends_on reference (which may be `/` or just ``) + // relative to the current epic. + function resolveRef(ref, currentEpic) { + if (ref.includes("/")) return ref; + return `${currentEpic}/${ref}`; + } + + const ready = []; + const blocked = []; + for (const entry of flat.values()) { + if (entry.status === "done") continue; + const refs = entry.depends_on.map((r) => resolveRef(r, entry.epic)); + const waitingOn = refs.filter((r) => { + const dep = flat.get(r); + return !dep || dep.status !== "done"; + }); + if (waitingOn.length === 0) { + ready.push({ epic: entry.epic, story: entry.story, title: entry.title }); + } else { + blocked.push({ + epic: entry.epic, + story: entry.story, + title: entry.title, + waiting_on: waitingOn, + }); + } + } + return { ready, blocked }; +} + /** * Read a markdown file's YAML frontmatter (between leading `---` delimiters) - * and return a flat object of string-valued keys. Numeric / bool values are - * left as strings; this is enough for our needs (status, id, title fields). + * and return a flat object of string or array values. Numeric / bool values are + * left as strings; array values like `[a, b, "c"]` are parsed into JS arrays. */ export function parseFrontmatter(filePath) { const src = fs.readFileSync(filePath, "utf8"); @@ -86,8 +151,26 @@ export function parseFrontmatter(filePath) { const m = line.match(/^([\w-]+):\s*(.*)$/); if (!m) continue; let value = m[2].trim(); - if (value.startsWith('"') && value.endsWith('"')) value = value.slice(1, -1); - if (value.startsWith("'") && value.endsWith("'")) value = value.slice(1, -1); + // Array values like [a, b, "c"] or [] + if (value.startsWith("[") && value.endsWith("]")) { + const inner = value.slice(1, -1).trim(); + if (inner === "") { + out[m[1]] = []; + } else { + out[m[1]] = inner.split(",").map((s) => { + let v = s.trim(); + if (v.startsWith('"') && v.endsWith('"')) v = v.slice(1, -1); + if (v.startsWith("'") && v.endsWith("'")) v = v.slice(1, -1); + return v; + }); + } + continue; + } + // Strings (with optional quotes) + if (value.startsWith('"') && value.endsWith('"')) + value = value.slice(1, -1); + if (value.startsWith("'") && value.endsWith("'")) + value = value.slice(1, -1); out[m[1]] = value; } return out; diff --git a/scripts/work/state-builder.test.mjs b/scripts/work/state-builder.test.mjs index 960a4fd..e3dad9d 100644 --- a/scripts/work/state-builder.test.mjs +++ b/scripts/work/state-builder.test.mjs @@ -2,7 +2,12 @@ import { describe, it, expect } from "vitest"; import path from "node:path"; import os from "node:os"; import fs from "node:fs"; -import { buildState, parseFrontmatter, countTaskCheckboxes } from "./state-builder.mjs"; +import { + buildState, + parseFrontmatter, + countTaskCheckboxes, + computeReadyBlocked, +} from "./state-builder.mjs"; function makeWorkTree({ epics }) { const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-state-")); @@ -81,8 +86,22 @@ describe("buildState", () => { status: "in-progress", title: "epic1", stories: { - story1: { status: "done", title: "story1", ac_total: 2, ac_completed: 2 }, - story2: { status: "todo", title: "story2", ac_total: 3, ac_completed: 0 }, + story1: { + status: "done", + title: "story1", + ac_total: 2, + ac_completed: 2, + depends_on: [], + blocks: [], + }, + story2: { + status: "todo", + title: "story2", + ac_total: 3, + ac_completed: 0, + depends_on: [], + blocks: [], + }, }, }, }); @@ -91,7 +110,10 @@ describe("buildState", () => { it("skips _templates and prds folders", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-skip-")); fs.mkdirSync(path.join(root, "_templates"), { recursive: true }); - fs.writeFileSync(path.join(root, "_templates", "_epic.md"), "---\nid: x\n---"); + fs.writeFileSync( + path.join(root, "_templates", "_epic.md"), + "---\nid: x\n---", + ); fs.mkdirSync(path.join(root, "prds"), { recursive: true }); fs.writeFileSync(path.join(root, "prds", "_epic.md"), "---\nid: y\n---"); expect(buildState(root).epics).toEqual({}); @@ -102,6 +124,56 @@ describe("buildState", () => { fs.mkdirSync(path.join(root, "incomplete"), { recursive: true }); expect(buildState(root).epics).toEqual({}); }); + + it("parses depends-on and blocks frontmatter arrays", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "work-dep-")); + const epicDir = path.join(root, "epic1"); + fs.mkdirSync(epicDir, { recursive: true }); + fs.writeFileSync( + path.join(epicDir, "_epic.md"), + `--- +id: epic1 +title: epic1 +status: todo +--- +`, + ); + const story1Dir = path.join(epicDir, "story1"); + fs.mkdirSync(story1Dir, { recursive: true }); + fs.writeFileSync( + path.join(story1Dir, "_story.md"), + `--- +id: story1 +title: story1 +status: todo +depends-on: [] +blocks: [story2, other-epic/x] +--- +`, + ); + const story2Dir = path.join(epicDir, "story2"); + fs.mkdirSync(story2Dir, { recursive: true }); + fs.writeFileSync( + path.join(story2Dir, "_story.md"), + `--- +id: story2 +title: story2 +status: todo +depends-on: [story1] +blocks: [] +--- +`, + ); + + const state = buildState(root); + expect(state.epics.epic1.stories.story1.depends_on).toEqual([]); + expect(state.epics.epic1.stories.story1.blocks).toEqual([ + "story2", + "other-epic/x", + ]); + expect(state.epics.epic1.stories.story2.depends_on).toEqual(["story1"]); + expect(state.epics.epic1.stories.story2.blocks).toEqual([]); + }); }); describe("countTaskCheckboxes", () => { @@ -125,7 +197,10 @@ describe("countTaskCheckboxes", () => { }); it("returns zeros when no Tasks section exists", () => { - expect(countTaskCheckboxes(`## Goal\n\nFoo\n`)).toEqual({ total: 0, completed: 0 }); + expect(countTaskCheckboxes(`## Goal\n\nFoo\n`)).toEqual({ + total: 0, + completed: 0, + }); }); }); @@ -133,14 +208,21 @@ describe("parseFrontmatter", () => { it("extracts simple string keys", () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fm-")); const fp = path.join(dir, "f.md"); - fs.writeFileSync(fp, `--- + fs.writeFileSync( + fp, + `--- id: x title: A title status: done --- -Body`); - expect(parseFrontmatter(fp)).toEqual({ id: "x", title: "A title", status: "done" }); +Body`, + ); + expect(parseFrontmatter(fp)).toEqual({ + id: "x", + title: "A title", + status: "done", + }); }); it("returns {} when no frontmatter present", () => { @@ -149,4 +231,157 @@ Body`); fs.writeFileSync(fp, "# Just a heading\n"); expect(parseFrontmatter(fp)).toEqual({}); }); + + it("parses array frontmatter values", () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "fm-arr-")); + const fp = path.join(dir, "f.md"); + fs.writeFileSync( + fp, + `--- +id: x +items: [a, b, "c"] +empty: [] +--- +`, + ); + expect(parseFrontmatter(fp)).toEqual({ + id: "x", + items: ["a", "b", "c"], + empty: [], + }); + }); +}); + +describe("computeReadyBlocked", () => { + it("returns ready for stories whose depends_on are all done", () => { + const state = { + updated_at: "x", + epics: { + e: { + status: "in-progress", + title: "e", + stories: { + a: { + status: "done", + title: "a", + ac_total: 0, + ac_completed: 0, + depends_on: [], + blocks: [], + }, + b: { + status: "todo", + title: "b", + ac_total: 0, + ac_completed: 0, + depends_on: ["a"], + blocks: [], + }, + }, + }, + }, + }; + const { ready, blocked } = computeReadyBlocked(state); + expect(ready).toEqual([{ epic: "e", story: "b", title: "b" }]); + expect(blocked).toEqual([]); + }); + + it("returns blocked for stories whose depends_on are not done", () => { + const state = { + updated_at: "x", + epics: { + e: { + status: "in-progress", + title: "e", + stories: { + a: { + status: "todo", + title: "a", + ac_total: 0, + ac_completed: 0, + depends_on: [], + blocks: [], + }, + b: { + status: "todo", + title: "b", + ac_total: 0, + ac_completed: 0, + depends_on: ["a"], + blocks: [], + }, + }, + }, + }, + }; + const { ready, blocked } = computeReadyBlocked(state); + expect(ready).toEqual([{ epic: "e", story: "a", title: "a" }]); + expect(blocked).toEqual([ + { epic: "e", story: "b", title: "b", waiting_on: ["e/a"] }, + ]); + }); + + it("resolves cross-epic refs", () => { + const state = { + updated_at: "x", + epics: { + e1: { + status: "done", + title: "e1", + stories: { + a: { + status: "done", + title: "a", + ac_total: 0, + ac_completed: 0, + depends_on: [], + blocks: [], + }, + }, + }, + e2: { + status: "in-progress", + title: "e2", + stories: { + b: { + status: "todo", + title: "b", + ac_total: 0, + ac_completed: 0, + depends_on: ["e1/a"], + blocks: [], + }, + }, + }, + }, + }; + const { ready, blocked } = computeReadyBlocked(state); + expect(ready).toEqual([{ epic: "e2", story: "b", title: "b" }]); + expect(blocked).toEqual([]); + }); + + it("skips done stories", () => { + const state = { + updated_at: "x", + epics: { + e: { + status: "done", + title: "e", + stories: { + a: { + status: "done", + title: "a", + ac_total: 1, + ac_completed: 1, + depends_on: [], + blocks: [], + }, + }, + }, + }, + }; + const { ready, blocked } = computeReadyBlocked(state); + expect(ready).toEqual([]); + expect(blocked).toEqual([]); + }); });