import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; import { Text } from "@earendil-works/pi-tui"; import { StringEnum } from "@earendil-works/pi-ai"; import { Type } from "typebox"; import type { UpdateTicketParams, TicketStatus, TicketPriority } from "../types.js"; import { readTicket, writeTicket, isInitialized, syncBacklog, contentText } from "../utils.js"; const VALID_STATUSES: TicketStatus[] = [ "backlog", "ready", "in-progress", "review", "done", "blocked", ]; const VALID_PRIORITIES: TicketPriority[] = ["P0", "P1", "P2", "P3"]; export function registerDocgraphTicketUpdate(pi: ExtensionAPI): void { pi.registerTool({ name: "docgraph_ticket_update", label: "Docgraph Ticket Update", description: "Update the status, priority, title, context, or add an implementation note to an existing ticket. Use ticket IDs without the 'T-' prefix (e.g. '001').", parameters: Type.Object({ id: Type.String({ description: "Ticket ID (e.g. '001', not 'T-001')", }), status: Type.Optional( StringEnum(["backlog", "ready", "in-progress", "review", "done", "blocked"] as const), ), priority: Type.Optional( StringEnum(["P0", "P1", "P2", "P3"] as const), ), title: Type.Optional( Type.String({ description: "New title for the ticket" }), ), context: Type.Optional( Type.String({ description: "Replaces the entire Context section (original requirements). Prefer implementationNote when completing a ticket so existing context is preserved.", }), ), implementationNote: Type.Optional( Type.String({ description: "Concise note appended to the Implementation Notes section describing what was implemented, how, decisions, assumptions, and trade-offs. Never rewrites existing content. Required when moving a ticket to 'done'.", }), ), }), async execute(_toolCallId, params, _signal, _onUpdate, ctx) { const p = params as UpdateTicketParams; if (!isInitialized(ctx)) { return { content: [ { type: "text", text: "Documentation not initialized. Run `docgraph_init` first.", }, ], details: { action: "ticket_update", error: "not_initialized", state: { initialized: false, schemaVersion: 1 }, }, }; } const ticket = readTicket(p.id, ctx.cwd); if (!ticket) { return { content: [ { type: "text", text: `Ticket T-${p.id} not found.`, }, ], details: { action: "ticket_update", error: "not_found", id: p.id, state: { initialized: true, schemaVersion: 1 }, }, }; } const changes: string[] = []; // Preserve existing content: only mutate fields that were explicitly provided, // and never touch fields (context, criteria, notes, references) not in the call. if ( p.status && p.status === "done" && (!p.implementationNote || p.implementationNote.trim() === "") ) { return { content: [ { type: "text", text: `Cannot mark T-${p.id} as done without an implementationNote. Provide a concise note describing what was implemented and verified, then retry.`, }, ], details: { action: "ticket_update", error: "done_requires_implementation_note", id: p.id, state: { initialized: true, schemaVersion: 1 }, }, }; } if (p.status && VALID_STATUSES.includes(p.status as TicketStatus)) { const old = ticket.status; ticket.status = p.status as TicketStatus; changes.push(`status: ${old} → ${ticket.status}`); } if (p.priority && VALID_PRIORITIES.includes(p.priority as TicketPriority)) { const old = ticket.priority; ticket.priority = p.priority as TicketPriority; changes.push(`priority: ${old} → ${ticket.priority}`); } if (p.title) { ticket.title = p.title; changes.push(`title updated`); } if (p.context !== undefined) { ticket.context = p.context; changes.push(`context updated`); } if (p.implementationNote && p.implementationNote.trim() !== "") { const note = p.implementationNote.trim(); ticket.implementationNotes = [ ...(ticket.implementationNotes ?? []), note, ]; changes.push(`implementation note added`); } ticket.updatedAt = new Date().toISOString(); if (changes.length === 0) { return { content: [ { type: "text", text: `No changes for T-${ticket.id}. Provide at least one field to update.`, }, ], details: { action: "ticket_update", id: ticket.id, state: { initialized: true, schemaVersion: 1 }, }, }; } const ok = writeTicket(ticket, ctx.cwd); // Keep docs/BACKLOG.md in sync with the ticket files (status moves, // renames, notes, etc.). const backlogSynced = ok ? syncBacklog(ctx.cwd) : false; return { content: [ { type: "text", text: (ok ? `Updated T-${ticket.id}: ${changes.join(", ")}` : `Failed to update T-${ticket.id}`) + (ok && !backlogSynced ? " (warning: docs/BACKLOG.md not updated)" : ""), }, ], details: { action: "ticket_update", ticket, changes, success: ok, backlogSynced, state: { initialized: true, schemaVersion: 1 }, }, }; }, renderCall(args, theme) { return new Text( theme.fg("toolTitle", theme.bold("docgraph-ticket-update ")) + theme.fg("accent", `T-${args.id}`) + (args.status ? theme.fg("muted", ` → ${args.status}`) : ""), 0, 0, ); }, renderResult(result, _opts, theme) { const text = contentText(result.content?.[0]); const isError = text.startsWith("Failed") || text.startsWith("Ticket"); return new Text( isError ? theme.fg("error", text) : theme.fg("success", text), 0, 0, ); }, }); }