ReviewOS

also looking at this

stacks/buddy

fix(cli): apply the review and scan flags that were accepted and ignored

#1431
Merged glennmichael123 wants to merge fix/cli-flag-noops into main
7 files +197 -19
bin/cli.tsmodified+13-2
Changes to bin/cli.ts
@@ -1610,9 +1610,9 @@ cli
16101610 // runs was the one path missing both.
16111611 if (prNumber) {
16121612 const gitProvider = await providerFor(config, 'reviewing a pull request', log)
1613 const { runReviewForPR } = await import('../src/review/run')
1613 const { reviewPullRequest } = await import('../src/review/run')
16141614
1615 const status = await runReviewForPR({
1615 const { status, result } = await reviewPullRequest({
16161616 config,
16171617 provider: gitProvider,
16181618 prNumber: Number.parseInt(prNumber, 10),
@@ -1626,6 +1626,17 @@ cli
16261626 })
16271627
16281628 logger.success(`✅ ${status}`)
1629
1630 // `--format` and `--fail-on` were accepted on this path and applied
1631 // only on the local one, so a pull request review could not be
1632 // rendered as JSON or made to fail a pipeline. The review is posted
1633 // regardless; these govern what the terminal sees and the exit code.
1634 if (result && format !== 'pretty')
1635 process.stdout.write(formatReview(result, format))
1636
1637 if (result && options.failOn && shouldFail(result.findings, options.failOn as 'critical'))
1638 process.exit(1)
1639
16291640 return
16301641 }
16311642
docs/cli/review.mdmodified+5-0
Changes to docs/cli/review.md
@@ -44,6 +44,11 @@ fetched, costing one API call rather than a full review:
4444Without `--auto` none of these apply except `enabled`, because a person at a
4545terminal has already decided they want the review.
4646
47`--format` and `--fail-on` apply here too. The review is posted to the pull
48request regardless; they govern what the terminal sees and the exit code, so
49`buddy review 123 --fail-on major` can gate a pipeline on the review it just
50left.
51
4752A pull request review runs the same path as [`@buddy review`](/features/pr-conversations),
4853so both assemble the same context: the analyzers, the guidelines and the
4954learnings `@buddy remember` has recorded — all read from the base branch, never
src/buddy.tsmodified+14-2
Changes to src/buddy.ts
@@ -2062,7 +2062,7 @@ export class Buddy {
20622062 */
20632063 async checkPackages(packageNames: string[]): Promise<PackageUpdate[]> {
20642064 this.logger.info(`Checking specific packages: ${packageNames.join(', ')}`)
2065 return this.registryClient.getUpdatesForPackages(packageNames)
2065 return this.applyStrategy(await this.registryClient.getUpdatesForPackages(packageNames))
20662066 }
20672067
20682068 /**
@@ -2070,7 +2070,19 @@ export class Buddy {
20702070 */
20712071 async checkPackagesWithPattern(pattern: string): Promise<PackageUpdate[]> {
20722072 this.logger.info(`Checking packages with pattern: ${pattern}`)
2073 return this.registryClient.getUpdatesWithPattern(pattern)
2073 return this.applyStrategy(await this.registryClient.getUpdatesWithPattern(pattern))
2074 }
2075
2076 /**
2077 * Apply the configured strategy to a targeted check.
2078 *
2079 * The full scan filtered by strategy and these two paths did not, so
2080 * `buddy scan --strategy patch --packages react` folded `--strategy` into
2081 * the config and then listed the major anyway.
2082 */
2083 private applyStrategy(updates: PackageUpdate[]): PackageUpdate[] {
2084 const strategy = this.config.packages?.strategy
2085 return strategy ? this.filterUpdatesByStrategy(updates, strategy) : updates
20742086 }
20752087
20762088 /**
src/review/index.tsmodified+2-1
Changes to src/review/index.ts
@@ -32,7 +32,8 @@ export type { RefFileReader } from './guidelines'
3232export { prepareReview, renderFinding } from './poster'
3333export { resolveBuddyThreads } from './resolve'
3434export type { ResolveThreadsOptions } from './resolve'
35export { runReviewForPR } from './run'
35export { reviewPullRequest, runReviewForPR } from './run'
36export type { ReviewOutcome } from './run'
3637export type { RunReviewOptions } from './run'
3738export type { InlineComment, PreparedReview } from './poster'
3839export {
src/review/run.tsmodified+60-13
Changes to src/review/run.ts
@@ -10,6 +10,7 @@ import { parseUnifiedDiff } from './diff'
1010import { reviewDiff } from './engine'
1111import { composeInstructions, loadGuidelines } from './guidelines'
1212import type { ReviewProfile } from './engine'
13import type { ReviewResult } from './findings'
1314import type { ReviewTrigger } from './filters'
1415import { reviewSkipReason } from './filters'
1516import { needsReview, parseReviewState, upsertReviewState } from './marker'
@@ -60,30 +61,49 @@ export interface RunReviewOptions {
6061 * @param options - Repository context and review settings
6162 * @returns A short status line describing what happened
6263 */
63export async function runReviewForPR(options: RunReviewOptions): Promise<string> {
64/** What a pull request review produced, for callers that need more than a status line. */
65export interface ReviewOutcome {
66 /** A short status line describing what happened */
67 status: string
68 /** The review, when one was computed — absent when skipped, paused or empty */
69 result?: ReviewResult
70}
71
72/**
73 * Review a pull request and report both the status and the findings.
74 *
75 * `runReviewForPR` returns only the status line, which is all a comment reply
76 * needs. The CLI needs the findings too: `--format` renders them and
77 * `--fail-on` gates the exit code on them, and both were accepted on the pull
78 * request path and then never applied, because the findings never came back.
79 *
80 * @param options - Repository context and review settings
81 * @returns The status, and the review when one was computed
82 */
83export async function reviewPullRequest(options: RunReviewOptions): Promise<ReviewOutcome> {
6484 const logger = options.logger ?? getDefaultLogger()
6585 const { config, provider, prNumber } = options
6686
6787 const prs = await provider.getPullRequests('open')
6888 const pr = prs.find(candidate => candidate.number === prNumber)
6989 if (!pr)
70 return `Could not find open pull request #${prNumber}.`
90 return { status: `Could not find open pull request #${prNumber}.` }
7191
7292 // Checked before the diff is fetched or a model is contacted, so an ignored
7393 // pull request costs one API call rather than a review's worth of tokens.
7494 const skip = reviewSkipReason(config, pr, options.trigger ?? 'requested')
7595 if (skip) {
7696 logger.info(`🔍 Skipping PR #${prNumber}: ${skip}`)
77 return skip
97 return { status: skip }
7898 }
7999
80100 const state = parseReviewState(pr.body)
81101 if (state?.paused && !options.full)
82 return 'Reviews are paused on this pull request. Say `@buddy resume` to restart them.'
102 return { status: 'Reviews are paused on this pull request. Say `@buddy resume` to restart them.' }
83103
84104 const diff = await provider.getPullRequestDiff(prNumber)
85105 if (!diff.trim())
86 return 'There are no changes to review.'
106 return { status: 'There are no changes to review.' }
87107
88108 const parsed = parseUnifiedDiff(diff)
89109 const changedFiles = parsed.files.map(file => file.path)
@@ -91,7 +111,7 @@ export async function runReviewForPR(options: RunReviewOptions): Promise<string>
91111
92112 if (options.skipIfReviewed && !needsReview(state, headSha)) {
93113 logger.info(`🔍 PR #${prNumber} already reviewed at ${headSha}`)
94 return 'Already reviewed at this commit.'
114 return { status: 'Already reviewed at this commit.' }
95115 }
96116
97117 const ai = options.ai === undefined ? createAiClient(config, logger) : options.ai
@@ -111,7 +131,7 @@ export async function runReviewForPR(options: RunReviewOptions): Promise<string>
111131
112132 if (!ai) {
113133 if (analysis.findings.length === 0)
114 return 'No AI provider configured and static analysis found nothing to report.'
134 return { status: 'No AI provider configured and static analysis found nothing to report.' }
115135
116136 const prepared = prepareReview(
117137 {
@@ -124,15 +144,23 @@ export async function runReviewForPR(options: RunReviewOptions): Promise<string>
124144 { headSha, requestChangesOn: config.ai?.review?.requestChangesOn },
125145 )
126146
147 const staticResult: ReviewResult = {
148 summary: 'Static analysis only — no AI provider is configured.',
149 walkthrough: [],
150 findings: analysis.findings,
151 effort: 1,
152 omittedFiles: [],
153 }
154
127155 if (options.dryRun) {
128156 reportDryRun(prepared, logger)
129 return `Would post ${analysis.findings.length} static-analysis finding(s).`
157 return { status: `Would post ${analysis.findings.length} static-analysis finding(s).`, result: staticResult }
130158 }
131159
132160 assertSupports(provider, 'inlineReviewComments', 'createReview', 'posting a review')
133161 await provider.createReview(prNumber, prepared)
134162 await persistReviewState(provider, prNumber, pr.body, prepared.state, state?.paused, logger)
135 return `Posted ${analysis.findings.length} static-analysis finding(s).`
163 return { status: `Posted ${analysis.findings.length} static-analysis finding(s).`, result: staticResult }
136164 }
137165
138166 // Guidelines and learnings are read from the base branch: both are inlined
@@ -172,18 +200,37 @@ export async function runReviewForPR(options: RunReviewOptions): Promise<string>
172200
173201 if (options.dryRun) {
174202 reportDryRun(prepared, logger)
175 return `Would post ${result.findings.length} finding(s).`
203 return { status: `Would post ${result.findings.length} finding(s).`, result }
176204 }
177205
178206 assertSupports(provider, 'inlineReviewComments', 'createReview', 'posting a review')
179207 await provider.createReview(prNumber, prepared)
180208 await persistReviewState(provider, prNumber, pr.body, prepared.state, state?.paused, logger)
181209
182 return result.findings.length === 0
183 ? 'Reviewed — nothing to report.'
184 : `Reviewed — ${result.findings.length} finding(s) posted.`
210 return {
211 status: result.findings.length === 0
212 ? 'Reviewed — nothing to report.'
213 : `Reviewed — ${result.findings.length} finding(s) posted.`,
214 result,
215 }
216}
217
218/**
219 * Review a pull request end to end and post the result.
220 *
221 * Shared by the CLI, the `@buddy review` command and the automatic
222 * trigger, so all three assemble context — guidelines, learnings, analyzer
223 * findings — the same way rather than drifting apart.
224 *
225 * @param options - Repository context and review settings
226 * @returns A short status line describing what happened
227 */
228export async function runReviewForPR(options: RunReviewOptions): Promise<string> {
229 return (await reviewPullRequest(options)).status
185230}
186231
232
233
187234/**
188235 * Print a review instead of posting it.
189236 *
test/review-parity.test.tsmodified+54-1
Changes to test/review-parity.test.ts
@@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
66import { promises as fs } from 'node:fs'
77import { tmpdir } from 'node:os'
88import { join } from 'node:path'
9import { runReviewForPR } from '../src/review/run'
9import { reviewPullRequest, runReviewForPR } from '../src/review/run'
1010import { NO_CAPABILITIES } from '../src/git/provider'
1111import { Logger } from '../src/utils/logger'
1212
@@ -227,3 +227,56 @@ describe('analyzers on a pull request', () => {
227227 expect(seen.reviews[0].body).toContain('Static analysis only')
228228 })
229229})
230
231/**
232 * `--format` and `--fail-on` were accepted on the pull request path and applied
233 * only on the local one — the findings never came back to the command, so a
234 * pull request review could not be rendered as JSON or fail a pipeline.
235 */
236describe('reviewPullRequest returns the findings', () => {
237 const config: BuddyConfig = {
238 repository: { provider: 'github', owner: 'o', name: 'r', baseBranch: 'main' },
239 }
240
241 /** An AI client that reports one finding on the added line. */
242 function findingAi(seen: Seen): AiClient {
243 return {
244 name: 'stub',
245 complete: async (request: { system?: string, messages: Array<{ content: string }> }) => {
246 seen.prompts.push(request.messages.map(m => m.content).join('\n'))
247 const json = {
248 summary: 'one problem',
249 walkthrough: [],
250 findings: [{ path: 'src/app.ts', line: 2, severity: 'critical', category: 'security', message: 'hard-coded token' }],
251 }
252 return { text: JSON.stringify(json), json, outputTokens: 1 }
253 },
254 } as unknown as AiClient
255 }
256
257 it('success case - the findings come back with the status', async () => {
258 const { provider, seen } = stub()
259
260 const outcome = await reviewPullRequest({ config, provider, prNumber: 7, ai: findingAi(seen), logger: Logger.silent() })
261
262 expect(outcome.status).toContain('1 finding')
263 expect(outcome.result?.findings.map(f => f.severity)).toEqual(['critical'])
264 })
265
266 it('success case - a skipped review carries no result', async () => {
267 const { provider } = stub()
268
269 const outcome = await reviewPullRequest({ config, provider, prNumber: 999, ai: null, logger: Logger.silent() })
270
271 expect(outcome.status).toContain('Could not find')
272 expect(outcome.result).toBeUndefined()
273 })
274
275 it('success case - the string form is the status alone', async () => {
276 const { provider, seen } = stub()
277
278 const status = await runReviewForPR({ config, provider, prNumber: 7, ai: findingAi(seen), logger: Logger.silent() })
279
280 expect(typeof status).toBe('string')
281 })
282})
test/scan-strategy.test.tsadded+49-0
Changes to test/scan-strategy.test.ts
@@ -0,0 +1,49 @@
1import type { PackageUpdate } from '../src/types'
2import { describe, expect, it } from 'bun:test'
3import { Buddy } from '../src/buddy'
4
5function update(name: string, updateType: PackageUpdate['updateType']): PackageUpdate {
6 return {
7 name,
8 currentVersion: '1.0.0',
9 newVersion: updateType === 'major' ? '2.0.0' : updateType === 'minor' ? '1.1.0' : '1.0.1',
10 updateType,
11 dependencyType: 'dependencies',
12 file: 'package.json',
13 } as PackageUpdate
14}
15
16/**
17 * `buddy scan --strategy patch --packages react` folded `--strategy` into the
18 * config and then listed the major anyway: the full scan filtered by strategy
19 * and the targeted `--packages` / `--pattern` paths did not.
20 */
21describe('strategy on targeted checks', () => {
22 function buddyWith(strategy: 'all' | 'major' | 'minor' | 'patch' | undefined) {
23 const buddy = new Buddy({ packages: strategy ? { strategy } : ({} as never) }, process.cwd())
24 const client = (buddy as never as { registryClient: Record<string, unknown> }).registryClient
25 client.getUpdatesForPackages = async () => [update('react', 'major'), update('lodash', 'patch')]
26 client.getUpdatesWithPattern = async () => [update('@types/node', 'minor'), update('@types/bun', 'patch')]
27 return buddy
28 }
29
30 it('success case - --packages honours the strategy', async () => {
31 const updates = await buddyWith('patch').checkPackages(['react', 'lodash'])
32
33 expect(updates.map(u => u.name)).toEqual(['lodash'])
34 })
35
36 it('success case - --pattern honours the strategy', async () => {
37 const updates = await buddyWith('patch').checkPackagesWithPattern('@types/*')
38
39 expect(updates.map(u => u.name)).toEqual(['@types/bun'])
40 })
41
42 it('success case - all admits everything, as the scan does', async () => {
43 expect(await buddyWith('all').checkPackages(['react', 'lodash'])).toHaveLength(2)
44 })
45
46 it('edge case - no strategy configured filters nothing', async () => {
47 expect(await buddyWith(undefined).checkPackages(['react', 'lodash'])).toHaveLength(2)
48 })
49})