Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)`.
75 changes: 67 additions & 8 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -16,13 +17,41 @@ 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());

// 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) {
Expand All @@ -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 });
}
});

Expand All @@ -64,15 +98,25 @@ 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' });
return;
}
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 });
}
});

Expand Down Expand Up @@ -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 });
}
});

Expand All @@ -243,15 +292,25 @@ 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' });
return;
}
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 });
}
});

Expand Down
114 changes: 11 additions & 103 deletions src/audit.ts
Original file line number Diff line number Diff line change
@@ -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),
};
}
8 changes: 6 additions & 2 deletions src/cli-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down
23 changes: 20 additions & 3 deletions src/commands/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,10 +33,11 @@ export default function register(ctx: PluginContext): void {
.option('--deleted-by <deletedBy>', 'Deleted by (interoperability field)')
.option('--delete-reason <deleteReason>', 'Delete reason (interoperability field)')
.option('--needs-producer-review <true|false>', 'Set needsProducerReview flag for the new item (true|false|yes|no)')
.option('--audit <text>', 'Add a structured audit note (freeform text; time and author are set automatically)')
.option('--audit <text>', 'Legacy alias for --audit-text')
.option('--audit-text <text>', 'Set structured audit text (time/author auto-populated)')
.option('--prefix <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);
Expand All @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down
27 changes: 25 additions & 2 deletions src/commands/doctor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`));
Expand Down
Loading
Loading