/** * Project ticket store — list / get / create / update / transition the * markdown tickets of one project (specs/2026-09-28-project-tickets.md). * * Storage is the project folder itself (`/.crewly/tickets/`), which * is tracked in git and edited by humans too. So: * - every read goes through a cheap mtime/size-keyed cache (one `readdir` + * `stat` per list), which is how edits made by hand or by a `git pull` are * picked up — no long-lived watcher to leak or miss events; * - every write re-reads the file under the folder lock, applies only the * owned-field / Log changes ({@link applyTicketChanges}) and writes * atomically; * - files that are not valid tickets are skipped with a warning and reported * in `invalid[]`, never fatal. * * This layer knows nothing about teams, agents or WorkItems — permissions and * the WorkItem link live in `ProjectTicketWorkflowService`. * * @module services/project-tickets/project-ticket.service */ import { type ComponentLogger } from '../core/logger.service.js'; import { type ProjectTicket, type ProjectTicketCriterion, type ProjectTicketFields, type ProjectTicketList, type ProjectTicketStatus } from '../../types/project-ticket.types.js'; import { type TicketTrackingOutcome } from './ticket-tracking.js'; /** An error with the HTTP status the API should answer with. */ export declare class ProjectTicketError extends Error { readonly status: number; /** * @param status - HTTP status (400 bad input, 403 not allowed, 404 missing, 409 conflict) * @param message - Human-readable reason */ constructor(status: number, message: string); } /** Input for {@link ProjectTicketService.create}. */ export interface CreateProjectTicketInput { title: string; description?: string; /** Criteria as text lines or `{ text, done }` */ acceptance?: Array; priority?: string; labels?: string[]; team?: string | null; status?: ProjectTicketStatus; ownerReview?: boolean; requestId?: string | null; source?: string | null; assignee?: string | null; migratedFrom?: string | null; /** Creation time (the migration keeps the original file's) */ createdAt?: string; } /** Field changes accepted by {@link ProjectTicketService.update}. */ export interface UpdateProjectTicketInput { title?: string; priority?: string; labels?: string[]; team?: string | null; ownerReview?: boolean; requestId?: string | null; /** `YYYY-MM-DD` / ISO time to keep the ticket out of triage until; `null` or '' clears it */ deferUntil?: string | null; /** * Put the ticket on hold with this reason (the owner's words), or lift the * hold with `null` / '' (crewly#1369). Held work may only be closed. */ hold?: string | null; description?: string; acceptance?: Array; } /** * A mutation computed from the current ticket. Return null for "no change". * `fields` are merged with `updatedAt`; `log` lines are formatted with the * actor and time. */ export interface TicketMutation { fields?: Partial; description?: string; acceptance?: ProjectTicketCriterion[]; log?: string[]; /** * Leave `updatedAt` as it is. For bookkeeping that is not real activity on * the ticket (the hygiene sweep's flags), so it never resets a ticket's age. */ keepUpdatedAt?: boolean; } /** Dependencies (injectable for tests). */ export interface ProjectTicketServiceDeps { logger?: ComponentLogger; /** Clock */ now?: () => string; /** Git tracking check, run once per project on its first write */ ensureTracked?: (projectPath: string) => Promise; } /** * A ticket write whose status or labels changed (see * {@link ProjectTicketService.onChange}). */ export interface ProjectTicketChange { /** Absolute project root */ projectPath: string; /** The ticket after the write */ ticket: ProjectTicket; /** Its status and labels before */ before: { status: ProjectTicketStatus; labels: string[]; }; /** Who wrote it (Log actor) */ actor: string; } /** Listener of ticket changes. */ export type ProjectTicketChangeListener = (change: ProjectTicketChange) => void; export declare class ProjectTicketService { private static instance; /** Listeners told after a write changed a ticket's status or labels */ private readonly listeners; private readonly logger; private readonly now; private readonly ensureTracked; /** dir → fileName → parsed entry */ private readonly cache; /** Invalid files already warned about (`path@mtime`) */ private readonly warned; /** Projects whose git tracking was checked this process */ private readonly trackingChecked; /** * @param deps - Optional dependencies */ constructor(deps?: ProjectTicketServiceDeps); /** * Process-wide instance. * * @returns The singleton */ static getInstance(): ProjectTicketService; /** Reset the singleton (tests). */ static resetInstance(): void; /** * The tickets folder of a project. * * @param projectPath - Absolute project root * @returns `/.crewly/tickets` */ ticketsDir(projectPath: string): string; /** * Every ticket of a project (plus the files that are not valid tickets). * Sorted by status order, then priority, then id number. * * @param projectPath - Absolute project root * @returns Tickets and invalid files */ list(projectPath: string): Promise; /** * One ticket by id, with its raw body. * * @param projectPath - Absolute project root * @param id - Ticket id (case-insensitive) * @returns The ticket, or null */ get(projectPath: string, id: string): Promise; /** * Create a ticket. Allocates the id under the folder lock and checks git * tracking on the first write into the project. * * @param projectPath - Absolute project root * @param projectName - Project name (id prefix on first allocation) * @param input - Ticket content * @param actor - Who creates it (`owner`, a session name, `system`) * @returns The new ticket * @throws ProjectTicketError(400) on invalid input */ create(projectPath: string, projectName: string, input: CreateProjectTicketInput, actor: string): Promise; /** * Be told after every write that changed a ticket's status or labels (the * ticket autopilot traces them; specs/2026-10-03-autopilot-experiments.md). * Listeners run after the write, synchronously; their errors are ignored. * * @param listener - Listener * @returns Unsubscribe */ onChange(listener: ProjectTicketChangeListener): () => void; /** * Tell the listeners about a write. * * @param change - The change */ private notifyChange; /** * Apply a mutation computed from the current ticket, under the folder lock. * The ticket is re-read inside the lock, so the mutation always sees the * latest file (a human edit or another writer). * * @param projectPath - Absolute project root * @param id - Ticket id * @param actor - Who changes it (for Log lines) * @param compute - Current ticket → changes (null = nothing to do) * @returns The ticket after the change * @throws ProjectTicketError(404) when the ticket does not exist; whatever `compute` throws */ mutate(projectPath: string, id: string, actor: string, compute: (ticket: ProjectTicket) => TicketMutation | null | Promise): Promise; /** * Change editable fields / sections. * * @param projectPath - Absolute project root * @param id - Ticket id * @param input - Changes * @param actor - Who changes it * @param note - Optional Log note * @returns Updated ticket * @throws ProjectTicketError(400/404) */ update(projectPath: string, id: string, input: UpdateProjectTicketInput, actor: string, note?: string): Promise; /** * Move a ticket to another status, enforcing the state machine. * * @param projectPath - Absolute project root * @param id - Ticket id * @param to - New status * @param actor - Who moves it * @param note - Optional reason (goes into the Log line) * @param extra - Other owned fields to set in the same write (assignee, workItemId…) * @returns Updated ticket * @throws ProjectTicketError(400) for an invalid transition, (404) when missing */ transition(projectPath: string, id: string, to: ProjectTicketStatus, actor: string, note?: string, extra?: Partial): Promise; /** * Append a note to the Log. * * @param projectPath - Absolute project root * @param id - Ticket id * @param actor - Who writes it * @param note - The note * @returns Updated ticket */ appendLog(projectPath: string, id: string, actor: string, note: string): Promise; /** * Read a file through the cache. * * @param dirCache - Cache of the folder * @param filePath - File * @param fileName - Name * @returns Parse result, or null when the file vanished / is not a file */ private readCached; /** * Build the API shape of a parsed file. * * @param result - Successful parse * @param fileName - File name * @param filePath - Absolute path * @param projectPath - Project root * @param withBody - Include the raw body * @returns Ticket */ private toTicket; /** * Check git tracking once per project per process (best-effort). * * @param projectPath - Project root */ private checkTracking; } //# sourceMappingURL=project-ticket.service.d.ts.map