From 526319bf9c69f075a10735c3ffd0d0789497a81a Mon Sep 17 00:00:00 2001 From: Stefan Victora Date: Fri, 12 Jun 2026 23:06:56 +0200 Subject: [PATCH 01/12] Clear published review.md and don't record ouput publish state --- CHANGELOG.md | 6 + src/cli/commands/publish.test.ts | 127 ++++++++++++++++++ src/cli/commands/publish.ts | 23 ++-- src/cli/commands/status.ts | 2 +- src/core/types.ts | 6 +- src/orchestration/orchestrator.test.ts | 12 +- .../local/local-git-provider.test.ts | 3 +- src/workspace/workspace-manager.test.ts | 56 ++++---- src/workspace/workspace-manager.ts | 45 ++++--- 9 files changed, 212 insertions(+), 68 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fd249b..1f584ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). +## [Unreleased] + +### Fixed + +- Fixed `revpack publish` leaving `review.md` populated after publishing, which could republish the same review note during later incremental reviews. + ## [0.4.0] - 2026-06-07 ### Added diff --git a/src/cli/commands/publish.test.ts b/src/cli/commands/publish.test.ts index 4941e46..5c9f218 100644 --- a/src/cli/commands/publish.test.ts +++ b/src/cli/commands/publish.test.ts @@ -29,6 +29,59 @@ describe('publish command internals', () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); + async function writeBundleState(provider = 'gitlab'): Promise { + await fs.mkdir(path.join(tmpDir, '.revpack'), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, '.revpack', 'bundle.json'), + JSON.stringify( + { + target: { provider, diffRefs: { headSha: 'head-sha' } }, + outputs: { + review: { path: '.revpack/outputs/review.md' }, + summary: { path: '.revpack/outputs/summary.md' }, + }, + publishedActions: [], + }, + null, + 2, + ), + 'utf-8', + ); + } + + async function writeValidFindingBundle(): Promise { + await fs.mkdir(path.join(tmpDir, '.revpack', 'diffs'), { recursive: true }); + await fs.mkdir(path.join(tmpDir, '.revpack', 'outputs'), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, '.revpack', 'diffs', 'latest.patch'), + [ + 'diff --git a/src/app.ts b/src/app.ts', + 'index 1111111..2222222 100644', + '--- a/src/app.ts', + '+++ b/src/app.ts', + '@@ -1 +1,2 @@', + ' const value = read();', + '+audit(value);', + '', + ].join('\n'), + 'utf-8', + ); + await fs.writeFile( + path.join(tmpDir, '.revpack', 'outputs', 'new-findings.json'), + JSON.stringify([ + { + oldPath: 'src/app.ts', + newPath: 'src/app.ts', + newLine: 2, + body: 'Audit call can throw unexpectedly.', + severity: 'medium', + category: 'correctness', + }, + ]), + 'utf-8', + ); + } + it('matches T-NNN reply refs case-insensitively', () => { const entries = [{ threadId: 'T-001', body: 'reply' }]; @@ -106,6 +159,80 @@ describe('publish command internals', () => { expect(createOrchestrator).not.toHaveBeenCalled(); }); + it('clears the default review note after publishing it', async () => { + await fs.mkdir(path.join(tmpDir, '.revpack', 'outputs'), { recursive: true }); + await writeBundleState(); + const reviewPath = path.join(tmpDir, '.revpack', 'outputs', 'review.md'); + await fs.writeFile(reviewPath, 'Review body', 'utf-8'); + + const orchestrator = { + publishReview: vi.fn().mockResolvedValue({ created: true, noteId: 'note-1' }), + }; + vi.mocked(createOrchestrator).mockResolvedValue(orchestrator as never); + + await expect(__testing.publishReviewCmd({})).resolves.toBe(1); + + await expect(fs.readFile(reviewPath, 'utf-8')).resolves.toBe(''); + const bundleState = JSON.parse(await fs.readFile(path.join(tmpDir, '.revpack', 'bundle.json'), 'utf-8')); + expect(bundleState.outputs.review.lastPublishedHash).toBeUndefined(); + expect(bundleState.outputs.review.providerNoteId).toBeUndefined(); + }); + + it('clears the default review note even when bundle state is unavailable', async () => { + await fs.mkdir(path.join(tmpDir, '.revpack', 'outputs'), { recursive: true }); + const reviewPath = path.join(tmpDir, '.revpack', 'outputs', 'review.md'); + await fs.writeFile(reviewPath, 'Review body', 'utf-8'); + + const orchestrator = { + publishReview: vi.fn().mockResolvedValue({ created: true, noteId: 'note-1' }), + }; + vi.mocked(createOrchestrator).mockResolvedValue(orchestrator as never); + + await expect(__testing.publishReviewCmd({})).resolves.toBe(1); + + await expect(fs.readFile(reviewPath, 'utf-8')).resolves.toBe(''); + }); + + it('does not clear the default review note when publishing from a custom file', async () => { + await fs.mkdir(path.join(tmpDir, '.revpack', 'outputs'), { recursive: true }); + await writeBundleState(); + const reviewPath = path.join(tmpDir, '.revpack', 'outputs', 'review.md'); + const customPath = path.join(tmpDir, 'custom-review.md'); + await fs.writeFile(reviewPath, 'Pending default review', 'utf-8'); + await fs.writeFile(customPath, 'Custom review body', 'utf-8'); + + const orchestrator = { + publishReview: vi.fn().mockResolvedValue({ created: true, noteId: 'note-1' }), + }; + vi.mocked(createOrchestrator).mockResolvedValue(orchestrator as never); + + await expect(__testing.publishReviewCmd({ from: customPath })).resolves.toBe(1); + + await expect(fs.readFile(reviewPath, 'utf-8')).resolves.toBe('Pending default review'); + await expect(fs.readFile(customPath, 'utf-8')).resolves.toBe('Custom review body'); + }); + + it('clears the default review note after including it in a GitHub review batch', async () => { + await writeBundleState('github'); + await writeValidFindingBundle(); + const reviewPath = path.join(tmpDir, '.revpack', 'outputs', 'review.md'); + await fs.writeFile(reviewPath, 'Batch review body', 'utf-8'); + + const orchestrator = { + publishReviewBatch: vi.fn().mockResolvedValue({ created: true }), + }; + vi.mocked(createOrchestrator).mockResolvedValue(orchestrator as never); + + await expect(__testing.publishFindingsAndReviewBatch('Batch review body')).resolves.toBe(1); + + await expect(fs.readFile(reviewPath, 'utf-8')).resolves.toBe(''); + await expect(fs.readFile(path.join(tmpDir, '.revpack', 'outputs', 'new-findings.json'), 'utf-8')).resolves.toBe( + '[]', + ); + const bundleState = JSON.parse(await fs.readFile(path.join(tmpDir, '.revpack', 'bundle.json'), 'utf-8')); + expect(bundleState.outputs.review.lastPublishedHash).toBeUndefined(); + }); + it('uses summary.md as the default description source', async () => { await fs.mkdir(path.join(tmpDir, '.revpack', 'outputs'), { recursive: true }); await fs.writeFile(path.join(tmpDir, '.revpack', 'outputs', 'summary.md'), 'Generated summary', 'utf-8'); diff --git a/src/cli/commands/publish.ts b/src/cli/commands/publish.ts index 0b855ca..3c3e5a7 100644 --- a/src/cli/commands/publish.ts +++ b/src/cli/commands/publish.ts @@ -260,6 +260,12 @@ async function trackFindingActions(ws: WorkspaceManager, findings: NewFinding[], } } +async function clearDefaultReviewOutput(clearDefaultOutput = true): Promise { + if (clearDefaultOutput) { + await fs.writeFile(workspacePath(DEFAULT_REVIEW_FILE), '', 'utf-8'); + } +} + async function publishFindings(opts: { from?: string; dryRun?: boolean; noRefresh?: boolean }): Promise { const filePath = opts.from ?? DEFAULT_FINDINGS_FILE; const { findings, resolvedPath } = await loadAndValidateFindings(filePath); @@ -349,7 +355,7 @@ async function publishDescription(opts: { from?: string; replace?: boolean; repo /** * GitHub-specific: load, validate, and submit findings + review.md as a single PR review batch. - * Updates the stored review state. Returns the number of findings published. + * Clears successfully published outputs. Returns the number of findings published. */ async function publishFindingsAndReviewBatch(reviewContent: string): Promise { const { findings, resolvedPath } = await loadAndValidateFindings(DEFAULT_FINDINGS_FILE); @@ -372,11 +378,7 @@ async function publishFindingsAndReviewBatch(reviewContent: string): Promise { // ─── Output publish state ────────────────────────────── describe('output publish state', () => { - it('tracks pending, published, modified, and empty output states', async () => { + it('tracks summary publish hashes and review note pending state separately', async () => { const orchestrator = new ReviewOrchestrator({ provider: mockProvider, workingDir: tmpDir }); await orchestrator.prepare('!42', 'group/project'); const ws = new WorkspaceManager(tmpDir); @@ -1368,7 +1368,7 @@ describe('ReviewOrchestrator', () => { const reviewPath = path.join(tmpDir, '.revpack', 'outputs', 'review.md'); expect(await ws.getOutputState('summary')).toBe('empty'); - expect(await ws.getOutputState('review')).toBe('empty'); + expect(await ws.getPendingOutputState('review')).toBe('empty'); const summary = '# Summary\nThis is a test'; await fs.writeFile(summaryPath, summary, 'utf-8'); @@ -1382,13 +1382,7 @@ describe('ReviewOrchestrator', () => { const review = '## Notes\nReview notes'; await fs.writeFile(reviewPath, review, 'utf-8'); - expect(await ws.getOutputState('review')).toBe('pending'); - - await ws.updateOutputPublishState('review', computeContentHash(review), 'bbb'); - expect(await ws.getOutputState('review')).toBe('published'); - - await fs.writeFile(reviewPath, '## Notes\nEdited', 'utf-8'); - expect(await ws.getOutputState('review')).toBe('modified since publish'); + expect(await ws.getPendingOutputState('review')).toBe('pending'); }); it('prefills summary from published description marker and marks it as published', async () => { diff --git a/src/providers/local/local-git-provider.test.ts b/src/providers/local/local-git-provider.test.ts index b428a86..987a7e6 100644 --- a/src/providers/local/local-git-provider.test.ts +++ b/src/providers/local/local-git-provider.test.ts @@ -900,7 +900,6 @@ describe('LocalGitProvider orchestrator boundary', () => { }, review: { path: '.revpack/outputs/review.md', - lastPublishedHash: 'sha256:old-review', }, }, publishedActions: [ @@ -945,6 +944,6 @@ describe('LocalGitProvider orchestrator boundary', () => { expect(result.bundle.target.diffRefs.headSha).toBe('head-sha'); expect(result.bundleState.publishedActions).toEqual([]); expect(result.bundleState.outputs.summary.lastPublishedHash).toBeUndefined(); - expect(result.bundleState.outputs.review.lastPublishedHash).toBeUndefined(); + expect(result.bundleState.outputs.review).toEqual({ path: '.revpack/outputs/review.md' }); }); }); diff --git a/src/workspace/workspace-manager.test.ts b/src/workspace/workspace-manager.test.ts index 2ca5433..6a876a0 100644 --- a/src/workspace/workspace-manager.test.ts +++ b/src/workspace/workspace-manager.test.ts @@ -1736,43 +1736,53 @@ describe('WorkspaceManager', () => { }); }); - describe('updateOutputPublishState', () => { - it('returns false when no bundle state exists', async () => { - const result = await manager.updateOutputPublishState('summary', 'hash', 'sha'); - expect(result).toBe(false); + describe('getPendingOutputState', () => { + it('returns empty when no bundle state exists', async () => { + const state = await manager.getPendingOutputState('review'); + expect(state).toBe('empty'); }); - it('stores hash, timestamp, and targetHeadSha', async () => { + it('returns empty when output file does not exist', async () => { await createBundleWithState(manager); - const result = await manager.updateOutputPublishState('review', 'abc123', 'head-sha'); - expect(result).toBe(true); - - const state = await manager.loadBundleState(); - expect(state!.outputs.review.lastPublishedHash).toBe('abc123'); - expect(state!.outputs.review.lastPublishedTargetHeadSha).toBe('head-sha'); - expect(state!.outputs.review.lastPublishedAt).toBeTruthy(); + const state = await manager.getPendingOutputState('review'); + expect(state).toBe('empty'); }); - it('stores providerNoteId when provided', async () => { + it('returns empty when output file is whitespace only', async () => { await createBundleWithState(manager); - await manager.updateOutputPublishState('summary', 'hash', 'sha', 'note-42'); - const state = await manager.loadBundleState(); - expect(state!.outputs.summary.providerNoteId).toBe('note-42'); + await manager.writeOutput('review.md', ' \n '); + const state = await manager.getPendingOutputState('review'); + expect(state).toBe('empty'); }); - it('does not set providerNoteId when not provided', async () => { + it('returns pending when review note content exists regardless of legacy publish hash', async () => { await createBundleWithState(manager); - await manager.updateOutputPublishState('summary', 'hash', 'sha'); const state = await manager.loadBundleState(); - expect(state!.outputs.summary.providerNoteId).toBeUndefined(); + (state!.outputs.review as { lastPublishedHash?: string }).lastPublishedHash = + computeContentHash('## Notes\nReview notes'); + await manager.saveBundleState(state!); + + await manager.writeOutput('review.md', '## Notes\nReview notes'); + + await expect(manager.getPendingOutputState('review')).resolves.toBe('pending'); }); + }); - it('preserves existing providerNoteId when not provided in subsequent call', async () => { + describe('updateOutputPublishState', () => { + it('returns false when no bundle state exists', async () => { + const result = await manager.updateOutputPublishState('summary', 'hash', 'sha'); + expect(result).toBe(false); + }); + + it('stores hash, timestamp, and targetHeadSha', async () => { await createBundleWithState(manager); - await manager.updateOutputPublishState('summary', 'h1', 'sha1', 'note-42'); - await manager.updateOutputPublishState('summary', 'h2', 'sha2'); + const result = await manager.updateOutputPublishState('summary', 'abc123', 'head-sha'); + expect(result).toBe(true); + const state = await manager.loadBundleState(); - expect(state!.outputs.summary.providerNoteId).toBe('note-42'); + expect(state!.outputs.summary.lastPublishedHash).toBe('abc123'); + expect(state!.outputs.summary.lastPublishedTargetHeadSha).toBe('head-sha'); + expect(state!.outputs.summary.lastPublishedAt).toBeTruthy(); }); }); diff --git a/src/workspace/workspace-manager.ts b/src/workspace/workspace-manager.ts index 8a2ecec..742744d 100644 --- a/src/workspace/workspace-manager.ts +++ b/src/workspace/workspace-manager.ts @@ -46,7 +46,6 @@ const OUTPUT_DEFAULTS: readonly [filename: string, content: string][] = [ const OUTPUT_STATE_KEYS = { 'summary.md': 'summary', - 'review.md': 'review', } as const; interface InstructionRoute { @@ -365,9 +364,9 @@ export class WorkspaceManager { digest: prepareSummary.current.threadsDigest, items: threadItems, }, - outputs: previousOutputs ?? { - summary: { path: '.revpack/outputs/summary.md' }, - review: { path: '.revpack/outputs/review.md' }, + outputs: { + summary: previousOutputs?.summary ?? { path: '.revpack/outputs/summary.md' }, + review: { path: previousOutputs?.review.path ?? '.revpack/outputs/review.md' }, }, publishedActions: previousActions ?? [], paths: { @@ -400,23 +399,15 @@ export class WorkspaceManager { } /** - * Update the publish hash for an output file in bundle.json. + * Update the publish hash for a hash-tracked output file in bundle.json. */ - async updateOutputPublishState( - outputKey: 'summary' | 'review', - hash: string, - targetHeadSha: string, - providerNoteId?: string, - ): Promise { + async updateOutputPublishState(outputKey: 'summary', hash: string, targetHeadSha: string): Promise { const state = await this.loadBundleState(); if (!state) return false; const entry = state.outputs[outputKey]; entry.lastPublishedHash = hash; entry.lastPublishedAt = new Date().toISOString(); entry.lastPublishedTargetHeadSha = targetHeadSha; - if (providerNoteId !== undefined) { - entry.providerNoteId = providerNoteId; - } await this.saveBundleState(state); return true; } @@ -424,7 +415,7 @@ export class WorkspaceManager { /** * Compute the current state of an output file relative to its publish hash. */ - async getOutputState(outputKey: 'summary' | 'review'): Promise { + async getOutputState(outputKey: 'summary'): Promise { const state = await this.loadBundleState(); if (!state) return 'empty'; const entry = state.outputs[outputKey]; @@ -441,6 +432,22 @@ export class WorkspaceManager { return currentHash === entry.lastPublishedHash ? 'published' : 'modified since publish'; } + /** + * Compute whether a queue-style output file currently has content to publish. + */ + async getPendingOutputState(outputKey: 'review'): Promise> { + const state = await this.loadBundleState(); + if (!state) return 'empty'; + const entry = state.outputs[outputKey]; + const filePath = path.resolve(this.workingDir, entry.path); + try { + const content = await fs.readFile(filePath, 'utf-8'); + return content.trim() ? 'pending' : 'empty'; + } catch { + return 'empty'; + } + } + /** * Remove the entire bundle directory (.revpack/). */ @@ -468,10 +475,10 @@ export class WorkspaceManager { } /** - * Prefill an output file with content from the last published review note, - * but only if the file is currently empty. This lets agents see and update - * existing content in incremental mode without triggering a "changed" state - * on the next status check (the publish hash stays matched). + * Prefill an output file with published remote content, but only if the file + * is currently empty. This lets agents see and update existing content in + * incremental mode without triggering a "changed" state on the next status + * check (the publish hash stays matched). */ async prefillOutputIfEmpty(filename: string, content: string): Promise { const filePath = path.join(this.baseDir, 'outputs', filename); From 098a51d3fff57cef20c81db56bc0c52e7eed1592 Mon Sep 17 00:00:00 2001 From: Stefan Victora Date: Fri, 12 Jun 2026 23:16:28 +0200 Subject: [PATCH 02/12] Fix update of already matching description on later publish calls --- CHANGELOG.md | 1 + src/cli/commands/publish.test.ts | 43 ++++++++++++++++++++++++++++++-- src/cli/commands/publish.ts | 12 ++++++++- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1f584ba..88e16bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed - Fixed `revpack publish` leaving `review.md` populated after publishing, which could republish the same review note during later incremental reviews. +- Fixed `revpack publish all` updating the PR/MR description summary again when `revpack status` already reported the summary as published. ## [0.4.0] - 2026-06-07 diff --git a/src/cli/commands/publish.test.ts b/src/cli/commands/publish.test.ts index 5c9f218..47575c5 100644 --- a/src/cli/commands/publish.test.ts +++ b/src/cli/commands/publish.test.ts @@ -4,6 +4,7 @@ import * as os from 'node:os'; import * as path from 'node:path'; import { __testing } from './publish.js'; import { createOrchestrator, getRepoFromGit } from '../helpers.js'; +import { computeContentHash } from '../../workspace/thread-digest.js'; vi.mock('../helpers.js', () => ({ createOrchestrator: vi.fn(), @@ -29,7 +30,7 @@ describe('publish command internals', () => { await fs.rm(tmpDir, { recursive: true, force: true }); }); - async function writeBundleState(provider = 'gitlab'): Promise { + async function writeBundleState(provider = 'gitlab', options?: { summaryHash?: string }): Promise { await fs.mkdir(path.join(tmpDir, '.revpack'), { recursive: true }); await fs.writeFile( path.join(tmpDir, '.revpack', 'bundle.json'), @@ -38,7 +39,10 @@ describe('publish command internals', () => { target: { provider, diffRefs: { headSha: 'head-sha' } }, outputs: { review: { path: '.revpack/outputs/review.md' }, - summary: { path: '.revpack/outputs/summary.md' }, + summary: { + path: '.revpack/outputs/summary.md', + ...(options?.summaryHash ? { lastPublishedHash: options.summaryHash } : {}), + }, }, publishedActions: [], }, @@ -253,6 +257,41 @@ describe('publish command internals', () => { ); }); + it('skips the default summary when it is already published', async () => { + const summary = 'Generated summary'; + await writeBundleState('gitlab', { summaryHash: computeContentHash(summary) }); + await fs.mkdir(path.join(tmpDir, '.revpack', 'outputs'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.revpack', 'outputs', 'summary.md'), summary, 'utf-8'); + + await expect(__testing.publishDescription({})).resolves.toBe(0); + + expect(createOrchestrator).not.toHaveBeenCalled(); + expect(console.log).toHaveBeenCalledWith(expect.stringContaining('summary already published')); + }); + + it('publishes a custom description file even when the default summary is already published', async () => { + const summary = 'Generated summary'; + await writeBundleState('gitlab', { summaryHash: computeContentHash(summary) }); + await fs.mkdir(path.join(tmpDir, '.revpack', 'outputs'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.revpack', 'outputs', 'summary.md'), summary, 'utf-8'); + const customPath = path.join(tmpDir, 'custom-summary.md'); + await fs.writeFile(customPath, 'Custom summary', 'utf-8'); + + const orchestrator = { + open: vi.fn().mockResolvedValue({ description: 'Existing description' }), + updateDescription: vi.fn().mockResolvedValue(undefined), + }; + vi.mocked(createOrchestrator).mockResolvedValue(orchestrator as never); + + await expect(__testing.publishDescription({ from: customPath })).resolves.toBe(1); + + expect(orchestrator.updateDescription).toHaveBeenCalledWith( + undefined, + expect.stringContaining('Custom summary'), + 'group/project', + ); + }); + it('auto-refreshes after publish without mutating unrelated pending outputs', async () => { const orchestrator = { prepare: vi.fn().mockResolvedValue(undefined), diff --git a/src/cli/commands/publish.ts b/src/cli/commands/publish.ts index 3c3e5a7..8c99926 100644 --- a/src/cli/commands/publish.ts +++ b/src/cli/commands/publish.ts @@ -313,6 +313,7 @@ async function publishFindings(opts: { from?: string; dryRun?: boolean; noRefres async function publishDescription(opts: { from?: string; replace?: boolean; repo?: string }): Promise { let content: string; let usedSummary = false; + let ws: WorkspaceManager | undefined; if (opts.from) { content = await fs.readFile(workspacePath(opts.from), 'utf-8'); } else { @@ -325,6 +326,15 @@ async function publishDescription(opts: { from?: string; replace?: boolean; repo } requirePublishableContent(content, usedSummary ? DEFAULT_SUMMARY_FILE : (opts.from ?? 'description content')); + if (usedSummary) { + ws = new WorkspaceManager(process.cwd()); + const summaryState = await ws.getOutputState('summary'); + if (summaryState === 'published') { + console.log(chalk.dim(' (summary already published)')); + return 0; + } + } + const orchestrator = await createOrchestrator(); const defaultRepo = opts.repo ?? (await getRepoFromGit()); @@ -341,7 +351,7 @@ async function publishDescription(opts: { from?: string; replace?: boolean; repo // Store publish hash for summary tracking if (usedSummary) { - const ws = new WorkspaceManager(process.cwd()); + ws ??= new WorkspaceManager(process.cwd()); const bundleState = await ws.loadBundleState(); if (bundleState) { const summaryContent = await fs.readFile(workspacePath(DEFAULT_SUMMARY_FILE), 'utf-8'); From 6ce8866612f80241dbb762722d89543133693c65 Mon Sep 17 00:00:00 2001 From: Stefan Victora Date: Fri, 12 Jun 2026 23:36:07 +0200 Subject: [PATCH 03/12] Fix findings --- CHANGELOG.md | 2 +- src/cli/commands/publish.test.ts | 36 ++++++++++++++++++++++++++++++++ src/cli/commands/publish.ts | 6 ++++-- 3 files changed, 41 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 88e16bc..48151f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed -- Fixed `revpack publish` leaving `review.md` populated after publishing, which could republish the same review note during later incremental reviews. +- Fixed `revpack publish review` leaving `review.md` populated after publishing, which could republish the same review note during later incremental reviews. - Fixed `revpack publish all` updating the PR/MR description summary again when `revpack status` already reported the summary as published. ## [0.4.0] - 2026-06-07 diff --git a/src/cli/commands/publish.test.ts b/src/cli/commands/publish.test.ts index 47575c5..37c572a 100644 --- a/src/cli/commands/publish.test.ts +++ b/src/cli/commands/publish.test.ts @@ -197,6 +197,24 @@ describe('publish command internals', () => { await expect(fs.readFile(reviewPath, 'utf-8')).resolves.toBe(''); }); + it('keeps the default review note when no review note is created', async () => { + await fs.mkdir(path.join(tmpDir, '.revpack', 'outputs'), { recursive: true }); + await writeBundleState(); + const reviewPath = path.join(tmpDir, '.revpack', 'outputs', 'review.md'); + await fs.writeFile(reviewPath, 'Review body', 'utf-8'); + + const orchestrator = { + publishReview: vi.fn().mockResolvedValue({ created: false }), + }; + vi.mocked(createOrchestrator).mockResolvedValue(orchestrator as never); + + await expect(__testing.publishReviewCmd({})).resolves.toBe(0); + + await expect(fs.readFile(reviewPath, 'utf-8')).resolves.toBe('Review body'); + const bundleState = JSON.parse(await fs.readFile(path.join(tmpDir, '.revpack', 'bundle.json'), 'utf-8')); + expect(bundleState.outputs.review.lastPublishedHash).toBeUndefined(); + }); + it('does not clear the default review note when publishing from a custom file', async () => { await fs.mkdir(path.join(tmpDir, '.revpack', 'outputs'), { recursive: true }); await writeBundleState(); @@ -269,6 +287,24 @@ describe('publish command internals', () => { expect(console.log).toHaveBeenCalledWith(expect.stringContaining('summary already published')); }); + it('replaces the description from the default summary even when it is already published', async () => { + const summary = 'Generated summary'; + await writeBundleState('gitlab', { summaryHash: computeContentHash(summary) }); + await fs.mkdir(path.join(tmpDir, '.revpack', 'outputs'), { recursive: true }); + await fs.writeFile(path.join(tmpDir, '.revpack', 'outputs', 'summary.md'), summary, 'utf-8'); + + const orchestrator = { + open: vi.fn(), + updateDescription: vi.fn().mockResolvedValue(undefined), + }; + vi.mocked(createOrchestrator).mockResolvedValue(orchestrator as never); + + await expect(__testing.publishDescription({ replace: true })).resolves.toBe(1); + + expect(orchestrator.open).not.toHaveBeenCalled(); + expect(orchestrator.updateDescription).toHaveBeenCalledWith(undefined, summary, 'group/project'); + }); + it('publishes a custom description file even when the default summary is already published', async () => { const summary = 'Generated summary'; await writeBundleState('gitlab', { summaryHash: computeContentHash(summary) }); diff --git a/src/cli/commands/publish.ts b/src/cli/commands/publish.ts index 8c99926..2196e38 100644 --- a/src/cli/commands/publish.ts +++ b/src/cli/commands/publish.ts @@ -326,7 +326,7 @@ async function publishDescription(opts: { from?: string; replace?: boolean; repo } requirePublishableContent(content, usedSummary ? DEFAULT_SUMMARY_FILE : (opts.from ?? 'description content')); - if (usedSummary) { + if (usedSummary && !opts.replace) { ws = new WorkspaceManager(process.cwd()); const summaryState = await ws.getOutputState('summary'); if (summaryState === 'published') { @@ -419,7 +419,9 @@ async function publishReviewCmd(opts: { from?: string; repo?: string; allowEmpty } const isDefaultReviewFile = workspacePath(filePath) === workspacePath(DEFAULT_REVIEW_FILE); - await clearDefaultReviewOutput(isDefaultReviewFile); + if (result.created && isDefaultReviewFile) { + await clearDefaultReviewOutput(); + } return result.created ? 1 : 0; } From cba68d4b23f63748febfc95f1f1463284f73006c Mon Sep 17 00:00:00 2001 From: Stefan Victora Date: Fri, 12 Jun 2026 23:43:08 +0200 Subject: [PATCH 04/12] Switch to explicit code highlight for CLI commands This allows us to dim some commands, if they are not primary --- src/cli/commands/checkout.ts | 2 +- src/cli/commands/clean.ts | 7 +++---- src/cli/commands/prepare.ts | 2 +- src/cli/commands/setup.ts | 14 +++++++------- src/cli/commands/status.ts | 4 ++-- src/cli/output.ts | 6 ------ 6 files changed, 14 insertions(+), 21 deletions(-) diff --git a/src/cli/commands/checkout.ts b/src/cli/commands/checkout.ts index 242757c..5d32c1f 100644 --- a/src/cli/commands/checkout.ts +++ b/src/cli/commands/checkout.ts @@ -76,7 +76,7 @@ export function registerCheckoutCommand(program: Command): void { console.log(formatGuidanceLine('Next:')); console.log(formatGuidanceLine(' Open .revpack/CONTEXT.md and point your agent at it')); if (clonedTo) { - console.log(formatGuidanceLine(` cd ${clonedTo}`)); + console.log(formatGuidanceLine(` \`cd ${clonedTo}\``)); } } catch (err) { handleError(err); diff --git a/src/cli/commands/clean.ts b/src/cli/commands/clean.ts index 4ad86ac..87ca173 100644 --- a/src/cli/commands/clean.ts +++ b/src/cli/commands/clean.ts @@ -24,11 +24,10 @@ export function registerCleanCommand(program: Command): void { console.log(chalk.dim('Nothing to clean — .revpack/ does not exist.')); } console.log(''); - console.log( - chalk.dim('.revpack/ is disposable local state. This does not affect the MR/PR or published comments.'), - ); + console.log(chalk.dim('.revpack/ is disposable local state.')); + console.log(chalk.dim('This does not affect the MR/PR or published comments.')); console.log(formatGuidanceLine('Next:')); - console.log(formatGuidanceLine(' revpack prepare')); + console.log(formatGuidanceLine(' `revpack prepare`')); } catch (err) { handleError(err); } diff --git a/src/cli/commands/prepare.ts b/src/cli/commands/prepare.ts index f220f57..1942520 100644 --- a/src/cli/commands/prepare.ts +++ b/src/cli/commands/prepare.ts @@ -118,7 +118,7 @@ export function registerPrepareCommand(program: Command): void { // Next steps console.log(formatGuidanceLine('Next:')); console.log(formatGuidanceLine(' Ask your agent to read .revpack/CONTEXT.md')); - console.log(formatGuidanceLine(' Or run `/revpack-review` if installed')); + console.log(formatGuidanceLine(' Or invoke `/revpack-review` skill if installed')); console.log(''); console.log(formatGuidanceLine('After new commits or review comments:')); console.log(formatGuidanceLine(' revpack prepare')); diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 259cf62..6603815 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -102,12 +102,12 @@ export async function runSetup(opts: SetupOptions): Promise { } if (!opts.prompts) { console.log(formatGuidanceLine(' Tip: install an agent adapter, for example:')); - console.log(formatGuidanceLine(' revpack setup agent codex')); + console.log(formatGuidanceLine(' `revpack setup agent codex`')); } else { console.log(formatGuidanceLine(' Tip: `revpack setup --prompts` is deprecated; use:')); - console.log(formatGuidanceLine(' revpack setup agent copilot')); + console.log(formatGuidanceLine(' `revpack setup agent copilot`')); } - console.log(formatGuidanceLine(' revpack prepare')); + console.log(formatGuidanceLine(' `revpack prepare`')); } } @@ -120,7 +120,7 @@ export async function runSetupAgent(opts: SetupAgentOptions): Promise { if (!(await fileExists(path.join(opts.cwd, 'REVIEW.md')))) { console.log(formatGuidanceLine('Tip: add project-specific review guidance in REVIEW.md.')); - console.log(formatGuidanceLine(' revpack setup')); + console.log(formatGuidanceLine(' `revpack setup`')); } } @@ -209,15 +209,15 @@ function printAgentUsage(target: AgentTarget): void { switch (target) { case 'claude': console.log(formatGuidanceLine('Use it in Claude Code with:')); - console.log(formatGuidanceLine(' /revpack-review')); + console.log(formatGuidanceLine(' `/revpack-review`')); break; case 'codex': console.log(formatGuidanceLine('Use it in Codex with:')); - console.log(formatGuidanceLine(' $revpack-review')); + console.log(formatGuidanceLine(' `$revpack-review`')); break; case 'copilot': console.log(formatGuidanceLine('Use it in Copilot Chat with:')); - console.log(formatGuidanceLine(' /revpack-review')); + console.log(formatGuidanceLine(' `/revpack-review`')); break; case 'cursor': console.log(formatGuidanceLine('Use it in Cursor by asking for a revpack review.')); diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index dc89a89..dba1087 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -161,7 +161,7 @@ export function registerStatusCommand(program: Command): void { if (bundleIsOutdated) { console.log(formatGuidanceLine('Next:')); - console.log(formatGuidanceLine(' revpack prepare')); + console.log(formatGuidanceLine(' `revpack prepare`')); const pendingOlderBundleLines = buildPendingOlderBundleLines({ repliesReady: pendingReplies > 0, findingsReady: pendingFindings > 0, @@ -202,7 +202,7 @@ export function registerStatusCommand(program: Command): void { console.log(formatGuidanceLine('No bundle prepared.')); console.log(''); console.log(formatGuidanceLine('Next:')); - console.log(formatGuidanceLine(' revpack prepare')); + console.log(formatGuidanceLine(' `revpack prepare`')); } } catch (err) { handleError(err); diff --git a/src/cli/output.ts b/src/cli/output.ts index b891c7d..c0a08cf 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -1,16 +1,10 @@ import chalk from 'chalk'; -const COMMAND_LINE_PATTERN = /^\s*(?:revpack\b|cd\b|export\b|\/revpack-review\b|\$revpack-review)/u; const INLINE_CODE_PATTERN = /`([^`\r\n]+)`/gu; export function formatGuidanceLine(line: string): string { if (line === '') return ''; - // Full command lines stay fully visible. - if (COMMAND_LINE_PATTERN.test(line)) { - return line; - } - let result = ''; let lastIndex = 0; let foundInlineCode = false; From c2ca7e9a5aa7397f96bba4ee46bf20fca93fd3fb Mon Sep 17 00:00:00 2001 From: Stefan Victora Date: Fri, 12 Jun 2026 23:59:05 +0200 Subject: [PATCH 05/12] Revert "Switch to explicit code highlight for CLI commands" This reverts commit cba68d4b23f63748febfc95f1f1463284f73006c. --- src/cli/commands/checkout.ts | 2 +- src/cli/commands/clean.ts | 7 ++++--- src/cli/commands/prepare.ts | 2 +- src/cli/commands/setup.ts | 14 +++++++------- src/cli/commands/status.ts | 4 ++-- src/cli/output.ts | 6 ++++++ 6 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/cli/commands/checkout.ts b/src/cli/commands/checkout.ts index 5d32c1f..242757c 100644 --- a/src/cli/commands/checkout.ts +++ b/src/cli/commands/checkout.ts @@ -76,7 +76,7 @@ export function registerCheckoutCommand(program: Command): void { console.log(formatGuidanceLine('Next:')); console.log(formatGuidanceLine(' Open .revpack/CONTEXT.md and point your agent at it')); if (clonedTo) { - console.log(formatGuidanceLine(` \`cd ${clonedTo}\``)); + console.log(formatGuidanceLine(` cd ${clonedTo}`)); } } catch (err) { handleError(err); diff --git a/src/cli/commands/clean.ts b/src/cli/commands/clean.ts index 87ca173..4ad86ac 100644 --- a/src/cli/commands/clean.ts +++ b/src/cli/commands/clean.ts @@ -24,10 +24,11 @@ export function registerCleanCommand(program: Command): void { console.log(chalk.dim('Nothing to clean — .revpack/ does not exist.')); } console.log(''); - console.log(chalk.dim('.revpack/ is disposable local state.')); - console.log(chalk.dim('This does not affect the MR/PR or published comments.')); + console.log( + chalk.dim('.revpack/ is disposable local state. This does not affect the MR/PR or published comments.'), + ); console.log(formatGuidanceLine('Next:')); - console.log(formatGuidanceLine(' `revpack prepare`')); + console.log(formatGuidanceLine(' revpack prepare')); } catch (err) { handleError(err); } diff --git a/src/cli/commands/prepare.ts b/src/cli/commands/prepare.ts index 1942520..f220f57 100644 --- a/src/cli/commands/prepare.ts +++ b/src/cli/commands/prepare.ts @@ -118,7 +118,7 @@ export function registerPrepareCommand(program: Command): void { // Next steps console.log(formatGuidanceLine('Next:')); console.log(formatGuidanceLine(' Ask your agent to read .revpack/CONTEXT.md')); - console.log(formatGuidanceLine(' Or invoke `/revpack-review` skill if installed')); + console.log(formatGuidanceLine(' Or run `/revpack-review` if installed')); console.log(''); console.log(formatGuidanceLine('After new commits or review comments:')); console.log(formatGuidanceLine(' revpack prepare')); diff --git a/src/cli/commands/setup.ts b/src/cli/commands/setup.ts index 6603815..259cf62 100644 --- a/src/cli/commands/setup.ts +++ b/src/cli/commands/setup.ts @@ -102,12 +102,12 @@ export async function runSetup(opts: SetupOptions): Promise { } if (!opts.prompts) { console.log(formatGuidanceLine(' Tip: install an agent adapter, for example:')); - console.log(formatGuidanceLine(' `revpack setup agent codex`')); + console.log(formatGuidanceLine(' revpack setup agent codex')); } else { console.log(formatGuidanceLine(' Tip: `revpack setup --prompts` is deprecated; use:')); - console.log(formatGuidanceLine(' `revpack setup agent copilot`')); + console.log(formatGuidanceLine(' revpack setup agent copilot')); } - console.log(formatGuidanceLine(' `revpack prepare`')); + console.log(formatGuidanceLine(' revpack prepare')); } } @@ -120,7 +120,7 @@ export async function runSetupAgent(opts: SetupAgentOptions): Promise { if (!(await fileExists(path.join(opts.cwd, 'REVIEW.md')))) { console.log(formatGuidanceLine('Tip: add project-specific review guidance in REVIEW.md.')); - console.log(formatGuidanceLine(' `revpack setup`')); + console.log(formatGuidanceLine(' revpack setup')); } } @@ -209,15 +209,15 @@ function printAgentUsage(target: AgentTarget): void { switch (target) { case 'claude': console.log(formatGuidanceLine('Use it in Claude Code with:')); - console.log(formatGuidanceLine(' `/revpack-review`')); + console.log(formatGuidanceLine(' /revpack-review')); break; case 'codex': console.log(formatGuidanceLine('Use it in Codex with:')); - console.log(formatGuidanceLine(' `$revpack-review`')); + console.log(formatGuidanceLine(' $revpack-review')); break; case 'copilot': console.log(formatGuidanceLine('Use it in Copilot Chat with:')); - console.log(formatGuidanceLine(' `/revpack-review`')); + console.log(formatGuidanceLine(' /revpack-review')); break; case 'cursor': console.log(formatGuidanceLine('Use it in Cursor by asking for a revpack review.')); diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index dba1087..dc89a89 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -161,7 +161,7 @@ export function registerStatusCommand(program: Command): void { if (bundleIsOutdated) { console.log(formatGuidanceLine('Next:')); - console.log(formatGuidanceLine(' `revpack prepare`')); + console.log(formatGuidanceLine(' revpack prepare')); const pendingOlderBundleLines = buildPendingOlderBundleLines({ repliesReady: pendingReplies > 0, findingsReady: pendingFindings > 0, @@ -202,7 +202,7 @@ export function registerStatusCommand(program: Command): void { console.log(formatGuidanceLine('No bundle prepared.')); console.log(''); console.log(formatGuidanceLine('Next:')); - console.log(formatGuidanceLine(' `revpack prepare`')); + console.log(formatGuidanceLine(' revpack prepare')); } } catch (err) { handleError(err); diff --git a/src/cli/output.ts b/src/cli/output.ts index c0a08cf..b891c7d 100644 --- a/src/cli/output.ts +++ b/src/cli/output.ts @@ -1,10 +1,16 @@ import chalk from 'chalk'; +const COMMAND_LINE_PATTERN = /^\s*(?:revpack\b|cd\b|export\b|\/revpack-review\b|\$revpack-review)/u; const INLINE_CODE_PATTERN = /`([^`\r\n]+)`/gu; export function formatGuidanceLine(line: string): string { if (line === '') return ''; + // Full command lines stay fully visible. + if (COMMAND_LINE_PATTERN.test(line)) { + return line; + } + let result = ''; let lastIndex = 0; let foundInlineCode = false; From a3826c2fea9b84169459f24d76784773b082276f Mon Sep 17 00:00:00 2001 From: Stefan Victora Date: Fri, 12 Jun 2026 23:59:27 +0200 Subject: [PATCH 06/12] Wording updates --- src/cli/commands/prepare.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/cli/commands/prepare.ts b/src/cli/commands/prepare.ts index f220f57..0969108 100644 --- a/src/cli/commands/prepare.ts +++ b/src/cli/commands/prepare.ts @@ -120,8 +120,7 @@ export function registerPrepareCommand(program: Command): void { console.log(formatGuidanceLine(' Ask your agent to read .revpack/CONTEXT.md')); console.log(formatGuidanceLine(' Or run `/revpack-review` if installed')); console.log(''); - console.log(formatGuidanceLine('After new commits or review comments:')); - console.log(formatGuidanceLine(' revpack prepare')); + console.log(formatGuidanceLine('After new commits or review comments, run `revpack prepare` again')); } catch (err) { handleError(err); } From 655139e650295ed54769149888029e0b778f5395 Mon Sep 17 00:00:00 2001 From: Stefan Victora Date: Sat, 13 Jun 2026 00:16:24 +0200 Subject: [PATCH 07/12] Improve stack trace logging for errors --- CHANGELOG.md | 1 + src/cli/helpers.test.ts | 48 ++++++++++++++++++++++++++++++- src/cli/helpers.ts | 15 ++++++++-- src/orchestration/orchestrator.ts | 2 +- 4 files changed, 62 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48151f2..3561a2f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). - Fixed `revpack publish review` leaving `review.md` populated after publishing, which could republish the same review note during later incremental reviews. - Fixed `revpack publish all` updating the PR/MR description summary again when `revpack status` already reported the summary as published. +- Fixed debug error logging repeating the user-facing error message before the stack frames. ## [0.4.0] - 2026-06-07 diff --git a/src/cli/helpers.test.ts b/src/cli/helpers.test.ts index 333d9ff..147be43 100644 --- a/src/cli/helpers.test.ts +++ b/src/cli/helpers.test.ts @@ -2,6 +2,7 @@ import * as fs from 'node:fs/promises'; import * as os from 'node:os'; import * as path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'; +import { WorkspaceError } from '../core/errors.js'; import type { ReviewProvider } from '../providers/provider.js'; vi.mock('../config/index.js', () => ({ @@ -18,18 +19,33 @@ vi.mock('../providers/factory.js', () => ({ }), })); -const { createOrchestrator } = await import('./helpers.js'); +const { createOrchestrator, handleError } = await import('./helpers.js'); describe('cli helpers', () => { let tmpDir: string; let cwdSpy: MockInstance<() => string>; + let exitSpy: MockInstance; + let consoleErrorSpy: MockInstance; + let debugEnv: string | undefined; beforeEach(async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'revpack-helpers-')); cwdSpy = vi.spyOn(process, 'cwd').mockReturnValue(tmpDir); + exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => { + throw new Error('process.exit: 1'); + }); + consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + debugEnv = process.env.DEBUG; }); afterEach(async () => { + if (debugEnv === undefined) { + delete process.env.DEBUG; + } else { + process.env.DEBUG = debugEnv; + } + consoleErrorSpy.mockRestore(); + exitSpy.mockRestore(); cwdSpy.mockRestore(); await fs.rm(tmpDir, { recursive: true, force: true }); }); @@ -52,4 +68,34 @@ describe('cli helpers', () => { const explicit = await createOrchestrator(undefined, undefined, { allowActiveLocal: false }); expect((explicit as unknown as { provider: ReviewProvider }).provider.providerType).toBe('github'); }); + + it('prints the user-facing error message and debug hint without DEBUG', () => { + delete process.env.DEBUG; + + expect(() => handleError(new WorkspaceError('Cannot prepare bundle'))).toThrow('process.exit: 1'); + + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('[WORKSPACE_ERROR] Cannot prepare bundle')); + expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining('Set DEBUG=1 for full stack trace')); + expect(exitSpy).toHaveBeenCalledWith(1); + }); + + it('prints debug stack frames without repeating the error message', () => { + process.env.DEBUG = '1'; + const err = new Error('Cannot prepare bundle\n\nPush your commits, then run revpack prepare'); + err.stack = [ + 'Error: Cannot prepare bundle', + '', + 'Push your commits, then run revpack prepare', + ' at ReviewOrchestrator.prepare (orchestrator.js:126:23)', + ' at async Command. (prepare.js:20:28)', + ].join('\n'); + + expect(() => handleError(err)).toThrow('process.exit: 1'); + + const output = consoleErrorSpy.mock.calls.map(([message]) => String(message)).join('\n'); + expect(output.match(/Cannot prepare bundle/g)).toHaveLength(1); + expect(output).toContain('Stack trace:'); + expect(output).toContain(' at ReviewOrchestrator.prepare (orchestrator.js:126:23)'); + expect(output).toContain(' at async Command. (prepare.js:20:28)'); + }); }); diff --git a/src/cli/helpers.ts b/src/cli/helpers.ts index d02ae0d..c0e57bd 100644 --- a/src/cli/helpers.ts +++ b/src/cli/helpers.ts @@ -88,9 +88,20 @@ export function handleError(err: unknown): never { } if (process.env.DEBUG) { - console.error(err); + console.error(''); + console.error(chalk.dim('Stack trace:')); + if (err instanceof Error && err.stack) { + const stackFrames = err.stack + .split('\n') + .filter((line) => /^\s+at\s/.test(line)) + .join('\n'); + console.error(chalk.dim(stackFrames || err.stack)); + } else { + console.error(err); + } } else { - console.error(chalk.dim(' Set DEBUG=1 for full stack trace')); + console.error(''); + console.error(chalk.dim('Set DEBUG=1 for full stack trace')); } process.exit(1); diff --git a/src/orchestration/orchestrator.ts b/src/orchestration/orchestrator.ts index 7627355..e22ef4b 100644 --- a/src/orchestration/orchestrator.ts +++ b/src/orchestration/orchestrator.ts @@ -203,7 +203,7 @@ export class ReviewOrchestrator { `Cannot prepare an agent-ready bundle because the local checkout is ahead of the ${formatTargetKind(target)} head.\n\n` + `${formatTargetKind(target)} head: ${mrHeadSha}\n` + `Local HEAD: ${localHeadSha}\n\n` + - `Push your commits or reset/switch to the ${formatTargetKind(target)} head, then run:\n\n` + + `Push your commits or reset/switch to the ${formatTargetKind(target)} head, then run:\n` + ` revpack prepare`, ); } else { From 0ef1fa349d4c8606cad2351f60b084f874571fe3 Mon Sep 17 00:00:00 2001 From: Stefan Victora Date: Sat, 13 Jun 2026 01:01:32 +0200 Subject: [PATCH 08/12] Improve layout of prepare and status CLI output --- src/cli/commands/prepare.ts | 42 ++++++++++++++++--------------------- src/cli/commands/status.ts | 15 +++---------- 2 files changed, 21 insertions(+), 36 deletions(-) diff --git a/src/cli/commands/prepare.ts b/src/cli/commands/prepare.ts index 0969108..bb7da43 100644 --- a/src/cli/commands/prepare.ts +++ b/src/cli/commands/prepare.ts @@ -59,51 +59,44 @@ export function registerPrepareCommand(program: Command): void { console.log(chalk.green(`✓ Bundle ${action}`)); console.log(''); console.log(` ${chalk.bold(targetDisplayId)}: ${target.title}`); - console.log(` ${chalk.dim('State:')} ${stateColor(target.state)}`); - console.log(` ${chalk.dim('Author:')} ${isLocal ? target.author : `@${target.author}`}`); - console.log(` ${chalk.dim('Branch:')} ${target.sourceBranch} → ${target.targetBranch}`); - console.log(` ${chalk.dim(`${targetLabel} updated:`)} ${formatDate(target.updatedAt)}`); - console.log(` ${chalk.dim('Threads:')} ${bundle.threads.length} unresolved`); - console.log(` ${chalk.dim('Files:')} ${bundle.diffs.length} changed`); + console.log(` ${chalk.dim('State:')} ${stateColor(target.state)}`); + console.log(` ${chalk.dim('Author:')} ${isLocal ? target.author : `@${target.author}`}`); + console.log(` ${chalk.dim('Branch:')} ${target.sourceBranch} → ${target.targetBranch}`); + console.log(` ${chalk.dim(`Updated:`)} ${formatDate(target.updatedAt)}`); + console.log(` ${chalk.dim('Files:')} ${bundle.diffs.length} changed`); + console.log(` ${chalk.dim('Threads:')} ${bundle.threads.length} unresolved`); console.log(''); // Prepare summary — changes if (result.hasCheckpoint) { - console.log(` ${chalk.dim('Changes since last recorded review state:')}`); - console.log(` ${chalk.dim('Target code:')} ${result.targetCodeChanged ? 'yes' : 'no'}`); + console.log(` ${chalk.dim('Changes since last checkpoint:')}`); + console.log(` ${chalk.dim('Code:')} ${result.targetCodeChanged ? 'yes' : 'no'}`); console.log( - ` ${chalk.dim('Threads/replies:')} ${result.threadsChanged != null ? (result.threadsChanged ? 'yes' : 'no') : 'unknown'}`, + ` ${chalk.dim('Threads:')} ${result.threadsChanged != null ? (result.threadsChanged ? 'yes' : 'no') : 'unknown'}`, ); console.log( - ` ${chalk.dim('Description:')} ${result.descriptionChanged != null ? (result.descriptionChanged ? 'yes' : 'no') : 'unknown'}`, + ` ${chalk.dim('Description:')} ${result.descriptionChanged != null ? (result.descriptionChanged ? 'yes' : 'no') : 'unknown'}`, ); if (result.prunedReplies > 0) { console.log(` ${chalk.dim('Stale replies pruned:')} ${result.prunedReplies}`); } - if (result.publishedActionCount > 0) { - console.log(` ${chalk.dim('Previously published:')} ${result.publishedActionCount} previous`); - } console.log(''); // Focus guidance if (result.targetCodeChanged) { - console.log(` ${chalk.dim('Focus:')} updated diff and unresolved thread updates`); + console.log(` ${chalk.dim('Focus:')} updated diff and unresolved thread updates`); } else if (result.threadsChanged) { - console.log(` ${chalk.dim('Focus:')} updated threads/replies and pending outputs`); + console.log(` ${chalk.dim('Focus:')} updated threads/replies and pending outputs`); } else { - console.log(` ${chalk.dim('Focus:')} no remote changes since the last recorded review state`); + console.log(` ${chalk.dim('Focus:')} no remote changes since the last recorded review state`); } - console.log(''); } else if (mode !== 'fresh') { - console.log(` ${chalk.dim('Review mode:')} fresh review — no recorded review state found`); + console.log(` ${chalk.dim('Focus:')} fresh review — no recorded review state found`); console.log(''); } - - // Key paths - const bundleDir = bundle.bundlePath; - console.log(` ${chalk.dim('Bundle:')} ${bundleDir}`); - console.log(` ${chalk.dim('Context:')} ${result.contextPath}`); + // Context path + console.log(` ${chalk.dim('Context:')} ${result.contextPath}`); console.log(''); // Warnings @@ -120,7 +113,8 @@ export function registerPrepareCommand(program: Command): void { console.log(formatGuidanceLine(' Ask your agent to read .revpack/CONTEXT.md')); console.log(formatGuidanceLine(' Or run `/revpack-review` if installed')); console.log(''); - console.log(formatGuidanceLine('After new commits or review comments, run `revpack prepare` again')); + console.log(formatGuidanceLine('Later:')); + console.log(formatGuidanceLine(' After new commits or review comments, run `revpack prepare` again')); } catch (err) { handleError(err); } diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index dc89a89..33877d9 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -77,9 +77,11 @@ export function registerStatusCommand(program: Command): void { console.log(chalk.bold(`${targetKind} ${targetDisplayId}: ${t.title}`)); console.log(` ${chalk.dim('Repository:')} ${t.repository}`); + console.log(` ${chalk.dim('State:')} ${stateColor(t.state)}`); console.log(` ${chalk.dim('Author:')} @${t.author}`); console.log(` ${chalk.dim('Branch:')} ${t.sourceBranch} → ${t.targetBranch}`); - console.log(` ${chalk.dim('State:')} ${stateColor(t.state)}`); + console.log(` ${chalk.dim(`Updated:`)} ${formatDate(t.updatedAt)}`); + if (t.webUrl) { console.log(` ${chalk.dim('URL:')} ${t.webUrl}`); } @@ -148,13 +150,6 @@ export function registerStatusCommand(program: Command): void { const checkpointState = getCheckpointState(bundleState); console.log(` ${chalk.dim('Checkpoint:')} ${formatCheckpointState(checkpointState)}`); - // Publish history - if (bundleState.publishedActions.length > 0) { - console.log(''); - console.log(chalk.dim('─ Publish history ─')); - console.log(` ${formatCount(bundleState.publishedActions.length, 'action')} previously published`); - } - // Next step console.log(''); const needsCheckpoint = checkpointState !== 'current'; @@ -376,7 +371,3 @@ function formatDate(iso: string): string { return iso; } } - -function formatCount(count: number, singular: string, plural = `${singular}s`): string { - return `${count} ${count === 1 ? singular : plural}`; -} From 48185e2f679603078036d975cb43645498dc242d Mon Sep 17 00:00:00 2001 From: Stefan Victora Date: Fri, 26 Jun 2026 23:11:34 +0200 Subject: [PATCH 09/12] Improve status messages --- src/cli/commands/status.ts | 38 +++++++++++++++++++++++++------------ src/workspace/git-helper.ts | 6 +++--- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index 33877d9..dea3c55 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -103,19 +103,11 @@ export function registerStatusCommand(program: Command): void { const git = new GitHelper(process.cwd()); try { const [currentHead, currentBranch] = await Promise.all([git.headSha(), git.currentBranch()]); - const matchesTarget = currentHead === comparisonTargetHead; - - console.log(chalk.dim('─ Local checkout ─')); + console.log(chalk.dim('─ Checkout ─')); console.log(` ${chalk.dim('Branch:')} ${currentBranch}`); console.log(` ${chalk.dim('Current HEAD:')} ${currentHead.slice(0, 7)}`); - if (matchesTarget) { - console.log(` ${chalk.dim(`Matches ${targetKind}:`)} ${chalk.green('yes')}`); - } else { - const isAncestor = await git.isAncestor(comparisonTargetHead).catch(() => false); - const relation = isAncestor ? `ahead of ${targetKind} head` : `behind ${targetKind} head`; - console.log(` ${chalk.dim(`${targetKind} head:`)} ${comparisonTargetHead.slice(0, 7)}`); - console.log(` ${chalk.dim(`Matches ${targetKind}:`)} ${chalk.yellow(`no — ${relation}`)}`); - } + const status = await formatCheckoutState(git, comparisonTargetHead, currentHead, targetKind); + console.log(` ${chalk.dim(`Status:`)} ${status}`); } catch { // Not a git repo — skip local checkout info } @@ -275,7 +267,29 @@ function formatBundleFreshnessState( targetKind: string, ): string { if (!currentTargetHead) return chalk.yellow('unknown'); - return bundleIsOutdated ? chalk.yellow(`stale — prepared for older ${targetKind} head`) : chalk.green('current'); + return bundleIsOutdated + ? chalk.yellow(`stale — prepared for older ${targetKind} head`) + : chalk.green('current — matches latest PR head'); +} + +async function formatCheckoutState( + git: GitHelper, + comparisonTargetHead: string, + currentHead: string, + targetKind: string, +) { + const targetIsAncestorOfCurrent = await git.isAncestor(comparisonTargetHead, currentHead).catch(() => false); + const currentIsAncestorOfTarget = await git.isAncestor(currentHead, comparisonTargetHead).catch(() => false); + if (currentHead === comparisonTargetHead) { + return chalk.green(`current — matches latest ${targetKind} head`); + } + if (targetIsAncestorOfCurrent) { + return chalk.yellow(`ahead — local HEAD is not in the ${targetKind} yet`); + } + if (currentIsAncestorOfTarget) { + return chalk.yellow(`behind — latest ${targetKind} head is ${comparisonTargetHead.slice(0, 7)}`); + } + return chalk.yellow(`diverged — latest ${targetKind} head is ${comparisonTargetHead.slice(0, 7)}`); } function isPublishableOutputState(state: string): boolean { diff --git a/src/workspace/git-helper.ts b/src/workspace/git-helper.ts index 97ce178..d63b242 100644 --- a/src/workspace/git-helper.ts +++ b/src/workspace/git-helper.ts @@ -280,10 +280,10 @@ export class GitHelper { return stdout.trim(); } - /** Check if a commit is an ancestor of HEAD (i.e. HEAD includes that commit). */ - async isAncestor(commitSha: string): Promise { + /** Check if `ancestorSha` is an ancestor of `descendantRef`. */ + async isAncestor(ancestorSha: string, descendantRef = 'HEAD'): Promise { try { - await execGit(['merge-base', '--is-ancestor', commitSha, 'HEAD'], this.cwd); + await execGit(['merge-base', '--is-ancestor', ancestorSha, descendantRef], this.cwd); return true; } catch { return false; From 82f5f140795af6a3a5b1d788f8ab4e5c9a532d5a Mon Sep 17 00:00:00 2001 From: Stefan Victora Date: Sat, 27 Jun 2026 00:10:11 +0200 Subject: [PATCH 10/12] Improve next step guidance for ahead checkout --- CHANGELOG.md | 1 + src/cli/commands/status.test.ts | 26 ++++++++++++++++++++++ src/cli/commands/status.ts | 39 ++++++++++++++++++++++++++------- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3561a2f..3f9f459 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ### Fixed +- Fixed `revpack status` next-step guidance when the local checkout is ahead of the latest PR/MR head. - Fixed `revpack publish review` leaving `review.md` populated after publishing, which could republish the same review note during later incremental reviews. - Fixed `revpack publish all` updating the PR/MR description summary again when `revpack status` already reported the summary as published. - Fixed debug error logging repeating the user-facing error message before the stack frames. diff --git a/src/cli/commands/status.test.ts b/src/cli/commands/status.test.ts index 5bb1188..56047a5 100644 --- a/src/cli/commands/status.test.ts +++ b/src/cli/commands/status.test.ts @@ -97,6 +97,32 @@ describe('buildStatusNextLines', () => { ).toEqual(['Next:', ' revpack publish checkpoint']); }); + it('tells the user to push and prepare when the local checkout is ahead of the latest head', () => { + expect( + buildStatusNextLines({ + repliesReady: false, + findingsReady: false, + summaryReady: false, + reviewReady: false, + checkpointDue: true, + checkoutRelation: 'ahead', + }), + ).toEqual(['Next:', ' Push local commits, then run:', ' revpack prepare']); + }); + + it('keeps checkpoint guidance when the local checkout matches the latest head', () => { + expect( + buildStatusNextLines({ + repliesReady: false, + findingsReady: false, + summaryReady: false, + reviewReady: false, + checkpointDue: true, + checkoutRelation: 'current', + }), + ).toEqual(['Next:', ' revpack publish checkpoint']); + }); + it('shows no pending publish action when nothing is ready or due', () => { expect( buildStatusNextLines({ diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index dea3c55..47c6e46 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -101,12 +101,14 @@ export function registerStatusCommand(program: Command): void { // Local checkout info const git = new GitHelper(process.cwd()); + let checkoutRelation: CheckoutRelation = 'unknown'; try { const [currentHead, currentBranch] = await Promise.all([git.headSha(), git.currentBranch()]); console.log(chalk.dim('─ Checkout ─')); console.log(` ${chalk.dim('Branch:')} ${currentBranch}`); console.log(` ${chalk.dim('Current HEAD:')} ${currentHead.slice(0, 7)}`); - const status = await formatCheckoutState(git, comparisonTargetHead, currentHead, targetKind); + checkoutRelation = await compareCheckoutToTargetHead(git, comparisonTargetHead, currentHead); + const status = formatCheckoutState(checkoutRelation, comparisonTargetHead, targetKind); console.log(` ${chalk.dim(`Status:`)} ${status}`); } catch { // Not a git repo — skip local checkout info @@ -168,6 +170,7 @@ export function registerStatusCommand(program: Command): void { summaryReady: hasPublishableSummary, reviewReady: hasPublishableReview, checkpointDue: needsCheckpoint, + checkoutRelation, })) { console.log(formatGuidanceLine(line)); } @@ -223,6 +226,7 @@ async function countJsonArray(filePath: string): Promise { } type CheckpointState = 'none' | 'current' | 'outdated' | 'unknown'; +type CheckoutRelation = 'current' | 'ahead' | 'behind' | 'diverged' | 'unknown'; function getCheckpointState(bundleState: { prepare: { @@ -272,24 +276,38 @@ function formatBundleFreshnessState( : chalk.green('current — matches latest PR head'); } -async function formatCheckoutState( +async function compareCheckoutToTargetHead( git: GitHelper, comparisonTargetHead: string, currentHead: string, - targetKind: string, -) { +): Promise { const targetIsAncestorOfCurrent = await git.isAncestor(comparisonTargetHead, currentHead).catch(() => false); const currentIsAncestorOfTarget = await git.isAncestor(currentHead, comparisonTargetHead).catch(() => false); if (currentHead === comparisonTargetHead) { - return chalk.green(`current — matches latest ${targetKind} head`); + return 'current'; } if (targetIsAncestorOfCurrent) { - return chalk.yellow(`ahead — local HEAD is not in the ${targetKind} yet`); + return 'ahead'; } if (currentIsAncestorOfTarget) { - return chalk.yellow(`behind — latest ${targetKind} head is ${comparisonTargetHead.slice(0, 7)}`); + return 'behind'; + } + return 'diverged'; +} + +function formatCheckoutState(relation: CheckoutRelation, comparisonTargetHead: string, targetKind: string): string { + switch (relation) { + case 'current': + return chalk.green(`current — matches latest ${targetKind} head`); + case 'ahead': + return chalk.yellow(`ahead — local HEAD is not in the ${targetKind} yet`); + case 'behind': + return chalk.yellow(`behind — latest ${targetKind} head is ${comparisonTargetHead.slice(0, 7)}`); + case 'diverged': + return chalk.yellow(`diverged — latest ${targetKind} head is ${comparisonTargetHead.slice(0, 7)}`); + case 'unknown': + return chalk.yellow('unknown'); } - return chalk.yellow(`diverged — latest ${targetKind} head is ${comparisonTargetHead.slice(0, 7)}`); } function isPublishableOutputState(state: string): boolean { @@ -302,7 +320,12 @@ export function buildStatusNextLines(options: { summaryReady: boolean; reviewReady: boolean; checkpointDue: boolean; + checkoutRelation?: CheckoutRelation; }): string[] { + if (options.checkoutRelation === 'ahead') { + return ['Next:', ' Push local commits, then run:', ' revpack prepare']; + } + const contentReady = [options.repliesReady, options.findingsReady, options.summaryReady, options.reviewReady].filter( Boolean, ).length; From 3baf47eff213661f0d0cc48c65e953e1c8565a45 Mon Sep 17 00:00:00 2001 From: Stefan Victora Date: Sat, 27 Jun 2026 00:25:34 +0200 Subject: [PATCH 11/12] Fix incorrect target kind usage --- src/cli/commands/status.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index 47c6e46..87bb11c 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -273,7 +273,7 @@ function formatBundleFreshnessState( if (!currentTargetHead) return chalk.yellow('unknown'); return bundleIsOutdated ? chalk.yellow(`stale — prepared for older ${targetKind} head`) - : chalk.green('current — matches latest PR head'); + : chalk.green(`current — matches latest ${targetKind} head`); } async function compareCheckoutToTargetHead( From 214276d029b926b80ca8dd7737b6fdb92e8554b1 Mon Sep 17 00:00:00 2001 From: Stefan Victora Date: Sat, 27 Jun 2026 00:29:49 +0200 Subject: [PATCH 12/12] Return unknown if commit is not found --- src/cli/commands/status.test.ts | 46 ++++++++++++++++++++++++++++++++- src/cli/commands/status.ts | 15 ++++++++--- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/src/cli/commands/status.test.ts b/src/cli/commands/status.test.ts index 56047a5..148f776 100644 --- a/src/cli/commands/status.test.ts +++ b/src/cli/commands/status.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { buildPendingOlderBundleLines, buildStatusNextLines } from './status.js'; +import type { GitHelper } from '../../workspace/git-helper.js'; +import { buildPendingOlderBundleLines, buildStatusNextLines, compareCheckoutToTargetHead } from './status.js'; describe('buildStatusNextLines', () => { // Checkpoint guidance is intentionally omitted when exactly one content output is ready. @@ -179,3 +180,46 @@ describe('buildPendingOlderBundleLines', () => { ).toEqual([]); }); }); + +describe('compareCheckoutToTargetHead', () => { + function gitDouble(options: { + hasCommit: (sha: string) => boolean | Promise; + isAncestor?: (ancestorSha: string, descendantRef?: string) => boolean | Promise; + }) { + return { + hasCommit: options.hasCommit, + isAncestor: options.isAncestor ?? (() => false), + } as unknown as GitHelper; + } + + it('reports unknown when the comparison target commit is not available locally', async () => { + const git = gitDouble({ + hasCommit: (sha) => sha === 'current-head', + isAncestor: () => { + throw new Error('ancestry should not be checked for missing commits'); + }, + }); + + await expect(compareCheckoutToTargetHead(git, 'missing-target-head', 'current-head')).resolves.toBe('unknown'); + }); + + it('reports unknown when the current commit cannot be inspected locally', async () => { + const git = gitDouble({ + hasCommit: (sha) => sha === 'target-head', + isAncestor: () => { + throw new Error('ancestry should not be checked for missing commits'); + }, + }); + + await expect(compareCheckoutToTargetHead(git, 'target-head', 'missing-current-head')).resolves.toBe('unknown'); + }); + + it('reports diverged only after both commits are available for ancestry checks', async () => { + const git = gitDouble({ + hasCommit: () => true, + isAncestor: () => false, + }); + + await expect(compareCheckoutToTargetHead(git, 'target-head', 'current-head')).resolves.toBe('diverged'); + }); +}); diff --git a/src/cli/commands/status.ts b/src/cli/commands/status.ts index 87bb11c..9cfd668 100644 --- a/src/cli/commands/status.ts +++ b/src/cli/commands/status.ts @@ -276,16 +276,25 @@ function formatBundleFreshnessState( : chalk.green(`current — matches latest ${targetKind} head`); } -async function compareCheckoutToTargetHead( +export async function compareCheckoutToTargetHead( git: GitHelper, comparisonTargetHead: string, currentHead: string, ): Promise { - const targetIsAncestorOfCurrent = await git.isAncestor(comparisonTargetHead, currentHead).catch(() => false); - const currentIsAncestorOfTarget = await git.isAncestor(currentHead, comparisonTargetHead).catch(() => false); if (currentHead === comparisonTargetHead) { return 'current'; } + + const [targetExists, currentExists] = await Promise.all([ + git.hasCommit(comparisonTargetHead), + git.hasCommit(currentHead), + ]); + if (!targetExists || !currentExists) { + return 'unknown'; + } + + const targetIsAncestorOfCurrent = await git.isAncestor(comparisonTargetHead, currentHead); + const currentIsAncestorOfTarget = await git.isAncestor(currentHead, comparisonTargetHead); if (targetIsAncestorOfCurrent) { return 'ahead'; }