/** * docgraph_ticket_update tool: registration, guards (initialization / missing * ticket), the done-requires-implementationNote rule, status/priority/title/ * context updates, append-only implementation notes, file renames on title * change, and backlog re-synchronization. */ import { test } from "node:test"; import assert from "node:assert/strict"; import { existsSync } from "node:fs"; import { join } from "node:path"; import { Text } from "@earendil-works/pi-tui"; import { registerDocgraphTicketUpdate } from "../src/tools/ticket-update.ts"; import { writeTicket, readTicket, readFileSafe } from "../src/utils.ts"; import { captureTool, makeTempRepo, cleanupTemp, readRepoFile, writeFiles, makeCtx, runTool, toolText, componentText, renderCall, renderResult, sampleTicket, } from "./helpers.ts"; function tool() { return captureTool(registerDocgraphTicketUpdate); } function seedRepo(cwd: string, ticket = sampleTicket()) { assert.equal(writeTicket(ticket, cwd), true); } test("docgraph_ticket_update registers a typed schema with status and priority enums", () => { const t = tool(); assert.equal(t.name, "docgraph_ticket_update"); const properties = (t.parameters as unknown as { properties: Record }).properties; assert.deepEqual(properties.status!.enum, [ "backlog", "ready", "in-progress", "review", "done", "blocked", ]); assert.deepEqual(properties.priority!.enum, ["P0", "P1", "P2", "P3"]); assert.ok(properties.implementationNote, "implementationNote expected"); }); test("docgraph_ticket_update refuses to run before initialization", async () => { const cwd = makeTempRepo(); try { const { ctx } = makeCtx({ cwd }); const res = await runTool(tool(), { id: "001", status: "done" }, ctx); assert.match(toolText(res), /not initialized/i); assert.equal((res.details as { error: string }).error, "not_initialized"); } finally { cleanupTemp(cwd); } }); test("docgraph_ticket_update reports unknown tickets", async () => { const cwd = makeTempRepo(); try { const { ctx } = makeCtx({ cwd, state: { initialized: true, schemaVersion: 1 } }); const res = await runTool(tool(), { id: "999", status: "ready" }, ctx); assert.match(toolText(res), /Ticket T-999 not found\./); assert.equal((res.details as { error: string }).error, "not_found"); } finally { cleanupTemp(cwd); } }); test("docgraph_ticket_update refuses to mark done without an implementation note", async () => { const cwd = makeTempRepo(); try { seedRepo(cwd); const { ctx } = makeCtx({ cwd, state: { initialized: true, schemaVersion: 1 } }); const res = await runTool(tool(), { id: "001", status: "done" }, ctx); assert.match(toolText(res), /Cannot mark T-001 as done without an implementationNote\./); assert.equal( (res.details as { error: string }).error, "done_requires_implementation_note", ); // Ticket state untouched. assert.equal(readTicket("001", cwd)!.status, "backlog"); } finally { cleanupTemp(cwd) } }); test("docgraph_ticket_update moves status and reports changes", async () => { const cwd = makeTempRepo(); try { seedRepo(cwd); const { ctx } = makeCtx({ cwd, state: { initialized: true, schemaVersion: 1 } }); const res = await runTool(tool(), { id: "001", status: "in-progress" }, ctx); assert.match(toolText(res), /Updated T-001: status: backlog → in-progress/); const details = res.details as { changes: string[]; success: boolean; backlogSynced: boolean }; assert.equal(details.success, true); assert.equal(details.backlogSynced, true); assert.deepEqual(details.changes, ["status: backlog → in-progress"]); // File persisted and backlog columns re-sorted. assert.equal(readTicket("001", cwd)!.status, "in-progress"); const backlog = readRepoFile(cwd, "docs/BACKLOG.md")!; assert.match(backlog, /## In Progress/); assert.doesNotMatch( backlog.split("## Backlog")[1]!.split("## Ready")[0]!, /T-001-add-dark-mode\.md/, ); } finally { cleanupTemp(cwd); } }); test("docgraph_ticket_update completes a ticket with an implementation note", async () => { const cwd = makeTempRepo(); try { seedRepo(cwd, sampleTicket({ implementationNotes: ["Kicked off earlier"] })); const { ctx } = makeCtx({ cwd, state: { initialized: true, schemaVersion: 1 } }); const res = await runTool( tool(), { id: "001", status: "done", implementationNote: "Shipped auth flow; all tests green." }, ctx, ); assert.match(toolText(res), /Updated T-001:/); const ticket = readTicket("001", cwd)!; assert.equal(ticket.status, "done"); assert.deepEqual(ticket.implementationNotes, [ "Kicked off earlier", "Shipped auth flow; all tests green.", ]); // The note is preserved verbatim when re-reading and re-writing. assert.match(readRepoFile(cwd, "docs/tickets/T-001-add-dark-mode.md")!, /Shipped auth flow; all tests green\./); } finally { cleanupTemp(cwd); } }); test("docgraph_ticket_update appends implementation notes without rewriting prior notes", async () => { const cwd = makeTempRepo(); try { seedRepo(cwd, sampleTicket({ implementationNotes: ["Initial setup"] })); const { ctx } = makeCtx({ cwd, state: { initialized: true, schemaVersion: 1 } }); const t = tool(); await runTool(t, { id: "001", implementationNote: "First pass" }, ctx); await runTool(t, { id: "001", implementationNote: "Second pass" }, ctx); const ticket = readTicket("001", cwd)!; assert.deepEqual(ticket.implementationNotes, ["Initial setup", "First pass", "Second pass"]); } finally { cleanupTemp(cwd); } }); test("docgraph_ticket_update updates priority, title and context together", async () => { const cwd = makeTempRepo(); try { seedRepo(cwd); const { ctx } = makeCtx({ cwd, state: { initialized: true, schemaVersion: 1 } }); const res = await runTool( tool(), { id: "001", priority: "P0", title: "Dark mode v2", context: "New context" }, ctx, ); const details = res.details as { changes: string[] }; assert.deepEqual(details.changes, ["priority: P2 → P0", "title updated", "context updated"]); const ticket = readTicket("001", cwd)!; assert.equal(ticket.priority, "P0"); assert.equal(ticket.title, "Dark mode v2"); assert.equal(ticket.context, "New context"); // Renamed on disk; old file removed. assert.ok(existsSync(join(cwd, "docs/tickets/T-001-dark-mode-v2.md"))); assert.ok(!existsSync(join(cwd, "docs/tickets/T-001-add-dark-mode.md"))); } finally { cleanupTemp(cwd); } }); test("docgraph_ticket_update is tolerant of the T- prefix in ids", async () => { const cwd = makeTempRepo(); try { seedRepo(cwd); const { ctx } = makeCtx({ cwd, state: { initialized: true, schemaVersion: 1 } }); const res = await runTool(tool(), { id: "T-001", status: "review" }, ctx); assert.match(toolText(res), /Updated T-001: status: backlog → review/); } finally { cleanupTemp(cwd); } }); test("docgraph_ticket_update ignores unsupported status values silently", async () => { const cwd = makeTempRepo(); try { seedRepo(cwd); const { ctx } = makeCtx({ cwd, state: { initialized: true, schemaVersion: 1 } }); const res = await runTool(tool(), { id: "001", status: "banana" }, ctx); assert.match(toolText(res), /No changes for T-001\. Provide at least one field to update\./); assert.equal(readTicket("001", cwd)!.status, "backlog"); } finally { cleanupTemp(cwd); } }); test("docgraph_ticket_update responds to no-op calls without mutating the file", async () => { const cwd = makeTempRepo(); try { seedRepo(cwd); const before = readFileSafe("docs/tickets/T-001-add-dark-mode.md", cwd); const { ctx } = makeCtx({ cwd, state: { initialized: true, schemaVersion: 1 } }); const res = await runTool(tool(), { id: "001" }, ctx); assert.match(toolText(res), /No changes for T-001\./); assert.equal(readFileSafe("docs/tickets/T-001-add-dark-mode.md", cwd), before); } finally { cleanupTemp(cwd); } }); test("docgraph_ticket_update bumps updatedAt while preserving createdAt", async () => { const cwd = makeTempRepo(); try { seedRepo(cwd); const original = readTicket("001", cwd)!; const { ctx } = makeCtx({ cwd, state: { initialized: true, schemaVersion: 1 } }); await runTool(tool(), { id: "001", priority: "P1" }, ctx); const after = readTicket("001", cwd)!; assert.equal(after.createdAt, original.createdAt); assert.notEqual(after.updatedAt, original.updatedAt); } finally { cleanupTemp(cwd); } }); test("docgraph_ticket_update preserves checked criteria, inline comments, and related-doc hrefs", async () => { const cwd = makeTempRepo(); try { // A ticket the AI has already implemented: checked boxes, inline // verification comments, and relative related-doc links. writeFiles(cwd, { "docs/tickets/T-001-foo.md": `# T-001 **ID:** 001 **Title:** Foo **Status:** in-progress **Priority:** P2 **Created:** 2026-01-01T00:00:00.000Z **Updated:** 2026-01-01T00:00:00.000Z ## Context ## Acceptance Criteria - [x] pnpm dev serves a page at localhost:3000 - [ ] pnpm typecheck passes with strict TypeScript ## Definition of Done - [x] Test written first - [ ] Test confirmed failing for the expected reason (Red) ## Implementation Notes None yet ## Related Documentation - [ARCHITECTURE](../ARCHITECTURE.md) ## Related Files None yet `, }); const { ctx } = makeCtx({ cwd, state: { initialized: true, schemaVersion: 1 } }); const res = await runTool(tool(), { id: "001", status: "review" }, ctx); assert.match(toolText(res), /Updated T-001:/); const file = readRepoFile(cwd, "docs/tickets/T-001-foo.md")!; // Checked state and inline comments survived the re-serialization. assert.match(file, /- \[x\] pnpm dev serves a page at localhost:3000