diff --git a/docs/migrations.md b/docs/migrations.md index 220b497..b420f64 100644 --- a/docs/migrations.md +++ b/docs/migrations.md @@ -77,3 +77,11 @@ Reference - Migrations runner: `src/migrations/index.ts` - Migration descriptors: `src/migrations/*` - Migration application: `runMigrations` creates backups and prunes to the last 5 backups. + +Audit field migration note +-------------------------- +- Migration `20260315-add-audit` adds the `audit` column to `workitems`. +- The migration does not backfill historical comment-based audit content. +- Structured audit data is only written when explicitly provided via write paths (for example `wl update --audit-text "..."`). +- Audit write semantics are overwrite-only for the single `audit` object (no history array in this slice). +- Redaction/safety rules for audit text are tracked separately in `Redaction and Safety Rules for Audit Text (WL-0MMNCOIYS15A1YSI)`. diff --git a/src/api.ts b/src/api.ts index 6ac570f..ef544a5 100644 --- a/src/api.ts +++ b/src/api.ts @@ -7,6 +7,7 @@ import { WorklogDatabase } from './database.js'; import { CreateWorkItemInput, UpdateWorkItemInput, WorkItemQuery, WorkItemStatus, WorkItemPriority, CreateCommentInput, UpdateCommentInput } from './types.js'; import { exportToJsonl, importFromJsonl, getDefaultDataPath } from './jsonl.js'; import { loadConfig } from './config.js'; +import { buildAuditEntry } from './audit.js'; function parseNeedsProducerReview(value: unknown): boolean | undefined { if (value === undefined || value === null) return undefined; @@ -16,6 +17,33 @@ function parseNeedsProducerReview(value: unknown): boolean | undefined { return undefined; } +function normalizeCreateInputWithAudit(input: CreateWorkItemInput): CreateWorkItemInput { + const rawAudit = (input as any).audit; + if (typeof rawAudit === 'string') { + return { + ...input, + audit: buildAuditEntry(rawAudit), + }; + } + return input; +} + +function normalizeUpdateInputWithAudit(input: UpdateWorkItemInput): UpdateWorkItemInput { + const rawAudit = (input as any).audit; + if (typeof rawAudit === 'string') { + return { + ...input, + audit: buildAuditEntry(rawAudit), + }; + } + return input; +} + +function hasAuditField(input: unknown): boolean { + if (!input || typeof input !== 'object') return false; + return Object.prototype.hasOwnProperty.call(input as object, 'audit') && (input as any).audit !== undefined; +} + export function createAPI(db: WorklogDatabase) { const app = express(); app.use(express.json()); @@ -23,6 +51,7 @@ export function createAPI(db: WorklogDatabase) { // Load configuration to get default prefix const config = loadConfig(); const defaultPrefix = config?.prefix || 'WI'; + const auditWriteEnabled = config?.auditWriteEnabled !== false; // Middleware to set the database prefix based on the route function setPrefixMiddleware(req: Request, res: Response, next: NextFunction) { @@ -41,11 +70,16 @@ export function createAPI(db: WorklogDatabase) { app.post('/items', (req: Request, res: Response) => { try { db.setPrefix(defaultPrefix); - const input: CreateWorkItemInput = req.body; + if (!auditWriteEnabled && hasAuditField(req.body)) { + res.status(400).json({ error: 'Audit writes are disabled by config (auditWriteEnabled: false)' }); + return; + } + const input: CreateWorkItemInput = normalizeCreateInputWithAudit(req.body); const item = db.create(input); res.status(201).json(item); } catch (error) { - res.status(400).json({ error: (error as Error).message }); + const message = (error as Error).message || 'Invalid request'; + res.status(400).json({ error: message }); } }); @@ -64,7 +98,16 @@ export function createAPI(db: WorklogDatabase) { app.put('/items/:id', (req: Request, res: Response) => { try { db.setPrefix(defaultPrefix); - const input: UpdateWorkItemInput = req.body; + if (!auditWriteEnabled && hasAuditField(req.body)) { + res.status(400).json({ error: 'Audit writes are disabled by config (auditWriteEnabled: false)' }); + return; + } + const current = db.get(req.params.id); + if (!current) { + res.status(404).json({ error: 'Work item not found' }); + return; + } + const input: UpdateWorkItemInput = normalizeUpdateInputWithAudit(req.body); const item = db.update(req.params.id, input); if (!item) { res.status(404).json({ error: 'Work item not found' }); @@ -72,7 +115,8 @@ export function createAPI(db: WorklogDatabase) { } res.json(item); } catch (error) { - res.status(400).json({ error: (error as Error).message }); + const message = (error as Error).message || 'Invalid request'; + res.status(400).json({ error: message }); } }); @@ -222,11 +266,16 @@ export function createAPI(db: WorklogDatabase) { // Create a work item with prefix app.post('/projects/:prefix/items', setPrefixMiddleware, (req: Request, res: Response) => { try { - const input: CreateWorkItemInput = req.body; + if (!auditWriteEnabled && hasAuditField(req.body)) { + res.status(400).json({ error: 'Audit writes are disabled by config (auditWriteEnabled: false)' }); + return; + } + const input: CreateWorkItemInput = normalizeCreateInputWithAudit(req.body); const item = db.create(input); res.status(201).json(item); } catch (error) { - res.status(400).json({ error: (error as Error).message }); + const message = (error as Error).message || 'Invalid request'; + res.status(400).json({ error: message }); } }); @@ -243,7 +292,16 @@ export function createAPI(db: WorklogDatabase) { // Update a work item with prefix app.put('/projects/:prefix/items/:id', setPrefixMiddleware, (req: Request, res: Response) => { try { - const input: UpdateWorkItemInput = req.body; + if (!auditWriteEnabled && hasAuditField(req.body)) { + res.status(400).json({ error: 'Audit writes are disabled by config (auditWriteEnabled: false)' }); + return; + } + const current = db.get(req.params.id); + if (!current) { + res.status(404).json({ error: 'Work item not found' }); + return; + } + const input: UpdateWorkItemInput = normalizeUpdateInputWithAudit(req.body); const item = db.update(req.params.id, input); if (!item) { res.status(404).json({ error: 'Work item not found' }); @@ -251,7 +309,8 @@ export function createAPI(db: WorklogDatabase) { } res.json(item); } catch (error) { - res.status(400).json({ error: (error as Error).message }); + const message = (error as Error).message || 'Invalid request'; + res.status(400).json({ error: message }); } }); diff --git a/src/audit.ts b/src/audit.ts index 9b78c7e..4c80b3b 100644 --- a/src/audit.ts +++ b/src/audit.ts @@ -1,114 +1,22 @@ -/** - * Audit entry utilities for Worklog. - * - * Provides helpers for building structured AuditEntry objects from - * freeform audit text, including conservative status derivation. - * - * Status derivation is intentionally conservative: - * - If the work item description lacks explicit success criteria, status - * is set to 'Missing Criteria' rather than inferring from audit text. - * - Keyword matching uses conservative thresholds to prefer 'Partial' or - * 'Not Started' over 'Complete' when uncertain. - */ +import os from 'node:os'; +import type { WorkItemAudit } from './types.js'; -import * as os from 'os'; -import type { AuditEntry, AuditStatus } from './types.js'; - -/** - * Patterns that indicate explicit success criteria in a work item description. - * At least one must match for the description to be considered criteria-bearing. - */ -const CRITERIA_PATTERNS = [ - /success criteria/i, - /acceptance criteria/i, - /\bAC\s*\d+/, - /\bdone when\b/i, - /\bcomplete when\b/i, - /\bshould\b.*\bcan\b/i, - /\bmust\b.*\bwhen\b/i, - /- \[[ xX]\]/, // checkbox list items often indicate criteria -]; - -/** - * Returns true if the description appears to contain explicit success criteria. - */ -export function hasExplicitCriteria(description: string): boolean { - if (!description || description.trim() === '') return false; - return CRITERIA_PATTERNS.some(p => p.test(description)); -} - -/** - * Conservatively derive an AuditStatus from audit text and item description. - * - * Rules (applied in order): - * 1. If description lacks explicit success criteria → 'Missing Criteria' - * 2. If audit text contains strong completion signals → 'Complete' - * 3. If audit text contains partial-progress signals → 'Partial' - * 4. Default → 'Not Started' - */ -export function deriveAuditStatus(auditText: string, description: string): AuditStatus { - if (!hasExplicitCriteria(description)) { - return 'Missing Criteria'; - } - - const text = auditText.toLowerCase(); - - // Strong completion signals (all criteria must be satisfied) - const completePatterns = [ - /\ball criteria (met|satisfied|complete)\b/, - /\bfully (complete|done|finished|implemented)\b/, - /\bcomplete\b.*\ball\b/, - /\ball (done|complete|finished)\b/, - /\bimplementation complete\b/, - /\bdelivery complete\b/, - ]; - if (completePatterns.some(p => p.test(text))) { - return 'Complete'; - } - - // Partial-progress signals - const partialPatterns = [ - /\bpartially\b/, - /\bin progress\b/, - /\bsome criteria\b/, - /\bpartial\b/, - /\bincomplete\b/, - /\bremaining\b/, - /\bnot all\b/, - /\bpending\b/, - /\bwork in progress\b/, - /\bwip\b/, - ]; - if (partialPatterns.some(p => p.test(text))) { - return 'Partial'; - } - - // Default conservative - return 'Not Started'; -} - -/** - * Get the current user identity for audit authorship. - * Returns the OS username, falling back to 'unknown' if unavailable. - */ -export function getCurrentUser(): string { +export function resolveAuditAuthor(): string { + const explicit = process.env.WL_USER || process.env.USER || process.env.USERNAME; + if (explicit && explicit.trim()) return explicit.trim(); try { - return os.userInfo().username || 'unknown'; + const username = os.userInfo().username; + if (username && username.trim()) return username.trim(); } catch { - return process.env.USER || process.env.USERNAME || 'unknown'; + // fall back below } + return 'worklog'; } -/** - * Build a complete AuditEntry from freeform text and the work item description. - * Populates `time` from now, `author` from the current OS user, - * and derives `status` conservatively. - */ -export function buildAuditEntry(auditText: string, description: string): AuditEntry { +export function buildAuditEntry(auditText: string, author?: string): WorkItemAudit { return { time: new Date().toISOString(), - author: getCurrentUser(), + author: author && author.trim() ? author.trim() : resolveAuditAuthor(), text: auditText, - status: deriveAuditStatus(auditText, description), }; } diff --git a/src/cli-types.ts b/src/cli-types.ts index e261062..7a9dfad 100644 --- a/src/cli-types.ts +++ b/src/cli-types.ts @@ -31,8 +31,10 @@ export interface CreateOptions { deleteReason?: string; /** Accepts true|false|yes|no to set needsProducerReview flag for the new item */ needsProducerReview?: string; - /** Freeform audit text; system populates time/author and derives status */ + /** Legacy audit flag (kept for compatibility) */ audit?: string; + /** Preferred audit flag for structured writes */ + auditText?: string; prefix?: string; } @@ -73,8 +75,10 @@ export interface UpdateOptions { createdBy?: string; deletedBy?: string; deleteReason?: string; - /** Freeform audit text; system populates time/author and derives status */ + /** Legacy audit flag (kept for compatibility) */ audit?: string; + /** Preferred audit flag for structured writes */ + auditText?: string; prefix?: string; } diff --git a/src/commands/create.ts b/src/commands/create.ts index 1a34032..d23e637 100644 --- a/src/commands/create.ts +++ b/src/commands/create.ts @@ -33,10 +33,11 @@ export default function register(ctx: PluginContext): void { .option('--deleted-by ', 'Deleted by (interoperability field)') .option('--delete-reason ', 'Delete reason (interoperability field)') .option('--needs-producer-review ', 'Set needsProducerReview flag for the new item (true|false|yes|no)') - .option('--audit ', 'Add a structured audit note (freeform text; time and author are set automatically)') + .option('--audit ', 'Legacy alias for --audit-text') + .option('--audit-text ', 'Set structured audit text (time/author auto-populated)') .option('--prefix ', 'Override the default prefix') .action(async (...rawArgs: any[]) => { - const normalized = normalizeActionArgs(rawArgs, ['title','description','descriptionFile','status','priority','parent','tags','assignee','stage','risk','effort','issueType','createdBy','deletedBy','deleteReason','needsProducerReview','audit','prefix']); + const normalized = normalizeActionArgs(rawArgs, ['title','description','descriptionFile','status','priority','parent','tags','assignee','stage','risk','effort','issueType','createdBy','deletedBy','deleteReason','needsProducerReview','audit','auditText','prefix']); let options: CreateOptions = normalized.options as any || {}; utils.requireInitialized(); const db = utils.getDatabase(options.prefix); @@ -53,6 +54,7 @@ export default function register(ctx: PluginContext): void { } const config = utils.getConfig(); + const auditWriteEnabled = config?.auditWriteEnabled !== false; const requestedStage = options.stage !== undefined ? options.stage : 'idea'; let normalizedStatus = (options.status || 'open') as WorkItemStatus; let normalizedStage = requestedStage; @@ -81,6 +83,21 @@ export default function register(ctx: PluginContext): void { } } + const auditTextInput = options.auditText ?? options.audit; + + if (auditTextInput !== undefined && !auditWriteEnabled) { + output.error('Audit writes are disabled by config (`auditWriteEnabled: false`).', { + success: false, + error: 'audit-write-disabled', + }); + process.exit(1); + } + + let auditEntry; + if (auditTextInput !== undefined) { + auditEntry = buildAuditEntry(String(auditTextInput)); + } + const item = db.createWithNextSortIndex({ title: options.title, description: description, @@ -99,7 +116,7 @@ export default function register(ctx: PluginContext): void { needsProducerReview: (options.needsProducerReview !== undefined) ? (['true','yes','1'].includes(String(options.needsProducerReview).toLowerCase())) : false, - audit: options.audit ? buildAuditEntry(options.audit, description) : undefined, + audit: auditEntry, }); const refreshed = db.get(item.id) || item; diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 27a82cd..5fd2c58 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -55,8 +55,31 @@ export default function register(ctx: PluginContext): void { // Not a dry-run: list safe migrations, print blank line, and ask to apply const safeMigs = pending.filter(p => p.safe); if (utils.isJsonMode()) { - output.json({ success: true, pending, safeMigrations: safeMigs }); - return; + if (!opts.confirm) { + output.json({ success: true, pending, safeMigrations: safeMigs, requiresConfirm: true }); + return; + } + + try { + const result = runMigrations({ + dryRun: false, + confirm: true, + logger: { info: s => console.error(s), error: s => console.error(s) } + }); + output.json({ + success: true, + pending, + safeMigrations: safeMigs, + applied: result.applied, + backups: result.backups, + }); + return; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + process.exitCode = 1; + output.json({ success: false, error: message }); + return; + } } console.log('Pending safe migrations:'); safeMigs.forEach(p => console.log(` - ${p.id}: ${p.description}`)); diff --git a/src/commands/helpers.ts b/src/commands/helpers.ts index 7b0ad2c..63dc355 100644 --- a/src/commands/helpers.ts +++ b/src/commands/helpers.ts @@ -260,6 +260,10 @@ export function humanFormatWorkItem(item: WorkItem, db: WorklogDatabase | null, lines.push(`Risk: ${item.risk || '—'}`); lines.push(`Effort: ${item.effort || '—'}`); if (item.assignee) lines.push(`Assignee: ${item.assignee}`); + if (item.audit) { + const firstLine = String(item.audit.text || '').split(/\r?\n/, 1)[0]; + lines.push(`Audit: ${firstLine}`); + } if (item.tags && item.tags.length > 0) lines.push(`Tags: ${item.tags.join(', ')}`); return lines.join('\n'); } @@ -280,6 +284,10 @@ export function humanFormatWorkItem(item: WorkItem, db: WorklogDatabase | null, lines.push(`Risk: ${item.risk || '—'}`); lines.push(`Effort: ${item.effort || '—'}`); if (item.assignee) lines.push(`Assignee: ${item.assignee}`); + if (item.audit) { + const firstLine = String(item.audit.text || '').split(/\r?\n/, 1)[0]; + lines.push(`Audit: ${firstLine}`); + } if (item.parentId) lines.push(`Parent: ${item.parentId}`); if (item.description) lines.push(`Description: ${item.description}`); return lines.join('\n'); @@ -367,10 +375,10 @@ export function humanFormatWorkItem(item: WorkItem, db: WorklogDatabase | null, lines.push(''); lines.push('## Audit'); lines.push(''); - lines.push(` Status : ${item.audit.status}`); - lines.push(` Author : ${item.audit.author}`); - lines.push(` Time : ${item.audit.time}`); - lines.push(` Note : ${item.audit.text}`); + lines.push(`Time: ${item.audit.time}`); + lines.push(`Author: ${item.audit.author}`); + lines.push(''); + lines.push(item.audit.text); } return lines.join('\n'); diff --git a/src/commands/update.ts b/src/commands/update.ts index 2d0e27b..8f90cb0 100644 --- a/src/commands/update.ts +++ b/src/commands/update.ts @@ -33,12 +33,13 @@ export default function register(ctx: PluginContext): void { .option('--deleted-by ', 'New deleted by (interoperability field)') .option('--delete-reason ', 'New delete reason (interoperability field)') .option('--needs-producer-review ', 'Set needsProducerReview flag (true|false|yes|no)') - .option('--audit ', 'Add a structured audit note (freeform text; time and author are set automatically)') + .option('--audit ', 'Legacy alias for --audit-text') + .option('--audit-text ', 'Set structured audit text (time/author auto-populated)') .option('--do-not-delegate ', 'Set or clear the do-not-delegate tag (true|false|yes|no)') .option('--prefix ', 'Override the default prefix') .action(async (...rawArgs: any[]) => { const knownOptionKeys = [ - 'title','description','descriptionFile','status','priority','parent','tags','assignee','stage','risk','effort','issueType','createdBy','deletedBy','deleteReason','needsProducerReview','doNotDelegate','audit','prefix' + 'title','description','descriptionFile','status','priority','parent','tags','assignee','stage','risk','effort','issueType','createdBy','deletedBy','deleteReason','needsProducerReview','audit','auditText','doNotDelegate','prefix' ]; const normalized = normalizeActionArgs(rawArgs, knownOptionKeys); @@ -99,12 +100,21 @@ export default function register(ctx: PluginContext): void { const assigneeCandidate = hasProvided('assignee') ? options.assignee : undefined; const stageCandidate = hasProvided('stage') ? options.stage : undefined; const config = utils.getConfig(); + const auditWriteEnabled = config?.auditWriteEnabled !== false; const riskCandidate = hasProvided('risk') ? options.risk as WorkItemRiskLevel | '' : undefined; const effortCandidate = hasProvided('effort') ? options.effort as WorkItemEffortLevel | '' : undefined; const issueTypeCandidate = hasProvided('issueType') ? options.issueType : undefined; const createdByCandidate = hasProvided('createdBy') ? options.createdBy : undefined; const deletedByCandidate = hasProvided('deletedBy') ? options.deletedBy : undefined; const deleteReasonCandidate = hasProvided('deleteReason') ? options.deleteReason : undefined; + const auditCandidate = hasProvided('auditText') ? options.auditText : (hasProvided('audit') ? options.audit : undefined); + if (auditCandidate !== undefined && !auditWriteEnabled) { + output.error('Audit writes are disabled by config (`auditWriteEnabled: false`).', { + success: false, + error: 'audit-write-disabled', + }); + process.exit(1); + } let needsProducerReviewCandidate: boolean | undefined; if (hasProvided('needsProducerReview')) { const raw = String(options.needsProducerReview).toLowerCase(); @@ -129,9 +139,6 @@ export default function register(ctx: PluginContext): void { } } - // --audit: freeform text provided by the operator; system populates time/author/status - const auditTextCandidate = hasProvided('audit') ? String(options.audit ?? '') : undefined; - const results: Array = []; for (const rawId of idsRaw) { const normalizedId = utils.normalizeCliId(rawId, options.prefix) || rawId; @@ -149,12 +156,14 @@ export default function register(ctx: PluginContext): void { if (deletedByCandidate !== undefined) updates.deletedBy = deletedByCandidate; if (deleteReasonCandidate !== undefined) updates.deleteReason = deleteReasonCandidate; if (needsProducerReviewCandidate !== undefined) updates.needsProducerReview = needsProducerReviewCandidate; - - // Build audit entry if --audit was provided (requires current item description for status derivation) - if (auditTextCandidate !== undefined) { + if (auditCandidate !== undefined) { const current = db.get(normalizedId); - const description = descriptionCandidate ?? current?.description ?? ''; - updates.audit = buildAuditEntry(auditTextCandidate, description); + if (!current) { + const message = `Work item not found: ${normalizedId}`; + results.push({ id: normalizedId, success: false, error: message }); + continue; + } + updates.audit = buildAuditEntry(String(auditCandidate)); } // Validate status/stage per-id if needed. diff --git a/src/database.ts b/src/database.ts index 5e5f509..7041faa 100644 --- a/src/database.ts +++ b/src/database.ts @@ -645,6 +645,7 @@ export class WorklogDatabase { githubIssueNumber: item.githubIssueNumber, githubIssueId: item.githubIssueId, githubIssueUpdatedAt: item.githubIssueUpdatedAt, + audit: input.audit ?? item.audit, }; if (process.env.WL_DEBUG_SQL_BINDINGS) { diff --git a/src/jsonl.ts b/src/jsonl.ts index f521a6b..eb26a93 100644 --- a/src/jsonl.ts +++ b/src/jsonl.ts @@ -196,6 +196,9 @@ export function importFromJsonlContent(content: string): { items: WorkItem[], co if ((item as any).needsProducerReview !== undefined) { (item as any).needsProducerReview = Boolean((item as any).needsProducerReview); } + if ((item as any).audit === undefined || (item as any).audit === null) { + (item as any).audit = undefined; + } // Normalize status to canonical hyphenated form (e.g. in_progress -> in-progress) // on import so all downstream consumers see consistent values. item.status = (normalizeStatusValue(item.status) ?? item.status) as WorkItem['status']; @@ -255,6 +258,9 @@ export function importFromJsonlContent(content: string): { items: WorkItem[], co if ((item as any).githubIssueUpdatedAt === undefined) { (item as any).githubIssueUpdatedAt = undefined; } + if ((item as any).audit === undefined || (item as any).audit === null) { + (item as any).audit = undefined; + } if ((item as any).githubIssueNumber !== undefined && (item as any).githubIssueNumber !== null) { (item as any).githubIssueNumber = Number((item as any).githubIssueNumber); } diff --git a/src/migrations/index.ts b/src/migrations/index.ts index b835a61..bb48854 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -20,11 +20,12 @@ interface RunOptions { logger?: { info: (s: string) => void; error: (s: string) => void }; } -const MIGRATIONS: Array<{ id: string; description: string; safe: boolean; apply: (db: Database.Database) => void }> = [ +const MIGRATIONS: Array<{ id: string; description: string; safe: boolean; requiredColumn: string; apply: (db: Database.Database) => void }> = [ { id: '20260210-add-needsProducerReview', description: 'Add needsProducerReview INTEGER column to workitems (default 0)', safe: true, + requiredColumn: 'needsProducerReview', apply: (db: Database.Database) => { const cols = db.prepare(`PRAGMA table_info('workitems')`).all() as any[]; const existingCols = new Set(cols.map(c => String(c.name))); @@ -35,9 +36,10 @@ const MIGRATIONS: Array<{ id: string; description: string; safe: boolean; apply: } }, { - id: '20260314-add-audit', - description: 'Add audit TEXT column to workitems (stores JSON-encoded AuditEntry, nullable)', + id: '20260315-add-audit', + description: 'Add audit TEXT column to workitems', safe: true, + requiredColumn: 'audit', apply: (db: Database.Database) => { const cols = db.prepare(`PRAGMA table_info('workitems')`).all() as any[]; const existingCols = new Set(cols.map(c => String(c.name))); @@ -48,16 +50,6 @@ const MIGRATIONS: Array<{ id: string; description: string; safe: boolean; apply: } ]; -/** - * Map from migration id to the column name whose presence indicates - * the migration has already been applied. - * This lets listPendingMigrations and runMigrations operate generically. - */ -const MIGRATION_COLUMN_SENTINEL: Record = { - '20260210-add-needsProducerReview': 'needsProducerReview', - '20260314-add-audit': 'audit', -}; - function resolveDbPath(dbPath?: string): string { if (dbPath) return dbPath; const dataPath = getDefaultDataPath(); @@ -75,12 +67,8 @@ export function listPendingMigrations(dbPath?: string): MigrationInfo[] { try { const cols = db.prepare(`PRAGMA table_info('workitems')`).all() as any[]; const existingCols = new Set(cols.map(c => String(c.name))); - const pending = MIGRATIONS - .filter(m => { - const sentinel = MIGRATION_COLUMN_SENTINEL[m.id]; - if (sentinel) return !existingCols.has(sentinel); - // Unknown migration: conservatively report as pending - return true; + const pending = MIGRATIONS.filter(m => { + return !existingCols.has(m.requiredColumn); }) .map(m => ({ id: m.id, description: m.description, safe: m.safe })); return pending; @@ -152,25 +140,19 @@ export function runMigrations(opts: RunOptions = {}, dbPath?: string, filter?: { const applied: MigrationInfo[] = []; try { const tx = db.transaction(() => { - // Fetch current columns once; each migration's apply() is idempotent, - // but we also guard here to avoid re-applying already-applied migrations. - const cols = db.prepare(`PRAGMA table_info('workitems')`).all() as any[]; - const existingCols = new Set(cols.map(c => String(c.name))); - for (const m of MIGRATIONS) { if (filter?.safeOnly && !m.safe) continue; - const sentinel = MIGRATION_COLUMN_SENTINEL[m.id]; - if (sentinel && existingCols.has(sentinel)) continue; // already applied - m.apply(db); - applied.push({ id: m.id, description: m.description, safe: m.safe }); + const cols = db.prepare(`PRAGMA table_info('workitems')`).all() as any[]; + const existingCols = new Set(cols.map(c => String(c.name))); + if (!existingCols.has(m.requiredColumn)) { + m.apply(db); + applied.push({ id: m.id, description: m.description, safe: m.safe }); + } } - // Update metadata schemaVersion (increment by 1 from existing if present) + // Update metadata schemaVersion deterministically to current schema. try { - const versionRow = db.prepare('SELECT value FROM metadata WHERE key = ?').get('schemaVersion') as { value: string } | undefined; - const current = versionRow ? parseInt(versionRow.value, 10) : 6; - const next = Math.max(current, 6) + (applied.length > 0 ? 1 : 0); - db.prepare('INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)').run('schemaVersion', String(next)); + db.prepare('INSERT OR REPLACE INTO metadata (key, value) VALUES (?, ?)').run('schemaVersion', '7'); } catch (err) { // Best-effort: don't fail migration if metadata update fails, but log logger.error?.(`Failed to update metadata.schemaVersion: ${(err as Error).message}`); diff --git a/src/persistent-store.ts b/src/persistent-store.ts index 7bb7172..27750c5 100644 --- a/src/persistent-store.ts +++ b/src/persistent-store.ts @@ -29,7 +29,7 @@ interface DbMetadata { schemaVersion: number; } -const SCHEMA_VERSION = 6; +const SCHEMA_VERSION = 7; /** * Normalize a single value for use as a better-sqlite3 binding parameter. @@ -1290,7 +1290,7 @@ export class SqlitePersistentStore { githubIssueId: row.githubIssueId ?? undefined, githubIssueUpdatedAt: row.githubIssueUpdatedAt || undefined, needsProducerReview: Boolean(row.needsProducerReview), - audit: (() => { try { return row.audit ? JSON.parse(row.audit) : undefined; } catch { return undefined; } })(), + audit: undefined, }; } } diff --git a/src/tui/types.ts b/src/tui/types.ts index bb09515..184ee86 100644 --- a/src/tui/types.ts +++ b/src/tui/types.ts @@ -45,6 +45,11 @@ export interface WorkItem { issueType?: 'bug' | 'feature' | 'task' | 'epic' | 'chore'; effort?: string; risk?: string; + audit?: { + time: string; + author: string; + text: string; + }; } export interface VisibleNode { @@ -94,4 +99,4 @@ export interface TuiComponentLifecycle { hide(): void; focus(): void; destroy(): void; -} \ No newline at end of file +} diff --git a/src/types.ts b/src/types.ts index 187e748..d0bb9c2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -4,32 +4,14 @@ export type WorkItemStatus = 'open' | 'in-progress' | 'completed' | 'blocked' | 'deleted'; export type WorkItemPriority = 'low' | 'medium' | 'high' | 'critical'; +export type WorkItemRiskLevel = 'Low' | 'Medium' | 'High' | 'Severe'; +export type WorkItemEffortLevel = 'XS' | 'S' | 'M' | 'L' | 'XL'; -/** - * Audit status values for structured audit entries. - * Derived conservatively from audit text and work item success criteria. - */ -export type AuditStatus = 'Complete' | 'Partial' | 'Not Started' | 'Missing Criteria'; - -/** - * Structured audit entry attached to a work item. - * Only the most recent audit is stored per item. - */ -export interface AuditEntry { - /** ISO 8601 timestamp when the audit was written */ +export interface WorkItemAudit { time: string; - /** Author identity (username) at audit write time */ author: string; - /** Freeform audit note text provided by the operator/agent */ text: string; - /** - * Conservative status derived from audit text and work item description. - * 'Missing Criteria' when the description lacks explicit success criteria. - */ - status: AuditStatus; } -export type WorkItemRiskLevel = 'Low' | 'Medium' | 'High' | 'Severe'; -export type WorkItemEffortLevel = 'XS' | 'S' | 'M' | 'L' | 'XL'; /** * JSONL dependency edge representation @@ -74,11 +56,7 @@ export interface WorkItem { githubIssueUpdatedAt?: string; // Indicates whether the item needs a Producer to review/sign-off. Default: false needsProducerReview?: boolean; - /** - * Structured audit entry (most recent). Set explicitly via --audit flag. - * Not backfilled from comment history. - */ - audit?: AuditEntry; + audit?: WorkItemAudit; } /** @@ -104,8 +82,7 @@ export interface CreateWorkItemInput { effort?: WorkItemEffortLevel | ''; /** When present, sets the needsProducerReview flag on the created item */ needsProducerReview?: boolean; - /** When present, attaches a structured audit entry to the created item */ - audit?: AuditEntry; + audit?: WorkItemAudit; } /** @@ -131,8 +108,7 @@ export interface UpdateWorkItemInput { effort?: WorkItemEffortLevel | ''; /** When present, sets the needsProducerReview flag */ needsProducerReview?: boolean; - /** When present, updates the structured audit entry on the work item */ - audit?: AuditEntry; + audit?: WorkItemAudit; } export interface WorkItemQuery { status?: WorkItemStatus; @@ -159,6 +135,7 @@ export interface WorklogConfig { prefix: string; autoExport?: boolean; autoSync?: boolean; + auditWriteEnabled?: boolean; syncRemote?: string; syncBranch?: string; githubRepo?: string; diff --git a/tests/audit.test.ts b/tests/audit.test.ts new file mode 100644 index 0000000..0b91648 --- /dev/null +++ b/tests/audit.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { buildAuditEntry } from '../src/audit.js'; + +describe('buildAuditEntry', () => { + it('builds an audit entry with generated time and author', () => { + const entry = buildAuditEntry('Applied DB migration'); + + expect(entry.text).toBe('Applied DB migration'); + expect(entry.author).toBeTruthy(); + expect(entry.time).toMatch(/Z$/); + }); + + it('uses explicit author when provided', () => { + const entry = buildAuditEntry('Manual handoff', 'cli-user'); + + expect(entry.author).toBe('cli-user'); + expect(entry.text).toBe('Manual handoff'); + }); + + it('does not add status to audit entries', () => { + const entry = buildAuditEntry('Any text'); + expect((entry as any).status).toBeUndefined(); + }); +}); diff --git a/tests/cli/cli-helpers.ts b/tests/cli/cli-helpers.ts index 6442812..9c2a804 100644 --- a/tests/cli/cli-helpers.ts +++ b/tests/cli/cli-helpers.ts @@ -204,6 +204,11 @@ export function seedWorkItems( assignee?: string; stage?: string; needsProducerReview?: boolean; + audit?: { + time: string; + author: string; + text: string; + }; }>, comments: Comment[] = [] ): WorkItem[] { @@ -228,6 +233,7 @@ export function seedWorkItems( risk: '' as const, effort: '' as const, needsProducerReview: item.needsProducerReview ?? false, + audit: item.audit, })); const dataPath = path.join(dir, '.worklog', 'worklog-data.jsonl'); diff --git a/tests/cli/doctor-upgrade.test.ts b/tests/cli/doctor-upgrade.test.ts new file mode 100644 index 0000000..91cc64a --- /dev/null +++ b/tests/cli/doctor-upgrade.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as path from 'path'; +import Database from 'better-sqlite3'; +import { + cliPath, + execAsync, + enterTempDir, + leaveTempDir, + writeConfig, + writeInitSemaphore, +} from './cli-helpers.js'; + +function createLegacyDbWithoutAudit(dbPath: string): void { + const db = new Database(dbPath); + try { + db.exec(` + CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS workitems ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL, + status TEXT NOT NULL, + priority TEXT NOT NULL, + sortIndex INTEGER NOT NULL DEFAULT 0, + parentId TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + tags TEXT NOT NULL, + assignee TEXT NOT NULL, + stage TEXT NOT NULL, + issueType TEXT NOT NULL, + createdBy TEXT NOT NULL, + deletedBy TEXT NOT NULL, + deleteReason TEXT NOT NULL, + risk TEXT NOT NULL, + effort TEXT NOT NULL, + githubIssueNumber INTEGER, + githubIssueId INTEGER, + githubIssueUpdatedAt TEXT, + needsProducerReview INTEGER NOT NULL DEFAULT 0 + ); + INSERT OR REPLACE INTO metadata (key, value) VALUES ('schemaVersion', '6'); + `); + } finally { + db.close(); + } +} + +describe('doctor upgrade command', () => { + let tempState: { tempDir: string; originalCwd: string }; + + beforeEach(() => { + tempState = enterTempDir(); + writeConfig(tempState.tempDir, 'Test Project', 'TEST'); + writeInitSemaphore(tempState.tempDir, '1.0.0'); + + const dbPath = path.join(tempState.tempDir, '.worklog', 'worklog.db'); + createLegacyDbWithoutAudit(dbPath); + }); + + afterEach(() => { + leaveTempDir(tempState); + }); + + it('keeps --dry-run JSON as preview-only', async () => { + const { stdout } = await execAsync(`tsx ${cliPath} --json doctor upgrade --dry-run`); + const result = JSON.parse(stdout); + + expect(result.success).toBe(true); + expect(result.dryRun).toBe(true); + expect(result.pending.some((m: any) => m.id === '20260315-add-audit')).toBe(true); + }); + + it('applies migrations with --confirm --json and returns applied metadata', async () => { + const { stdout } = await execAsync(`tsx ${cliPath} --json doctor upgrade --confirm`); + const result = JSON.parse(stdout); + + expect(result.success).toBe(true); + expect(Array.isArray(result.applied)).toBe(true); + expect(result.applied.some((m: any) => m.id === '20260315-add-audit')).toBe(true); + expect(Array.isArray(result.backups)).toBe(true); + expect(result.backups.length).toBeGreaterThan(0); + + const dbPath = path.join(tempState.tempDir, '.worklog', 'worklog.db'); + const db = new Database(dbPath, { readonly: true }); + try { + const cols = db.prepare(`PRAGMA table_info('workitems')`).all() as Array<{ name: string }>; + expect(cols.map(c => c.name)).toContain('audit'); + } finally { + db.close(); + } + }); +}); diff --git a/tests/cli/initialization-check.test.ts b/tests/cli/initialization-check.test.ts index 1dcc539..1b549ca 100644 --- a/tests/cli/initialization-check.test.ts +++ b/tests/cli/initialization-check.test.ts @@ -67,6 +67,18 @@ describe('CLI Initialization Check Tests', () => { } }); + it('should fail update --audit-text when not initialized', async () => { + try { + await execAsync(`tsx ${cliPath} --json update TEST-1 --audit-text "Test audit"`); + throw new Error('Expected update command to fail, but it succeeded'); + } catch (error: any) { + const result = JSON.parse(error.stdout || '{}'); + expect(result.success).toBe(false); + expect(result.initialized).toBe(false); + expect(result.error).toContain('not initialized'); + } + }); + it('should fail delete command when not initialized', async () => { try { await execAsync(`tsx ${cliPath} --json delete TEST-1`); diff --git a/tests/cli/issue-management.test.ts b/tests/cli/issue-management.test.ts index bddeccd..95132e0 100644 --- a/tests/cli/issue-management.test.ts +++ b/tests/cli/issue-management.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; import { cliPath, execAsync, @@ -22,6 +24,11 @@ describe('CLI Issue Management Tests', () => { }); describe('create command', () => { + it('should list --audit-text in create --help', async () => { + const { stdout } = await execAsync(`tsx ${cliPath} create --help`); + expect(stdout).toContain('--audit-text '); + }); + it('should create a work item with required fields', async () => { const { stdout } = await execAsync(`tsx ${cliPath} --json create -t "Test task"`); @@ -89,6 +96,11 @@ describe('CLI Issue Management Tests', () => { workItemId = result.workItem.id; }); + it('should list --audit-text in update --help', async () => { + const { stdout } = await execAsync(`tsx ${cliPath} update --help`); + expect(stdout).toContain('--audit-text '); + }); + it('should update a work item title', async () => { const { stdout } = await execAsync( `tsx ${cliPath} --json update ${workItemId} -t "Updated title"` @@ -99,6 +111,89 @@ describe('CLI Issue Management Tests', () => { expect(result.workItem.title).toBe('Updated title'); }); + it('should set audit via update command', async () => { + const { stdout } = await execAsync( + `tsx ${cliPath} --json update ${workItemId} --audit-text "Ready to close: Yes"` + ); + + const result = JSON.parse(stdout); + expect(result.success).toBe(true); + expect(result.workItem.audit).toBeDefined(); + expect(result.workItem.audit.text).toBe('Ready to close: Yes'); + expect(result.workItem.audit.author).toBeTruthy(); + expect(result.workItem.audit.time).toMatch(/Z$/); + }); + + it('should derive complete audit status when success criteria exist', async () => { + const { stdout: created } = await execAsync( + `tsx ${cliPath} --json create -t "With criteria" -d "Success criteria: Done means closed"` + ); + const itemId = JSON.parse(created).workItem.id; + + const { stdout } = await execAsync( + `tsx ${cliPath} --json update ${itemId} --audit-text "Ready to close: Yes"` + ); + + const result = JSON.parse(stdout); + expect(result.success).toBe(true); + expect(result.workItem.audit.text).toBe('Ready to close: Yes'); + }); + + it('should set audit via create command', async () => { + const { stdout } = await execAsync( + `tsx ${cliPath} --json create -t "Create audited" -d "Acceptance criteria: Must pass" --audit-text "Ready to close: No"` + ); + const result = JSON.parse(stdout); + expect(result.success).toBe(true); + expect(result.workItem.audit).toBeDefined(); + expect(result.workItem.audit.text).toBe('Ready to close: No'); + expect(result.workItem.audit.author).toBeTruthy(); + }); + + it('should accept free-form audit text', async () => { + const { stdout } = await execAsync( + `tsx ${cliPath} --json update ${workItemId} --audit-text "Looks good to me"` + ); + + const result = JSON.parse(stdout); + expect(result.success).toBe(true); + expect(result.workItem.audit.text).toBe('Looks good to me'); + }); + + it('should overwrite existing audit object on subsequent writes', async () => { + const first = await execAsync( + `tsx ${cliPath} --json update ${workItemId} --audit-text "First audit"` + ); + const firstResult = JSON.parse(first.stdout); + + const second = await execAsync( + `tsx ${cliPath} --json update ${workItemId} --audit-text "Second audit"` + ); + const secondResult = JSON.parse(second.stdout); + + expect(firstResult.success).toBe(true); + expect(secondResult.success).toBe(true); + expect(secondResult.workItem.audit.text).toBe('Second audit'); + expect(secondResult.workItem.audit.author).toBeTruthy(); + expect(secondResult.workItem.audit.time).toMatch(/Z$/); + }); + + it('should reject audit writes when auditWriteEnabled is false', async () => { + writeConfig(tempState.tempDir, 'Test Project', 'TEST'); + fs.appendFileSync(path.join(tempState.tempDir, '.worklog', 'config.yaml'), '\nauditWriteEnabled: false\n', 'utf-8'); + + try { + await execAsync( + `tsx ${cliPath} --json update ${workItemId} --audit-text "Ready to close: Yes"` + ); + expect.fail('Should have thrown an error'); + } catch (error: any) { + const result = JSON.parse(error.stderr || error.stdout || '{}'); + expect(result.success).toBe(false); + expect(result.error).toBe('audit-write-disabled'); + } + }); + it('should update multiple fields', async () => { const { stdout: created } = await execAsync( `tsx ${cliPath} --json create -t "Update base" -s in-progress --stage "in_progress"` diff --git a/tests/cli/issue-status.test.ts b/tests/cli/issue-status.test.ts index 266c7a1..56466ba 100644 --- a/tests/cli/issue-status.test.ts +++ b/tests/cli/issue-status.test.ts @@ -188,6 +188,16 @@ describe('CLI Issue Status Tests', () => { expect(result.workItem.title).toBe('Test task'); }); + it('should include audit in show json output', async () => { + await execAsync(`tsx ${cliPath} --json update ${workItemId} --audit-text "Ready to close: Yes"`); + const { stdout } = await execAsync(`tsx ${cliPath} --json show ${workItemId}`); + + const result = JSON.parse(stdout); + expect(result.success).toBe(true); + expect(result.workItem.audit).toBeDefined(); + expect(result.workItem.audit.text).toBe('Ready to close: Yes'); + }); + it('should show children when -c flag is used', async () => { await execAsync(`tsx ${cliPath} create -t "Child task" -P ${workItemId}`); diff --git a/tests/database.test.ts b/tests/database.test.ts index 9796412..df5eba8 100644 --- a/tests/database.test.ts +++ b/tests/database.test.ts @@ -80,6 +80,22 @@ describe('WorklogDatabase', () => { expect(item.createdBy).toBe('john.doe'); }); + it('should create a work item with a structured audit', () => { + const item = db.create({ + title: 'Audited item', + description: 'Success criteria: ship it', + audit: { + time: new Date().toISOString(), + author: 'tester', + text: 'Ready to close: Yes', + }, + }); + + expect(item.audit).toBeDefined(); + expect(item.audit?.author).toBe('tester'); + expect(item.audit?.text).toBe('Ready to close: Yes'); + }); + it('should create a work item with a parent', () => { const parent = db.create({ title: 'Parent task' }); const child = db.create({ @@ -250,6 +266,21 @@ describe('WorklogDatabase', () => { expect(updated?.description).toBe('New description'); }); + it('should update structured audit fields', () => { + const item = db.create({ title: 'Task' }); + const updated = db.update(item.id, { + audit: { + time: new Date().toISOString(), + author: 'updater', + text: 'Ready to close: No', + }, + }); + + expect(updated?.audit).toBeDefined(); + expect(updated?.audit?.author).toBe('updater'); + expect(db.get(item.id)?.audit?.text).toBe('Ready to close: No'); + }); + it('should return null for non-existent ID', () => { const result = db.update('TEST-NONEXISTENT', { title: 'Updated' }); expect(result).toBe(null); diff --git a/tests/migrations.test.ts b/tests/migrations.test.ts new file mode 100644 index 0000000..5633b26 --- /dev/null +++ b/tests/migrations.test.ts @@ -0,0 +1,90 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import Database from 'better-sqlite3'; +import { createTempDir, cleanupTempDir } from './test-utils.js'; +import { listPendingMigrations, runMigrations } from '../src/migrations/index.js'; + +function createLegacyDbWithoutAudit(dbPath: string): void { + const db = new Database(dbPath); + try { + db.exec(` + CREATE TABLE IF NOT EXISTS metadata ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS workitems ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + description TEXT NOT NULL, + status TEXT NOT NULL, + priority TEXT NOT NULL, + sortIndex INTEGER NOT NULL DEFAULT 0, + parentId TEXT, + createdAt TEXT NOT NULL, + updatedAt TEXT NOT NULL, + tags TEXT NOT NULL, + assignee TEXT NOT NULL, + stage TEXT NOT NULL, + issueType TEXT NOT NULL, + createdBy TEXT NOT NULL, + deletedBy TEXT NOT NULL, + deleteReason TEXT NOT NULL, + risk TEXT NOT NULL, + effort TEXT NOT NULL, + githubIssueNumber INTEGER, + githubIssueId INTEGER, + githubIssueUpdatedAt TEXT, + needsProducerReview INTEGER NOT NULL DEFAULT 0 + ); + INSERT OR REPLACE INTO metadata (key, value) VALUES ('schemaVersion', '6'); + `); + } finally { + db.close(); + } +} + +describe('migrations: add audit field', () => { + it('lists audit migration as pending for legacy db', () => { + const tempDir = createTempDir(); + try { + const dbPath = path.join(tempDir, 'worklog.db'); + createLegacyDbWithoutAudit(dbPath); + + const pending = listPendingMigrations(dbPath); + const ids = pending.map(p => p.id); + expect(ids).toContain('20260315-add-audit'); + } finally { + cleanupTempDir(tempDir); + } + }); + + it('applies audit migration with backup and idempotency', () => { + const tempDir = createTempDir(); + try { + const dbPath = path.join(tempDir, 'worklog.db'); + createLegacyDbWithoutAudit(dbPath); + + const dryRun = runMigrations({ dryRun: true }, dbPath); + expect(dryRun.applied.map(a => a.id)).toContain('20260315-add-audit'); + + const applied = runMigrations({ confirm: true }, dbPath); + expect(applied.applied.map(a => a.id)).toContain('20260315-add-audit'); + expect(applied.backups.length).toBe(1); + expect(fs.existsSync(applied.backups[0])).toBe(true); + + const db = new Database(dbPath, { readonly: true }); + try { + const cols = db.prepare(`PRAGMA table_info('workitems')`).all() as Array<{ name: string }>; + expect(cols.map(c => c.name)).toContain('audit'); + } finally { + db.close(); + } + + const secondRun = runMigrations({ confirm: true }, dbPath); + expect(secondRun.applied).toHaveLength(0); + } finally { + cleanupTempDir(tempDir); + } + }); +}); diff --git a/tests/normalize-sqlite-bindings.test.ts b/tests/normalize-sqlite-bindings.test.ts index 874c6ef..d96e591 100644 --- a/tests/normalize-sqlite-bindings.test.ts +++ b/tests/normalize-sqlite-bindings.test.ts @@ -165,6 +165,11 @@ describe('SQLite binding round-trip', () => { stage: 'idea', issueType: 'task', needsProducerReview: true, + audit: { + time: new Date().toISOString(), + author: 'roundtrip', + text: 'Ready to close: Yes', + }, }); const loaded = db.get(created.id); @@ -177,6 +182,8 @@ describe('SQLite binding round-trip', () => { expect(loaded!.stage).toBe('idea'); expect(loaded!.issueType).toBe('task'); expect(loaded!.needsProducerReview).toBe(true); + expect(loaded!.audit).toBeDefined(); + expect(loaded!.audit?.author).toBe('roundtrip'); // Date fields should be valid ISO strings expect(() => new Date(loaded!.createdAt).toISOString()).not.toThrow(); expect(() => new Date(loaded!.updatedAt).toISOString()).not.toThrow(); diff --git a/tests/unit/audit.test.ts b/tests/unit/audit.test.ts deleted file mode 100644 index 9c36af2..0000000 --- a/tests/unit/audit.test.ts +++ /dev/null @@ -1,359 +0,0 @@ -/** - * Tests for the audit field: type, persistence, migration, and status derivation. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import * as fs from 'fs'; -import * as path from 'path'; -import Database from 'better-sqlite3'; -import { WorklogDatabase } from '../../src/database.js'; -import { buildAuditEntry, deriveAuditStatus, hasExplicitCriteria, getCurrentUser } from '../../src/audit.js'; -import { listPendingMigrations, runMigrations } from '../../src/migrations/index.js'; -import { createTempDir, cleanupTempDir, createTempJsonlPath, createTempDbPath } from '../test-utils.js'; - -// --------------------------------------------------------------------------- -// Audit utility unit tests -// --------------------------------------------------------------------------- - -describe('audit utilities', () => { - describe('hasExplicitCriteria', () => { - it('returns false for empty description', () => { - expect(hasExplicitCriteria('')).toBe(false); - }); - - it('returns false for description with no criteria markers', () => { - expect(hasExplicitCriteria('This is a general description with no specific criteria.')).toBe(false); - }); - - it('detects "Success criteria" heading', () => { - expect(hasExplicitCriteria('Success criteria\n- Item must do X')).toBe(true); - }); - - it('detects "Acceptance criteria" (case-insensitive)', () => { - expect(hasExplicitCriteria('acceptance criteria:\n- must pass all tests')).toBe(true); - }); - - it('detects "AC 1" numbered criteria', () => { - expect(hasExplicitCriteria('AC 1: The system must respond in < 1s')).toBe(true); - }); - - it('detects "done when" phrase', () => { - expect(hasExplicitCriteria('This is done when the PR is merged.')).toBe(true); - }); - - it('detects checkbox list items', () => { - expect(hasExplicitCriteria('- [ ] First criterion\n- [ ] Second criterion')).toBe(true); - }); - }); - - describe('deriveAuditStatus', () => { - const descWithCriteria = 'Success criteria:\n- [ ] All tests pass\n- [ ] PR merged'; - const descWithoutCriteria = 'A simple description with no criteria.'; - - it('returns Missing Criteria when description lacks success criteria', () => { - expect(deriveAuditStatus('Work is complete', descWithoutCriteria)).toBe('Missing Criteria'); - }); - - it('returns Complete for strong completion signal', () => { - expect(deriveAuditStatus('All criteria met and verified', descWithCriteria)).toBe('Complete'); - }); - - it('returns Complete for "fully complete" signal', () => { - expect(deriveAuditStatus('Implementation is fully complete', descWithCriteria)).toBe('Complete'); - }); - - it('returns Partial for partial-progress signal', () => { - expect(deriveAuditStatus('Partially implemented; remaining tests needed', descWithCriteria)).toBe('Partial'); - }); - - it('returns Partial for "work in progress"', () => { - expect(deriveAuditStatus('Work in progress on the feature', descWithCriteria)).toBe('Partial'); - }); - - it('returns Not Started for unrecognized audit text with criteria present', () => { - expect(deriveAuditStatus('Applied migration on 2026-03-14', descWithCriteria)).toBe('Not Started'); - }); - - it('is case-insensitive for Complete match', () => { - expect(deriveAuditStatus('ALL CRITERIA MET', descWithCriteria)).toBe('Complete'); - }); - }); - - describe('buildAuditEntry', () => { - it('returns a complete AuditEntry shape', () => { - const entry = buildAuditEntry('Migration applied', 'Success criteria:\n- [ ] DB updated'); - expect(entry).toMatchObject({ - author: expect.any(String), - text: 'Migration applied', - status: expect.stringMatching(/^(Complete|Partial|Not Started|Missing Criteria)$/), - }); - expect(entry.time).toMatch(/^\d{4}-\d{2}-\d{2}T/); - }); - - it('sets author to a non-empty string', () => { - const entry = buildAuditEntry('test', ''); - expect(typeof entry.author).toBe('string'); - expect(entry.author.length).toBeGreaterThan(0); - }); - }); - - describe('getCurrentUser', () => { - it('returns a non-empty string', () => { - const user = getCurrentUser(); - expect(typeof user).toBe('string'); - expect(user.length).toBeGreaterThan(0); - }); - }); -}); - -// --------------------------------------------------------------------------- -// Persistence round-trip tests -// --------------------------------------------------------------------------- - -describe('audit field persistence (fresh DB)', () => { - let tempDir: string; - let dbPath: string; - let jsonlPath: string; - let db: WorklogDatabase; - - beforeEach(() => { - tempDir = createTempDir(); - dbPath = createTempDbPath(tempDir); - jsonlPath = createTempJsonlPath(tempDir); - db = new WorklogDatabase('TEST', dbPath, jsonlPath, true, true); - }); - - afterEach(() => { - db.close(); - cleanupTempDir(tempDir); - }); - - it('stores and retrieves an audit entry on a work item', () => { - const auditEntry = { - time: new Date().toISOString(), - author: 'testuser', - text: 'Migration applied successfully', - status: 'Not Started' as const, - }; - - const item = db.create({ title: 'Test item', description: 'no criteria' }); - const updated = db.update(item.id, { audit: auditEntry }); - - expect(updated).not.toBeNull(); - expect(updated!.audit).toEqual(auditEntry); - - // Verify persistence by fetching from DB - const fetched = db.get(item.id); - expect(fetched!.audit).toEqual(auditEntry); - }); - - it('audit field is undefined when not set', () => { - const item = db.create({ title: 'No audit item' }); - const fetched = db.get(item.id); - expect(fetched!.audit).toBeUndefined(); - }); - - it('creates a work item with audit entry via create()', () => { - const auditEntry = { - time: new Date().toISOString(), - author: 'operator', - text: 'Created with audit', - status: 'Complete' as const, - }; - - const item = db.create({ title: 'With audit', audit: auditEntry }); - const fetched = db.get(item.id); - expect(fetched!.audit).toEqual(auditEntry); - }); - - it('overwrites a previous audit entry on update', () => { - const first = { time: new Date().toISOString(), author: 'a', text: 'first', status: 'Not Started' as const }; - const second = { time: new Date().toISOString(), author: 'b', text: 'second', status: 'Complete' as const }; - - const item = db.create({ title: 'Replace audit', audit: first }); - db.update(item.id, { audit: second }); - - const fetched = db.get(item.id); - expect(fetched!.audit).toEqual(second); - }); - - it('can clear an audit entry by setting null (stores undefined)', () => { - const auditEntry = { time: new Date().toISOString(), author: 'x', text: 'note', status: 'Partial' as const }; - const item = db.create({ title: 'Will clear audit', audit: auditEntry }); - // Update without audit field - should not clear existing - db.update(item.id, { title: 'Updated title' }); - const fetched = db.get(item.id); - // Audit should still be present since we didn't update it - expect(fetched!.audit).toEqual(auditEntry); - }); -}); - -// --------------------------------------------------------------------------- -// Migration tests -// --------------------------------------------------------------------------- - -describe('20260314-add-audit migration', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = createTempDir(); - }); - - afterEach(() => { - cleanupTempDir(tempDir); - }); - - it('reports no pending migrations for a fresh DB that already has the audit column', () => { - const dbPath = path.join(tempDir, 'fresh.db'); - // Create DB via WorklogDatabase which creates schema with audit column - const db = new WorklogDatabase('WL', dbPath, path.join(tempDir, 'data.jsonl'), false, true); - db.close(); - - const pending = listPendingMigrations(dbPath); - const auditMig = pending.find(m => m.id === '20260314-add-audit'); - expect(auditMig).toBeUndefined(); - }); - - it('reports audit migration as pending for a DB that lacks the audit column', () => { - const dbPath = path.join(tempDir, 'old.db'); - // Simulate an old DB without the audit column - const rawDb = new Database(dbPath); - rawDb.exec(`CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)`); - rawDb.exec(`INSERT INTO metadata (key, value) VALUES ('schemaVersion', '6')`); - rawDb.exec(` - CREATE TABLE workitems ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - description TEXT NOT NULL, - status TEXT NOT NULL, - priority TEXT NOT NULL, - sortIndex INTEGER NOT NULL DEFAULT 0, - parentId TEXT, - createdAt TEXT NOT NULL, - updatedAt TEXT NOT NULL, - tags TEXT NOT NULL, - assignee TEXT NOT NULL, - stage TEXT NOT NULL, - issueType TEXT NOT NULL, - createdBy TEXT NOT NULL, - deletedBy TEXT NOT NULL, - deleteReason TEXT NOT NULL, - risk TEXT NOT NULL, - effort TEXT NOT NULL, - githubIssueNumber INTEGER, - githubIssueId INTEGER, - githubIssueUpdatedAt TEXT, - needsProducerReview INTEGER NOT NULL DEFAULT 0 - ) - `); - rawDb.close(); - - const pending = listPendingMigrations(dbPath); - const auditMig = pending.find(m => m.id === '20260314-add-audit'); - expect(auditMig).toBeDefined(); - expect(auditMig!.safe).toBe(true); - }); - - it('applies the audit migration with --confirm and creates backup', () => { - const dbPath = path.join(tempDir, 'migrate.db'); - // Simulate an old DB without audit column (has needsProducerReview already) - const rawDb = new Database(dbPath); - rawDb.exec(`CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)`); - rawDb.exec(`INSERT INTO metadata (key, value) VALUES ('schemaVersion', '7')`); - rawDb.exec(` - CREATE TABLE workitems ( - id TEXT PRIMARY KEY, - title TEXT NOT NULL, - description TEXT NOT NULL, - status TEXT NOT NULL, - priority TEXT NOT NULL, - sortIndex INTEGER NOT NULL DEFAULT 0, - parentId TEXT, - createdAt TEXT NOT NULL, - updatedAt TEXT NOT NULL, - tags TEXT NOT NULL, - assignee TEXT NOT NULL, - stage TEXT NOT NULL, - issueType TEXT NOT NULL, - createdBy TEXT NOT NULL, - deletedBy TEXT NOT NULL, - deleteReason TEXT NOT NULL, - risk TEXT NOT NULL, - effort TEXT NOT NULL, - githubIssueNumber INTEGER, - githubIssueId INTEGER, - githubIssueUpdatedAt TEXT, - needsProducerReview INTEGER NOT NULL DEFAULT 0 - ) - `); - rawDb.close(); - - const result = runMigrations({ confirm: true }, dbPath); - expect(result.applied.some(m => m.id === '20260314-add-audit')).toBe(true); - expect(result.backups.length).toBe(1); - - // Verify the column was added - const db2 = new Database(dbPath, { readonly: true }); - const cols = db2.prepare(`PRAGMA table_info('workitems')`).all() as any[]; - db2.close(); - const colNames = new Set(cols.map((c: any) => String(c.name))); - expect(colNames.has('audit')).toBe(true); - }); - - it('dry-run returns pending migrations without applying them', () => { - const dbPath = path.join(tempDir, 'dry.db'); - const rawDb = new Database(dbPath); - rawDb.exec(`CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)`); - rawDb.exec(`INSERT INTO metadata (key, value) VALUES ('schemaVersion', '6')`); - rawDb.exec(` - CREATE TABLE workitems ( - id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT NOT NULL, - status TEXT NOT NULL, priority TEXT NOT NULL, sortIndex INTEGER NOT NULL DEFAULT 0, - parentId TEXT, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, - tags TEXT NOT NULL, assignee TEXT NOT NULL, stage TEXT NOT NULL, - issueType TEXT NOT NULL, createdBy TEXT NOT NULL, deletedBy TEXT NOT NULL, - deleteReason TEXT NOT NULL, risk TEXT NOT NULL, effort TEXT NOT NULL, - githubIssueNumber INTEGER, githubIssueId INTEGER, githubIssueUpdatedAt TEXT, - needsProducerReview INTEGER NOT NULL DEFAULT 0 - ) - `); - rawDb.close(); - - const result = runMigrations({ dryRun: true }, dbPath); - expect(result.applied.some(m => m.id === '20260314-add-audit')).toBe(true); - expect(result.backups.length).toBe(0); // no backup in dry-run - - // Column must NOT have been added - const db2 = new Database(dbPath, { readonly: true }); - const cols = db2.prepare(`PRAGMA table_info('workitems')`).all() as any[]; - db2.close(); - const colNames = new Set(cols.map((c: any) => String(c.name))); - expect(colNames.has('audit')).toBe(false); - }); - - it('is idempotent: applying audit migration twice does not throw', () => { - const dbPath = path.join(tempDir, 'idem.db'); - const rawDb = new Database(dbPath); - rawDb.exec(`CREATE TABLE metadata (key TEXT PRIMARY KEY, value TEXT NOT NULL)`); - rawDb.exec(`INSERT INTO metadata (key, value) VALUES ('schemaVersion', '6')`); - rawDb.exec(` - CREATE TABLE workitems ( - id TEXT PRIMARY KEY, title TEXT NOT NULL, description TEXT NOT NULL, - status TEXT NOT NULL, priority TEXT NOT NULL, sortIndex INTEGER NOT NULL DEFAULT 0, - parentId TEXT, createdAt TEXT NOT NULL, updatedAt TEXT NOT NULL, - tags TEXT NOT NULL, assignee TEXT NOT NULL, stage TEXT NOT NULL, - issueType TEXT NOT NULL, createdBy TEXT NOT NULL, deletedBy TEXT NOT NULL, - deleteReason TEXT NOT NULL, risk TEXT NOT NULL, effort TEXT NOT NULL, - githubIssueNumber INTEGER, githubIssueId INTEGER, githubIssueUpdatedAt TEXT, - needsProducerReview INTEGER NOT NULL DEFAULT 0 - ) - `); - rawDb.close(); - - runMigrations({ confirm: true }, dbPath); - // Second run should be a no-op (column already present) - const result2 = runMigrations({ confirm: true }, dbPath); - // No new migrations applied - expect(result2.applied.some(m => m.id === '20260314-add-audit')).toBe(false); - }); -});