diff --git a/src/commands/helpers.ts b/src/commands/helpers.ts index 63dc355..27dcc54 100644 --- a/src/commands/helpers.ts +++ b/src/commands/helpers.ts @@ -3,6 +3,7 @@ */ import { theme } from '../theme.js'; +import { redactAuditText } from '../audit.js'; import type { WorkItem, Comment } from '../types.js'; import type { SyncResult } from '../sync.js'; import type { WorklogDatabase } from '../database.js'; @@ -261,7 +262,11 @@ export function humanFormatWorkItem(item: WorkItem, db: WorklogDatabase | null, 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]; + // For human outputs, show a truncated, redacted one-line audit excerpt. + // Do not include the author in concise output to keep it compact. + const raw = String(item.audit.text || ''); + const redacted = redactAuditText(raw); + const firstLine = redacted.split(/\r?\n/, 1)[0]; lines.push(`Audit: ${firstLine}`); } if (item.tags && item.tags.length > 0) lines.push(`Tags: ${item.tags.join(', ')}`); @@ -285,7 +290,10 @@ export function humanFormatWorkItem(item: WorkItem, db: WorklogDatabase | null, 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]; + const raw = String(item.audit.text || ''); + const redacted = redactAuditText(raw); + const firstLine = redacted.split(/\r?\n/, 1)[0]; + // Keep concise audit excerpt in normal output as well (author omitted). lines.push(`Audit: ${firstLine}`); } if (item.parentId) lines.push(`Parent: ${item.parentId}`); diff --git a/src/commands/show.ts b/src/commands/show.ts index 997c09f..cc4482c 100644 --- a/src/commands/show.ts +++ b/src/commands/show.ts @@ -4,6 +4,7 @@ import type { PluginContext } from '../plugin-types.js'; import type { ShowOptions } from '../cli-types.js'; +import type { WorkItem, Comment, ShowJsonOutput } from '../types.js'; import { displayItemTree, displayItemTreeWithFormat, humanFormatComment, resolveFormat, humanFormatWorkItem } from './helpers.js'; export default function register(ctx: PluginContext): void { @@ -26,16 +27,25 @@ export default function register(ctx: PluginContext): void { } if (utils.isJsonMode()) { - const result: any = { success: true, workItem: item }; - result.comments = db.getCommentsForWorkItem(normalizedId); + // Prepare JSON-safe copies that omit the `audit` field when absent. + // Keep the audit object verbatim when present so JSON consumers can + // rely on the structured { time, author, text } shape. + const stripAudit = (src: WorkItem) => { + const copy: any = Object.assign({}, src); + if (copy.audit === undefined || copy.audit === null) delete copy.audit; + return copy as WorkItem; + }; + + const result: ShowJsonOutput = { success: true, workItem: stripAudit(item) }; + result.comments = db.getCommentsForWorkItem(normalizedId) as Comment[]; if (options.children) { - const children = db.getDescendants(normalizedId); - const ancestors: typeof item[] = []; + const children = db.getDescendants(normalizedId).map(stripAudit); + const ancestors: any[] = []; let currentParentId = item.parentId; while (currentParentId) { const parent = db.get(currentParentId); if (!parent) break; - ancestors.push(parent); + ancestors.push(stripAudit(parent)); currentParentId = parent.parentId; } result.children = children; diff --git a/src/github-sync.ts b/src/github-sync.ts index 1d50f93..9fd7c85 100644 --- a/src/github-sync.ts +++ b/src/github-sync.ts @@ -251,7 +251,10 @@ export async function upsertIssuesFromWorkItems( }; // Concurrency: upsert issues and comments with a bounded concurrency pool - const upsertConcurrency = Number(process.env.WL_GITHUB_CONCURRENCY || '6'); + // The central throttler enforces concurrency/rate limits. Do not rely on + // a local worker pool here; schedule GitHub API calls through `throttler`. + // Keep the env var available to the throttler implementation. + // (local upsertConcurrency removed) const truncateTitle = (title: string, maxLen = 60): string => title.length <= maxLen ? title : title.slice(0, maxLen - 1) + '\u2026'; @@ -292,14 +295,16 @@ export async function upsertIssuesFromWorkItems( const shouldUpdateIssue = !item.githubIssueNumber || !item.githubIssueUpdatedAt || new Date(item.updatedAt).getTime() > new Date(item.githubIssueUpdatedAt).getTime(); - if (shouldUpdateIssue) { + if (shouldUpdateIssue) { const upsertStart = Date.now(); if (onVerboseLog) { onVerboseLog(`[upsert] ${item.githubIssueNumber ? 'update' : 'create'} ${item.id}`); } - if (item.githubIssueNumber) { - increment('api.issue.update'); - issue = await updateGithubIssueAsync(config, item.githubIssueNumber!, payload); + if (item.githubIssueNumber) { + increment('api.issue.update'); + // updateGithubIssueAsync already schedules via the central throttler + // internally (see src/github.ts). Avoid double-scheduling here. + issue = await updateGithubIssueAsync(config, item.githubIssueNumber!, payload); if (item.status === 'deleted') { result.closed += 1; result.syncedItems.push({ @@ -317,13 +322,14 @@ export async function upsertIssuesFromWorkItems( issueNumber: item.githubIssueNumber, }); } - } else { - increment('api.issue.create'); - issue = await createGithubIssueAsync(config, { - title: payload.title, - body: payload.body, - labels: payload.labels, - }); + } else { + increment('api.issue.create'); + // createGithubIssueAsync schedules via the central throttler itself. + issue = await createGithubIssueAsync(config, { + title: payload.title, + body: payload.body, + labels: payload.labels, + }); result.created += 1; result.syncedItems.push({ action: 'created', @@ -343,14 +349,16 @@ export async function upsertIssuesFromWorkItems( } const shouldSyncCommentsNow = itemComments.length > 0 && (shouldSyncComments || shouldUpdateIssue); - if (shouldSyncCommentsNow && issueNumber) { - const commentListStart = Date.now(); - increment('api.comment.list'); - const existingComments = await listGithubIssueCommentsAsync(config, issueNumber!); - timing.commentListMs += Date.now() - commentListStart; - const commentUpsertStart = Date.now(); - const commentSummary = await upsertGithubIssueCommentsAsync(config, issueNumber, itemComments, existingComments); - timing.commentUpsertMs += Date.now() - commentUpsertStart; + if (shouldSyncCommentsNow && issueNumber) { + const commentListStart = Date.now(); + increment('api.comment.list'); + // listGithubIssueCommentsAsync now schedules internally via the throttler + // (see src/github.ts). Call it directly to avoid double-scheduling. + const existingComments = await listGithubIssueCommentsAsync(config, issueNumber!); + timing.commentListMs += Date.now() - commentListStart; + const commentUpsertStart = Date.now(); + const commentSummary = await upsertGithubIssueCommentsAsync(config, issueNumber, itemComments, existingComments); + timing.commentUpsertMs += Date.now() - commentUpsertStart; increment('api.comment.create', commentSummary.created || 0); increment('api.comment.update', commentSummary.updated || 0); result.commentsCreated = (result.commentsCreated || 0) + commentSummary.created; @@ -399,12 +407,14 @@ export async function upsertIssuesFromWorkItems( for (const comment of sorted) { const body = buildGithubCommentBody(comment); const existing = byWorklogId.get(comment.id); - if (existing) { + if (existing) { // If the GH comment exists, only update if body changed OR GH's updatedAt is newer than our recorded mapping const bodyMatch = (existing.body || '').trim() === body.trim(); - if (!bodyMatch) { - increment('api.comment.update'); - const updatedComment = await updateGithubIssueCommentAsync(issueConfig, existing.id!, body); + if (!bodyMatch) { + increment('api.comment.update'); + // updateGithubIssueCommentAsync now schedules internally via the throttler + // (see src/github.ts). Call it directly to avoid double-scheduling. + const updatedComment = await updateGithubIssueCommentAsync(issueConfig, existing.id!, body); // Persist mapping back to local comment comment.githubCommentId = existing.id; comment.githubCommentUpdatedAt = updatedComment.updatedAt; @@ -417,9 +427,11 @@ export async function upsertIssuesFromWorkItems( continue; } - // No GH comment mapping found — create a new comment - increment('api.comment.create'); - const createdComment = await createGithubIssueCommentAsync(issueConfig, issueNumber, body); + // No GH comment mapping found — create a new comment + increment('api.comment.create'); + // createGithubIssueCommentAsync now schedules internally via the throttler + // (see src/github.ts). Call it directly to avoid double-scheduling. + const createdComment = await createGithubIssueCommentAsync(issueConfig, issueNumber, body); // Persist mapping back to local comment so future runs can directly reference by ID comment.githubCommentId = createdComment.id; comment.githubCommentUpdatedAt = createdComment.updatedAt; @@ -433,23 +445,10 @@ export async function upsertIssuesFromWorkItems( return { created, updated, latestUpdatedAt }; } - // simple concurrent mapper for issue upserts - async function mapWithConcurrencyItems(arr: WorkItem[], limit: number, fn: (v: WorkItem, i: number) => Promise) { - const results: Promise[] = []; - let i = 0; - async function worker() { - while (true) { - const idx = i++; - if (idx >= arr.length) return; - await fn(arr[idx], idx); - } - } - const workers = Math.min(limit, arr.length); - for (let w = 0; w < workers; w += 1) results.push(worker()); - await Promise.all(results); - } - - await mapWithConcurrencyItems(issueItems, upsertConcurrency, upsertMapper); + // Launch upsert mappers without a local worker pool; schedule external + // GitHub API calls through the central throttler. The throttler enforces + // WL_GITHUB_CONCURRENCY and rate limits configured in src/github-throttler.ts. + await Promise.all(issueItems.map((it, idx) => upsertMapper(it, idx))); result.skipped = items.length - issueItems.length + skippedUpdates; @@ -554,23 +553,10 @@ export async function upsertIssuesFromWorkItems( } } - // simple concurrent mapper - async function mapWithConcurrency(arr: string[], limit: number, fn: (v: string, i: number) => Promise) { - const results: Promise[] = []; - let i = 0; - async function worker() { - while (true) { - const idx = i++; - if (idx >= arr.length) return; - await fn(arr[idx], idx); - } - } - const workers = Math.min(limit, arr.length); - for (let w = 0; w < workers; w += 1) results.push(worker()); - await Promise.all(results); - } - - await mapWithConcurrency(pairs, concurrency, mapper); + // Process hierarchy pairs concurrently and let the throttler limit GitHub + // requests. Avoid a local worker pool — schedule linking/fetch calls via + // the central throttler inside `mapper`. + await Promise.all(pairs.map((p, idx) => mapper(p, idx))); result.updated += linkedCount; timing.totalMs = Date.now() - startTime; diff --git a/src/github.ts b/src/github.ts index f4f8c07..057d6f8 100644 --- a/src/github.ts +++ b/src/github.ts @@ -934,14 +934,18 @@ export function listGithubIssueComments(config: GithubConfig, issueNumber: numbe export async function listGithubIssueCommentsAsync(config: GithubConfig, issueNumber: number): Promise { const { owner, name } = parseRepoSlug(config.repo); const command = `gh api repos/${owner}/${name}/issues/${issueNumber}/comments --paginate`; - try { - const data = await runGhJsonAsync(command); - if (!data) return []; - const raw = Array.isArray(data) ? data : []; - return raw.map(comment => normalizeGithubIssueComment(comment)); - } catch { - return []; - } + // Schedule network call through central throttler to enforce concurrency + // and rate limits. Callers should not need to schedule this themselves. + return await throttler.schedule(async () => { + try { + const data = await runGhJsonAsync(command); + if (!data) return []; + const raw = Array.isArray(data) ? data : []; + return raw.map(comment => normalizeGithubIssueComment(comment)); + } catch { + return []; + } + }); } export function createGithubIssueComment(config: GithubConfig, issueNumber: number, body: string): GithubIssueComment { @@ -952,10 +956,13 @@ export function createGithubIssueComment(config: GithubConfig, issueNumber: numb } export async function createGithubIssueCommentAsync(config: GithubConfig, issueNumber: number, body: string): Promise { - const { owner, name } = parseRepoSlug(config.repo); - const command = `gh api -X POST repos/${owner}/${name}/issues/${issueNumber}/comments -F body=@-`; - const data = await runGhJsonAsync(command, body); - return normalizeGithubIssueComment(data); + // Ensure comment creation is scheduled through the central throttler. + return await throttler.schedule(async () => { + const { owner, name } = parseRepoSlug(config.repo); + const command = `gh api -X POST repos/${owner}/${name}/issues/${issueNumber}/comments -F body=@-`; + const data = await runGhJsonAsync(command, body); + return normalizeGithubIssueComment(data); + }); } export function updateGithubIssueComment(config: GithubConfig, commentId: number, body: string): GithubIssueComment { @@ -966,10 +973,13 @@ export function updateGithubIssueComment(config: GithubConfig, commentId: number } export async function updateGithubIssueCommentAsync(config: GithubConfig, commentId: number, body: string): Promise { - const { owner, name } = parseRepoSlug(config.repo); - const command = `gh api -X PATCH repos/${owner}/${name}/issues/comments/${commentId} -F body=@-`; - const data = await runGhJsonAsync(command, body); - return normalizeGithubIssueComment(data); + // Ensure comment updates are scheduled through the central throttler. + return await throttler.schedule(async () => { + const { owner, name } = parseRepoSlug(config.repo); + const command = `gh api -X PATCH repos/${owner}/${name}/issues/comments/${commentId} -F body=@-`; + const data = await runGhJsonAsync(command, body); + return normalizeGithubIssueComment(data); + }); } export function getGithubIssueComment(config: GithubConfig, commentId: number): GithubIssueComment { diff --git a/src/types.ts b/src/types.ts index e480779..7166866 100644 --- a/src/types.ts +++ b/src/types.ts @@ -225,3 +225,18 @@ export interface NextWorkItemResult { workItem: WorkItem | null; reason: string; } + +/** + * JSON output shape for the `show` command when --json mode is enabled. + * This keeps the CLI's JSON API stable and explicitly documents the fields + * returned by the endpoint. + */ +export interface ShowJsonOutput { + success: true | false; + workItem?: WorkItem; + comments?: Comment[]; + children?: WorkItem[]; + ancestors?: WorkItem[]; + // Optional error message used when success is false + error?: string; +} diff --git a/tests/cli/__snapshots__/human-show-list-audit-snapshots.test.ts.snap b/tests/cli/__snapshots__/human-show-list-audit-snapshots.test.ts.snap new file mode 100644 index 0000000..5514fff --- /dev/null +++ b/tests/cli/__snapshots__/human-show-list-audit-snapshots.test.ts.snap @@ -0,0 +1,41 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Human snapshots: show and list outputs with audit > renders concise/list and single-item human outputs with and without audit (snapshots) > human-list-with-audit 1`] = ` +"Found 2 work item(s): + + +├── Audited task TEST-1 +│ Status: Open · Stage: Undefined | Priority: medium +│ SortIndex: 0 +│ Risk: — +│ Effort: — +│ Audit: Ready to close: Yes +└── No audit TEST-2 + Status: Open · Stage: Undefined | Priority: medium + SortIndex: 0 + Risk: — + Effort: — + +" +`; + +exports[`Human snapshots: show and list outputs with audit > renders concise/list and single-item human outputs with and without audit (snapshots) > human-show-with-audit 1`] = ` +" +└── Audited task TEST-1 + Status: Open · Stage: Undefined | Priority: medium + SortIndex: 0 + Risk: — + Effort: — + Audit: Ready to close: Yes +" +`; + +exports[`Human snapshots: show and list outputs with audit > renders concise/list and single-item human outputs with and without audit (snapshots) > human-show-without-audit 1`] = ` +" +└── No audit TEST-2 + Status: Open · Stage: Undefined | Priority: medium + SortIndex: 0 + Risk: — + Effort: — +" +`; diff --git a/tests/cli/human-show-list-audit-snapshots.test.ts b/tests/cli/human-show-list-audit-snapshots.test.ts new file mode 100644 index 0000000..826d7bd --- /dev/null +++ b/tests/cli/human-show-list-audit-snapshots.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execAsync, enterTempDir, leaveTempDir, writeConfig, writeInitSemaphore, seedWorkItems, cliPath } from './cli-helpers.js'; + +describe('Human snapshots: show and list outputs with audit', () => { + let state: { tempDir: string; originalCwd: string }; + + beforeEach(() => { + state = enterTempDir(); + writeConfig(state.tempDir, 'Test Project', 'TEST'); + writeInitSemaphore(state.tempDir, '1.0.0'); + }); + + afterEach(() => { + leaveTempDir(state); + }); + + it('renders concise/list and single-item human outputs with and without audit (snapshots)', async () => { + // Seed two work items: one with audit and one without + seedWorkItems(state.tempDir, [ + { + id: 'TEST-1', + title: 'Audited task', + audit: { time: '2026-01-01T00:00:00Z', author: 'alice', text: 'Ready to close: Yes\nExtra details' } + }, + { + id: 'TEST-2', + title: 'No audit' + } + ]); + + // List (human) - compact output used by default + const { stdout: listOut } = await execAsync(`tsx ${cliPath} list`); + expect(listOut).toMatchSnapshot('human-list-with-audit'); + + // Single-item show (human) + const { stdout: showOut } = await execAsync(`tsx ${cliPath} show TEST-1`); + expect(showOut).toMatchSnapshot('human-show-with-audit'); + + // Single-item show for item without audit should not include an Audit block/placeholder + const { stdout: showOut2 } = await execAsync(`tsx ${cliPath} show TEST-2`); + expect(showOut2).toMatchSnapshot('human-show-without-audit'); + }); +}); diff --git a/tests/cli/show-json-audit.test.ts b/tests/cli/show-json-audit.test.ts new file mode 100644 index 0000000..a83b116 --- /dev/null +++ b/tests/cli/show-json-audit.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execAsync, enterTempDir, leaveTempDir, writeConfig, writeInitSemaphore, cliPath } from './cli-helpers.js'; + +describe('show --json audit handling', () => { + let state: { tempDir: string; originalCwd: string }; + + beforeEach(() => { + state = enterTempDir(); + writeConfig(state.tempDir, 'Test Project', 'TEST'); + writeInitSemaphore(state.tempDir, '1.0.0'); + }); + + afterEach(() => { + leaveTempDir(state); + }); + + it('includes structured audit object when audit present and omits when absent', async () => { + // Create an item with audit + const { stdout: created } = await execAsync(`tsx ${cliPath} --json create -t "Audited task" --audit-text "Ready to close: Yes"`); + const createdRes = JSON.parse(created); + expect(createdRes.success).toBe(true); + const id = createdRes.workItem.id; + + const { stdout: shown } = await execAsync(`tsx ${cliPath} --json show ${id}`); + const shownRes = JSON.parse(shown); + expect(shownRes.success).toBe(true); + expect(shownRes.workItem).toBeDefined(); + expect(shownRes.workItem.audit).toBeDefined(); + expect(typeof shownRes.workItem.audit.text).toBe('string'); + expect(shownRes.workItem.audit.text).toBe('Ready to close: Yes'); + expect(shownRes.workItem.audit.author).toBeTruthy(); + expect(shownRes.workItem.audit.time).toMatch(/Z$/); + + // Create an item without audit + const { stdout: created2 } = await execAsync(`tsx ${cliPath} --json create -t "No audit"`); + const createdRes2 = JSON.parse(created2); + expect(createdRes2.success).toBe(true); + const id2 = createdRes2.workItem.id; + + const { stdout: shown2 } = await execAsync(`tsx ${cliPath} --json show ${id2}`); + const shownRes2 = JSON.parse(shown2); + expect(shownRes2.success).toBe(true); + // When audit is absent, the JSON output must omit the `audit` key entirely. + expect(shownRes2.workItem.audit).toBeUndefined(); + }); +}); diff --git a/tests/cli/throttler-github-sync.test.ts b/tests/cli/throttler-github-sync.test.ts new file mode 100644 index 0000000..a05761d --- /dev/null +++ b/tests/cli/throttler-github-sync.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import throttler from '../../src/github-throttler.js'; +import * as githubSync from '../../src/github-sync.js'; +import * as githubHelpers from '../../src/github.js'; + +describe('github-sync throttler integration (unit)', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it('uses throttler.schedule for GitHub issue create/update and comment operations', async () => { + // Spy on throttler.schedule + const scheduleSpy = vi.spyOn(throttler, 'schedule'); + + // Prepare minimal items and comments to exercise upsert path + const items = [ + { + id: 'WI-1', + title: 'T1', + description: 'desc', + status: 'open', + priority: 'medium', + sortIndex: 0, + parentId: null, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + tags: [], + assignee: '', + }, + ]; + const comments = []; + + // Stub out github API helpers (they are exported from src/github.js). + // Each stub should still call the central throttler so we can assert + // `throttler.schedule` is used by the flow. + vi.spyOn(githubHelpers as any, 'createGithubIssueAsync').mockImplementation(() => + throttler.schedule(async () => ({ number: 123, id: 99, updatedAt: new Date().toISOString() })) + ); + vi.spyOn(githubHelpers as any, 'updateGithubIssueAsync').mockImplementation(() => + throttler.schedule(async () => ({ number: 123, id: 99, updatedAt: new Date().toISOString() })) + ); + vi.spyOn(githubHelpers as any, 'listGithubIssueCommentsAsync').mockImplementation(() => + throttler.schedule(async () => []) + ); + vi.spyOn(githubHelpers as any, 'createGithubIssueCommentAsync').mockImplementation(() => + throttler.schedule(async () => ({ id: 1, updatedAt: new Date().toISOString() })) + ); + vi.spyOn(githubHelpers as any, 'updateGithubIssueCommentAsync').mockImplementation(() => + throttler.schedule(async () => ({ id: 1, updatedAt: new Date().toISOString() })) + ); + + const config = { repo: 'owner/repo', labelPrefix: 'wl:' } as any; + + await githubSync.upsertIssuesFromWorkItems(items as any, comments as any, config); + + // Assert that throttle.schedule was used at least once (multiple callsites exist) + expect(scheduleSpy).toHaveBeenCalled(); + }); +}); diff --git a/tests/integration/audit-roundtrip.test.ts b/tests/integration/audit-roundtrip.test.ts new file mode 100644 index 0000000..c99b04a --- /dev/null +++ b/tests/integration/audit-roundtrip.test.ts @@ -0,0 +1,47 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { execAsync, enterTempDir, leaveTempDir, writeConfig, writeInitSemaphore, cliPath } from '../cli/cli-helpers.js'; + +describe('integration: audit write -> read roundtrip', () => { + let state: { tempDir: string; originalCwd: string }; + + beforeEach(() => { + state = enterTempDir(); + writeConfig(state.tempDir, 'Test Project', 'TEST'); + writeInitSemaphore(state.tempDir, '1.0.0'); + }); + + afterEach(() => { + leaveTempDir(state); + }); + + it('persists audit via create/update and is returned by show --json', async () => { + // Create with audit using CLI create (which uses buildAuditEntry and redacts before persist) + const { stdout: created } = await execAsync(`tsx ${cliPath} --json create -t "Roundtrip audit" --audit-text "Confirm by alice@example.com"`); + const createdRes = JSON.parse(created); + expect(createdRes.success).toBe(true); + const id = createdRes.workItem.id; + + // show --json should include audit object + const { stdout: shown } = await execAsync(`tsx ${cliPath} --json show ${id}`); + const shownRes = JSON.parse(shown); + expect(shownRes.success).toBe(true); + expect(shownRes.workItem).toBeDefined(); + expect(shownRes.workItem.audit).toBeDefined(); + // Audit text should be redacted by buildAuditEntry before persistence + // redactAuditText replaces local part with firstChar + '***' and keeps domain + expect(shownRes.workItem.audit.text).toContain('a***@example.com'); + expect(shownRes.workItem.audit.author).toBeTruthy(); + expect(shownRes.workItem.audit.time).toMatch(/Z$/); + + // Now update the item with a new audit text and verify it overwrote + const { stdout: updated } = await execAsync(`tsx ${cliPath} --json update ${id} --audit-text "Updated by bob@domain.org"`); + const updatedRes = JSON.parse(updated); + expect(updatedRes.success).toBe(true); + + const { stdout: shown2 } = await execAsync(`tsx ${cliPath} --json show ${id}`); + const shownRes2 = JSON.parse(shown2); + expect(shownRes2.success).toBe(true); + expect(shownRes2.workItem.audit).toBeDefined(); + expect(shownRes2.workItem.audit.text).toContain('b***@domain.org'); + }); +}); diff --git a/tests/unit/__snapshots__/human-audit-format.test.ts.snap b/tests/unit/__snapshots__/human-audit-format.test.ts.snap new file mode 100644 index 0000000..5c2c414 --- /dev/null +++ b/tests/unit/__snapshots__/human-audit-format.test.ts.snap @@ -0,0 +1,91 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`humanFormatWorkItem audit formatting > renders concise/normal/full outputs with audit present (snapshots) > concise-with-audit 1`] = ` +"Audit formatting test TEST-1 +Status: Open · Stage: In Progress | Priority: medium +SortIndex: 100 +Risk: — +Effort: — +Assignee: alice +Audit: Ready to close: Yes" +`; + +exports[`humanFormatWorkItem audit formatting > renders concise/normal/full outputs with audit present (snapshots) > full-with-audit 1`] = ` +"# Audit formatting test + +ID : TEST-1 +Status : Open · Stage: In Progress | Priority: medium +Type : task +SortIndex: 100 +Risk : — +Effort : — +Assignee : alice + +## Description + +A test item for audit formatting + +## Stage + +in_progress + +## Audit + +Time: 2026-03-26T20:29:00Z +Author: alice + +Ready to close: Yes +Extra details" +`; + +exports[`humanFormatWorkItem audit formatting > renders concise/normal/full outputs with audit present (snapshots) > normal-with-audit 1`] = ` +"ID: TEST-1 +Title: Audit formatting test +Status: Open · Stage: In Progress | Priority: medium +SortIndex: 100 +Risk: — +Effort: — +Assignee: alice +Audit: Ready to close: Yes +Description: A test item for audit formatting" +`; + +exports[`humanFormatWorkItem audit formatting > renders concise/normal/full outputs without audit (snapshots) > concise-without-audit 1`] = ` +"Audit formatting test TEST-1 +Status: Open · Stage: In Progress | Priority: medium +SortIndex: 100 +Risk: — +Effort: — +Assignee: alice" +`; + +exports[`humanFormatWorkItem audit formatting > renders concise/normal/full outputs without audit (snapshots) > full-without-audit 1`] = ` +"# Audit formatting test + +ID : TEST-1 +Status : Open · Stage: In Progress | Priority: medium +Type : task +SortIndex: 100 +Risk : — +Effort : — +Assignee : alice + +## Description + +A test item for audit formatting + +## Stage + +in_progress" +`; + +exports[`humanFormatWorkItem audit formatting > renders concise/normal/full outputs without audit (snapshots) > normal-without-audit 1`] = ` +"ID: TEST-1 +Title: Audit formatting test +Status: Open · Stage: In Progress | Priority: medium +SortIndex: 100 +Risk: — +Effort: — +Assignee: alice +Description: A test item for audit formatting" +`; diff --git a/tests/unit/human-audit-format.test.ts b/tests/unit/human-audit-format.test.ts new file mode 100644 index 0000000..cd7e2e6 --- /dev/null +++ b/tests/unit/human-audit-format.test.ts @@ -0,0 +1,49 @@ +import { describe, it, expect } from 'vitest'; +import { humanFormatWorkItem } from '../../src/commands/helpers.js'; + +// Minimal WorkItem-like shape used for formatting tests +const baseItem: any = { + id: 'TEST-1', + title: 'Audit formatting test', + status: 'open', + priority: 'medium', + sortIndex: 100, + stage: 'in_progress', + createdAt: '2026-03-26T00:00:00Z', + updatedAt: '2026-03-26T00:00:00Z', + tags: [], + assignee: 'alice', + description: 'A test item for audit formatting', + parentId: undefined, + risk: undefined, + effort: undefined, + issueType: 'task' +}; + +describe('humanFormatWorkItem audit formatting', () => { + it('renders concise/normal/full outputs with audit present (snapshots)', () => { + const item = Object.assign({}, baseItem, { + audit: { time: '2026-03-26T20:29:00Z', author: 'alice', text: 'Ready to close: Yes\nExtra details' } + }); + + const concise = humanFormatWorkItem(item, null, 'concise'); + const normal = humanFormatWorkItem(item, null, 'normal'); + const full = humanFormatWorkItem(item, null, 'full'); + + expect(concise).toMatchSnapshot('concise-with-audit'); + expect(normal).toMatchSnapshot('normal-with-audit'); + expect(full).toMatchSnapshot('full-with-audit'); + }); + + it('renders concise/normal/full outputs without audit (snapshots)', () => { + const item = Object.assign({}, baseItem); + + const concise = humanFormatWorkItem(item, null, 'concise'); + const normal = humanFormatWorkItem(item, null, 'normal'); + const full = humanFormatWorkItem(item, null, 'full'); + + expect(concise).toMatchSnapshot('concise-without-audit'); + expect(normal).toMatchSnapshot('normal-without-audit'); + expect(full).toMatchSnapshot('full-without-audit'); + }); +});