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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,15 @@ 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 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.

## [0.4.0] - 2026-06-07

### Added
Expand Down
43 changes: 18 additions & 25 deletions src/cli/commands/prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -120,8 +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:'));
console.log(formatGuidanceLine(' revpack prepare'));
console.log(formatGuidanceLine('Later:'));
console.log(formatGuidanceLine(' After new commits or review comments, run `revpack prepare` again'));
} catch (err) {
handleError(err);
}
Expand Down
202 changes: 202 additions & 0 deletions src/cli/commands/publish.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -29,6 +30,62 @@ describe('publish command internals', () => {
await fs.rm(tmpDir, { recursive: true, force: true });
});

async function writeBundleState(provider = 'gitlab', options?: { summaryHash?: string }): Promise<void> {
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',
...(options?.summaryHash ? { lastPublishedHash: options.summaryHash } : {}),
},
},
publishedActions: [],
},
null,
2,
),
'utf-8',
);
}

async function writeValidFindingBundle(): Promise<void> {
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' }];

Expand Down Expand Up @@ -106,6 +163,98 @@ 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('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();
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');
Expand All @@ -126,6 +275,59 @@ 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('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) });
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),
Expand Down
Loading