ReviewOS

also looking at this

stacks/buddy

fix(review): persist review state where it is read back

#1405
Merged glennmichael123 wants to merge fix/persist-review-state into main
6 files +176 -14
bin/cli.tsmodified+24-2
Changes to bin/cli.ts
@@ -1614,6 +1614,8 @@ cli
16141614 let headSha = ''
16151615 let seenFingerprints: string[] = []
16161616 let gitProvider: GitProvider | null = null
1617 let prBody: string | null = null
1618 let wasPaused = false
16171619
16181620 if (prNumber) {
16191621 gitProvider = await providerFor(config, 'reviewing a pull request', log)
@@ -1627,7 +1629,13 @@ cli
16271629 const pr = prs.find(candidate => candidate.number === number)
16281630 const state = parseReviewState(pr?.body)
16291631 seenFingerprints = state?.fingerprints ?? []
1630 headSha = state?.reviewedSha ?? ''
1632 prBody = pr?.body ?? null
1633 wasPaused = Boolean(state?.paused)
1634
1635 // The commit being reviewed now, not the one reviewed last time.
1636 // Reading it from the stored state recorded a stale sha, so the next
1637 // run could not tell whether this commit had been seen.
1638 headSha = await gitProvider.getPullRequestHeadSha(number)
16311639 }
16321640 else {
16331641 const base = options.base || config.repository?.baseBranch || 'main'
@@ -1734,7 +1742,21 @@ cli
17341742 }
17351743
17361744 assertSupports(gitProvider!, 'inlineReviewComments', 'createReview', 'posting a review')
1737 await gitProvider.createReview(Number.parseInt(prNumber, 10), prepared)
1745 const reviewedNumber = Number.parseInt(prNumber, 10)
1746 await gitProvider.createReview(reviewedNumber, prepared)
1747
1748 // The review has landed; recording where it got to is bookkeeping, so a
1749 // failure here must not present as a failed review.
1750 try {
1751 const { upsertReviewState } = await import('../src/review/marker')
1752 await gitProvider.updatePullRequest(reviewedNumber, {
1753 body: upsertReviewState(prBody, { ...prepared.state, ...(wasPaused ? { paused: true } : {}) }),
1754 })
1755 }
1756 catch (error) {
1757 logger.warn(`Could not record review state on PR #${prNumber}: ${error instanceof Error ? error.message : String(error)}`)
1758 }
1759
17381760 logger.success(`✅ Reviewed PR #${prNumber}: ${result.findings.length} finding(s)`)
17391761 }
17401762 catch (error) {
src/commands/handlers.tsmodified+8-10
Changes to src/commands/handlers.ts
@@ -4,7 +4,6 @@ import type { CommandContext, CommandHandler, CommandOutcome } from './dispatche
44import process from 'node:process'
55import { createAiClient } from '../ai'
66import { attemptFix } from '../ci/fix'
7import { serializeReviewState } from '../review/marker'
87
98/** Everything the built-in handlers need to act. */
109export interface HandlerDeps {
@@ -144,16 +143,15 @@ async function setPaused(deps: HandlerDeps, prNumber: number, paused: boolean):
144143 if (!pr)
145144 return
146145
147 const { parseReviewState } = await import('../review/marker')
146 const { parseReviewState, upsertReviewState } = await import('../review/marker')
148147 const state = parseReviewState(pr.body)
149148
150 const marker = serializeReviewState({
151 reviewedSha: state?.reviewedSha ?? '',
152 fingerprints: state?.fingerprints ?? [],
153 reviewedAt: state?.reviewedAt ?? new Date().toISOString(),
154 ...(paused ? { paused: true } : {}),
149 await deps.provider.updatePullRequest(prNumber, {
150 body: upsertReviewState(pr.body, {
151 reviewedSha: state?.reviewedSha ?? '',
152 fingerprints: state?.fingerprints ?? [],
153 reviewedAt: state?.reviewedAt ?? new Date().toISOString(),
154 ...(paused ? { paused: true } : {}),
155 }),
155156 })
156
157 const body = pr.body.replace(/<!--\s*buddy:review[\s\S]*?-->/, '').trimEnd()
158 await deps.provider.updatePullRequest(prNumber, { body: `${body}\n\n${marker}` })
159157}
src/review/marker.tsmodified+22-0
Changes to src/review/marker.ts
@@ -63,6 +63,28 @@ export function parseReviewState(body: string | null | undefined): ReviewState |
6363 }
6464}
6565
66/**
67 * Replace the review-state marker in a pull request body.
68 *
69 * The marker has to live on the pull request itself: `parseReviewState` reads
70 * `pr.body` and nothing else, so state written anywhere else — a review
71 * summary, a comment — is written and never read back.
72 *
73 * @param body - Current pull request body
74 * @param state - State to store
75 * @returns The body with exactly one, current, marker
76 * @example
77 * ```ts
78 * await provider.updatePullRequest(number, {
79 * body: upsertReviewState(pr.body, { reviewedSha, fingerprints, reviewedAt }),
80 * })
81 * ```
82 */
83export function upsertReviewState(body: string | null | undefined, state: Omit<ReviewState, 'schemaVersion'>): string {
84 const withoutMarker = (body ?? '').replace(MARKER_REGEX, '').trimEnd()
85 return `${withoutMarker}\n\n${serializeReviewState(state)}`
86}
87
6688/**
6789 * Whether a pull request needs reviewing.
6890 *
src/review/poster.tsmodified+12-0
Changes to src/review/poster.ts
@@ -1,4 +1,5 @@
11import type { ReviewFinding, ReviewResult } from './findings'
2import type { ReviewState } from './marker'
23import { fingerprint } from './findings'
34import { serializeReviewState } from './marker'
45
@@ -25,6 +26,12 @@ export interface PreparedReview {
2526 comments: InlineComment[]
2627 /** `REQUEST_CHANGES` only when configured and warranted */
2728 event: 'COMMENT' | 'REQUEST_CHANGES'
29 /**
30 * State this review established, for the caller to persist onto the pull
31 * request. The copy inside `body` is an audit trail of what this particular
32 * review saw; only the copy on `pr.body` is ever read back.
33 */
34 state: Omit<ReviewState, 'schemaVersion'>
2835}
2936
3037/**
@@ -83,6 +90,11 @@ export function prepareReview(
8390 body: renderSummary(result, counts, options.headSha, fingerprints),
8491 comments,
8592 event: options.requestChangesOn === 'critical' && hasCritical ? 'REQUEST_CHANGES' : 'COMMENT',
93 state: {
94 reviewedSha: options.headSha,
95 fingerprints,
96 reviewedAt: new Date().toISOString(),
97 },
8698 }
8799}
88100
src/review/run.tsmodified+39-1
Changes to src/review/run.ts
@@ -8,7 +8,8 @@ import { getDefaultLogger } from '../utils/logger'
88import { parseUnifiedDiff } from './diff'
99import { reviewDiff } from './engine'
1010import { composeInstructions, loadGuidelines } from './guidelines'
11import { needsReview, parseReviewState } from './marker'
11import { needsReview, parseReviewState, upsertReviewState } from './marker'
12import type { PreparedReview } from './poster'
1213import { prepareReview } from './poster'
1314
1415/** Inputs to a full review of a pull request. */
@@ -93,6 +94,7 @@ export async function runReviewForPR(options: RunReviewOptions): Promise<string>
9394
9495 assertSupports(provider, 'inlineReviewComments', 'createReview', 'posting a review')
9596 await provider.createReview(prNumber, prepared)
97 await persistReviewState(provider, prNumber, pr.body, prepared.state, state?.paused, logger)
9698 return `Posted ${analysis.findings.length} static-analysis finding(s).`
9799 }
98100
@@ -133,8 +135,44 @@ export async function runReviewForPR(options: RunReviewOptions): Promise<string>
133135
134136 assertSupports(provider, 'inlineReviewComments', 'createReview', 'posting a review')
135137 await provider.createReview(prNumber, prepared)
138 await persistReviewState(provider, prNumber, pr.body, prepared.state, state?.paused, logger)
136139
137140 return result.findings.length === 0
138141 ? 'Reviewed — nothing to report.'
139142 : `Reviewed — ${result.findings.length} finding(s) posted.`
140143}
144
145/**
146 * Write the state a review established back onto the pull request.
147 *
148 * Separated from posting because the review itself has already landed by this
149 * point: if updating the body fails — a token without write access to the
150 * description, say — the findings are still on the pull request, and the worst
151 * case is that the next run reviews the same commit again. That is a better
152 * failure than losing the review to a bookkeeping error.
153 *
154 * @param provider - Git provider to write through
155 * @param prNumber - Pull request to update
156 * @param body - Current pull request body
157 * @param state - State this review established
158 * @param paused - Carried forward so persisting does not silently resume a
159 * paused pull request
160 * @param logger - Logger for the failure path
161 */
162async function persistReviewState(
163 provider: GitProvider,
164 prNumber: number,
165 body: string | null | undefined,
166 state: PreparedReview['state'],
167 paused: boolean | undefined,
168 logger: Logger,
169): Promise<void> {
170 try {
171 await provider.updatePullRequest(prNumber, {
172 body: upsertReviewState(body, { ...state, ...(paused ? { paused: true } : {}) }),
173 })
174 }
175 catch (error) {
176 logger.warn(`Could not record review state on PR #${prNumber}: ${error instanceof Error ? error.message : String(error)}`)
177 }
178}
test/review-engine.test.tsmodified+71-1
Changes to test/review-engine.test.ts
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'bun:test'
44import { parseUnifiedDiff, renderDiffForReview } from '../src/review/diff'
55import { defaultIncludePath, reviewDiff } from '../src/review/engine'
66import { dedupeFindings, fingerprint, validateFindings } from '../src/review/findings'
7import { needsReview, parseReviewState, serializeReviewState } from '../src/review/marker'
7import { needsReview, parseReviewState, serializeReviewState, upsertReviewState } from '../src/review/marker'
88import { prepareReview, renderFinding } from '../src/review/poster'
99
1010const SAMPLE_DIFF = `diff --git a/src/app.ts b/src/app.ts
@@ -446,3 +446,73 @@ describe('reviewDiff', () => {
446446 expect(ai.requests[0].system).toContain('less certain')
447447 })
448448})
449
450describe('review state round-trip', () => {
451 it('success case - prepareReview exposes the state for the caller to persist', () => {
452 // The marker was only ever written into the review summary, which nothing
453 // reads back — so `skipIfReviewed` never skipped and dedupe never engaged.
454 const prepared = prepareReview(
455 { summary: 'Adds logging.', walkthrough: [], findings: [makeFinding()], effort: 2, omittedFiles: [] },
456 { headSha: 'sha123' },
457 )
458
459 expect(prepared.state.reviewedSha).toBe('sha123')
460 expect(prepared.state.fingerprints.length).toBeGreaterThan(0)
461 })
462
463 it('success case - state written to a PR body is readable again', () => {
464 const prepared = prepareReview(
465 { summary: 'x', walkthrough: [], findings: [makeFinding()], effort: 1, omittedFiles: [] },
466 { headSha: 'abc' },
467 )
468
469 const body = upsertReviewState('Original description.', prepared.state)
470 const read = parseReviewState(body)
471
472 expect(read?.reviewedSha).toBe('abc')
473 expect(read?.fingerprints).toEqual(prepared.state.fingerprints)
474 expect(body).toContain('Original description.')
475 })
476
477 it('success case - the round-trip satisfies needsReview', () => {
478 // The whole point: a second run at the same commit must skip.
479 const prepared = prepareReview(
480 { summary: 'x', walkthrough: [], findings: [], effort: 1, omittedFiles: [] },
481 { headSha: 'head-1' },
482 )
483
484 const state = parseReviewState(upsertReviewState('desc', prepared.state))
485
486 expect(needsReview(state, 'head-1')).toBe(false)
487 expect(needsReview(state, 'head-2')).toBe(true)
488 })
489
490 it('edge case - replaces the existing marker rather than stacking them', () => {
491 const first = upsertReviewState('desc', { reviewedSha: 'one', fingerprints: [], reviewedAt: 'x' })
492 const second = upsertReviewState(first, { reviewedSha: 'two', fingerprints: [], reviewedAt: 'y' })
493
494 expect(second.match(/<!--\s*buddy:review/g)).toHaveLength(1)
495 expect(parseReviewState(second)?.reviewedSha).toBe('two')
496 expect(second).toContain('desc')
497 })
498
499 it('edge case - persisting a review does not resume a paused pull request', () => {
500 const paused = upsertReviewState('desc', { reviewedSha: 'one', fingerprints: [], reviewedAt: 'x', paused: true })
501 const previous = parseReviewState(paused)
502
503 const next = upsertReviewState(paused, {
504 reviewedSha: 'two',
505 fingerprints: [],
506 reviewedAt: 'y',
507 ...(previous?.paused ? { paused: true } : {}),
508 })
509
510 expect(parseReviewState(next)?.paused).toBe(true)
511 })
512
513 it('edge case - tolerates an empty pull request body', () => {
514 const body = upsertReviewState(null, { reviewedSha: 'x', fingerprints: [], reviewedAt: 'y' })
515
516 expect(parseReviewState(body)?.reviewedSha).toBe('x')
517 })
518})