also looking at this
fix(review): honour the ai.review settings that nothing read
#1406
7 files
+282
-8
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -291,6 +291,8 @@ interface CLIOptions { | ||
| 291 | 291 | respectLatest?: boolean |
| 292 | 292 | /** Which settings pages `open-settings` should open: repo, org or both. */ |
| 293 | 293 | type?: string |
| 294 | /** Set by the generated workflow: honour the `ai.review` filters. */ | |
| 295 | auto?: boolean | |
| 294 | 296 | } |
| 295 | 297 | |
| 296 | 298 | cli |
| @@ -1558,6 +1560,7 @@ cli | ||
| 1558 | 1560 | .option('--fail-on <severity>', 'Exit non-zero when a finding at or above this severity is found') |
| 1559 | 1561 | .option('--profile <name>', 'Review profile: chill|assertive') |
| 1560 | 1562 | .option('--summary-only', 'Post only the summary, without inline findings') |
| 1563 | .option('--auto', 'Treat this as an automatic trigger, honouring the ai.review filters') | |
| 1561 | 1564 | .option('--dry-run', 'Print the review instead of posting it') |
| 1562 | 1565 | .example('buddy review 42') |
| 1563 | 1566 | .example('buddy review --staged --light') |
| @@ -1621,12 +1624,24 @@ cli | ||
| 1621 | 1624 | gitProvider = await providerFor(config, 'reviewing a pull request', log) |
| 1622 | 1625 | |
| 1623 | 1626 | const number = Number.parseInt(prNumber, 10) |
| 1624 | diff = await gitProvider.getPullRequestDiff(number) | |
| 1625 | 1627 | |
| 1626 | 1628 | // Carry forward what earlier reviews already said, so a re-review |
| 1627 | 1629 | // reports only what is new rather than repeating itself. |
| 1628 | 1630 | const prs = await gitProvider.getPullRequests('open') |
| 1629 | 1631 | const pr = prs.find(candidate => candidate.number === number) |
| 1632 | ||
| 1633 | // `--auto` is what the generated workflow passes. Without it this is | |
| 1634 | // someone at a terminal, who has already decided they want a review. | |
| 1635 | if (pr) { | |
| 1636 | const { reviewSkipReason } = await import('../src/review/filters') | |
| 1637 | const skip = reviewSkipReason(config, pr, options.auto ? 'automatic' : 'requested') | |
| 1638 | if (skip) { | |
| 1639 | log.info(`🔍 Skipping PR #${number}: ${skip}`) | |
| 1640 | return | |
| 1641 | } | |
| 1642 | } | |
| 1643 | ||
| 1644 | diff = await gitProvider.getPullRequestDiff(number) | |
| 1630 | 1645 | const state = parseReviewState(pr?.body) |
| 1631 | 1646 | seenFingerprints = state?.fingerprints ?? [] |
| 1632 | 1647 | prBody = pr?.body ?? null |
| @@ -20,6 +20,30 @@ The default includes staged changes deliberately: a pre-commit review that | ||
| 20 | 20 | ignored what you just staged would miss the very changes you are about to |
| 21 | 21 | commit. |
| 22 | 22 | |
| 23 | ## Reviewing a pull request | |
| 24 | ||
| 25 | Pass a number to review a pull request instead of local changes: | |
| 26 | ||
| 27 | ```bash | |
| 28 | buddy review 123 | |
| 29 | ``` | |
| 30 | ||
| 31 | Add `--auto` when Buddy is choosing to review rather than being asked — which | |
| 32 | is what the generated workflow does. It honours the filters under | |
| 33 | `ai.review`, so a draft or a `wip` pull request is skipped before the diff is | |
| 34 | fetched, costing one API call rather than a full review: | |
| 35 | ||
| 36 | | Setting | Skips when | | |
| 37 | |---|---| | |
| 38 | | `enabled: false` | Always — including an explicit `@buddy review` | | |
| 39 | | `autoReview: false` | The trigger is automatic; asking still works | | |
| 40 | | `drafts` | The pull request is a draft and this is not `true` | | |
| 41 | | `ignoreTitleKeywords` | The title contains one of them, case-insensitively | | |
| 42 | | `ignoreUsernames` | The author is listed | | |
| 43 | ||
| 44 | Without `--auto` none of these apply except `enabled`, because a person at a | |
| 45 | terminal has already decided they want the review. | |
| 46 | ||
| 23 | 47 | ## Without an API key |
| 24 | 48 | |
| 25 | 49 | `--light` runs the analyzers and skips the model entirely — secret scanning, |
| @@ -0,0 +1,102 @@ | ||
| 1 | import type { BuddyConfig, PullRequest } from '../types' | |
| 2 | ||
| 3 | /** | |
| 4 | * How a review came to be running. | |
| 5 | * | |
| 6 | * The distinction matters because every filter here except `enabled` describes | |
| 7 | * when Buddy should review *on its own*. Someone who types `@buddy review` on a | |
| 8 | * draft has answered the question the filter exists to ask. | |
| 9 | */ | |
| 10 | export type ReviewTrigger = 'automatic' | 'requested' | |
| 11 | ||
| 12 | /** The pull request fields a filter decision needs. */ | |
| 13 | export type ReviewCandidate = Pick<PullRequest, 'title' | 'author' | 'draft'> | |
| 14 | ||
| 15 | /** | |
| 16 | * Why this pull request should not be reviewed, if it should not be. | |
| 17 | * | |
| 18 | * These settings were declared, validated and documented long before anything | |
| 19 | * read them, so a repository that set `ai.review.enabled: false` still got | |
| 20 | * reviews. Each one is honoured here, in one place, rather than being scattered | |
| 21 | * across the two paths that post reviews. | |
| 22 | * | |
| 23 | * `undefined` means "not configured" and never means "off": reviews already | |
| 24 | * require both a workflow trigger and a provider key, so treating an unset flag | |
| 25 | * as a refusal would silently disable reviews for every existing repository. | |
| 26 | * Only an explicit `false` turns something off. | |
| 27 | * | |
| 28 | * @param config - Resolved Buddy configuration | |
| 29 | * @param pr - Pull request under consideration | |
| 30 | * @param trigger - Whether Buddy chose to review, or was asked to | |
| 31 | * @returns A human-readable reason to skip, or `null` to go ahead | |
| 32 | * @example | |
| 33 | * ```ts | |
| 34 | * const skip = reviewSkipReason(config, pr, 'automatic') | |
| 35 | * if (skip) | |
| 36 | * return skip | |
| 37 | * ``` | |
| 38 | */ | |
| 39 | export function reviewSkipReason( | |
| 40 | config: BuddyConfig, | |
| 41 | pr: ReviewCandidate, | |
| 42 | trigger: ReviewTrigger, | |
| 43 | ): string | null { | |
| 44 | const review = config.ai?.review | |
| 45 | ||
| 46 | // The one filter that also refuses an explicit request: `enabled: false` is | |
| 47 | // a statement about the repository, not about a particular pull request. | |
| 48 | if (review?.enabled === false) | |
| 49 | return 'AI review is disabled for this repository (`ai.review.enabled` is false).' | |
| 50 | ||
| 51 | if (trigger === 'requested') | |
| 52 | return null | |
| 53 | ||
| 54 | if (review?.autoReview === false) | |
| 55 | return 'Automatic review is off (`ai.review.autoReview` is false). Ask for one with `@buddy review`.' | |
| 56 | ||
| 57 | if (pr.draft && review?.drafts !== true) | |
| 58 | return 'This pull request is a draft. Set `ai.review.drafts` to true, or ask with `@buddy review`.' | |
| 59 | ||
| 60 | const keyword = matchedKeyword(pr.title, review?.ignoreTitleKeywords) | |
| 61 | if (keyword) | |
| 62 | return `The title contains \`${keyword}\` (\`ai.review.ignoreTitleKeywords\`).` | |
| 63 | ||
| 64 | if (matchesUsername(pr.author, review?.ignoreUsernames)) | |
| 65 | return `Pull requests from \`${pr.author}\` are ignored (\`ai.review.ignoreUsernames\`).` | |
| 66 | ||
| 67 | return null | |
| 68 | } | |
| 69 | ||
| 70 | /** | |
| 71 | * The first configured keyword appearing in a title. | |
| 72 | * | |
| 73 | * Matched case-insensitively as a substring, so `wip` catches `WIP:` and | |
| 74 | * `[wip]` without asking anyone to write a regular expression. | |
| 75 | * | |
| 76 | * @param title - Pull request title | |
| 77 | * @param keywords - Configured keywords, if any | |
| 78 | * @returns The keyword that matched, or `null` | |
| 79 | */ | |
| 80 | function matchedKeyword(title: string, keywords: string[] | undefined): string | null { | |
| 81 | if (!keywords?.length) | |
| 82 | return null | |
| 83 | ||
| 84 | const haystack = title.toLowerCase() | |
| 85 | return keywords.find(keyword => keyword.trim() && haystack.includes(keyword.toLowerCase().trim())) ?? null | |
| 86 | } | |
| 87 | ||
| 88 | /** | |
| 89 | * Whether an author is on the ignore list. | |
| 90 | * | |
| 91 | * Compared case-insensitively, because GitHub logins are. | |
| 92 | * | |
| 93 | * @param author - Pull request author's login | |
| 94 | * @param usernames - Configured logins to ignore, if any | |
| 95 | */ | |
| 96 | function matchesUsername(author: string, usernames: string[] | undefined): boolean { | |
| 97 | if (!usernames?.length) | |
| 98 | return false | |
| 99 | ||
| 100 | const login = author.toLowerCase() | |
| 101 | return usernames.some(name => name.toLowerCase().trim() === login) | |
| 102 | } | |
| @@ -8,6 +8,8 @@ import { getDefaultLogger } from '../utils/logger' | ||
| 8 | 8 | import { parseUnifiedDiff } from './diff' |
| 9 | 9 | import { reviewDiff } from './engine' |
| 10 | 10 | import { composeInstructions, loadGuidelines } from './guidelines' |
| 11 | import type { ReviewTrigger } from './filters' | |
| 12 | import { reviewSkipReason } from './filters' | |
| 11 | 13 | import { needsReview, parseReviewState, upsertReviewState } from './marker' |
| 12 | 14 | import type { PreparedReview } from './poster' |
| 13 | 15 | import { prepareReview } from './poster' |
| @@ -23,6 +25,14 @@ export interface RunReviewOptions { | ||
| 23 | 25 | summaryOnly?: boolean |
| 24 | 26 | /** Skip when the head commit was already reviewed */ |
| 25 | 27 | skipIfReviewed?: boolean |
| 28 | /** | |
| 29 | * Whether Buddy chose to review or was asked to (default: `requested`). | |
| 30 | * | |
| 31 | * Defaults to the permissive value so a caller that has not been taught the | |
| 32 | * distinction keeps working: an unasked-for filter should never silently | |
| 33 | * swallow a review someone requested. | |
| 34 | */ | |
| 35 | trigger?: ReviewTrigger | |
| 26 | 36 | logger?: Logger |
| 27 | 37 | } |
| 28 | 38 | |
| @@ -45,6 +55,14 @@ export async function runReviewForPR(options: RunReviewOptions): Promise<string> | ||
| 45 | 55 | if (!pr) |
| 46 | 56 | return `Could not find open pull request #${prNumber}.` |
| 47 | 57 | |
| 58 | // Checked before the diff is fetched or a model is contacted, so an ignored | |
| 59 | // pull request costs one API call rather than a review's worth of tokens. | |
| 60 | const skip = reviewSkipReason(config, pr, options.trigger ?? 'requested') | |
| 61 | if (skip) { | |
| 62 | logger.info(`🔍 Skipping PR #${prNumber}: ${skip}`) | |
| 63 | return skip | |
| 64 | } | |
| 65 | ||
| 48 | 66 | const state = parseReviewState(pr.body) |
| 49 | 67 | if (state?.paused && !options.full) |
| 50 | 68 | return 'Reviews are paused on this pull request. Say `@buddy resume` to restart them.' |
| @@ -1587,10 +1587,12 @@ ${generateComposerSetupSteps()} | ||
| 1587 | 1587 | - name: Install dependencies |
| 1588 | 1588 | run: bun install |
| 1589 | 1589 | |
| 1590 | # Skips itself when ai.review.enabled is false, so adding the trigger | |
| 1591 | # does not turn reviews on by itself. | |
| 1590 | # --auto honours the ai.review filters: enabled, autoReview, drafts, | |
| 1591 | # ignoreTitleKeywords and ignoreUsernames. Adding the trigger therefore | |
| 1592 | # does not turn reviews on by itself, and a draft or WIP pull request | |
| 1593 | # costs one API call rather than a full review. | |
| 1592 | 1594 | - name: Review pull request |
| 1593 | run: bunx @buddysh/buddy review \${{ github.event.pull_request.number }} --verbose | |
| 1595 | run: bunx @buddysh/buddy review \${{ github.event.pull_request.number }} --auto --verbose | |
| 1594 | 1596 | env: |
| 1595 | 1597 | GITHUB_TOKEN: \${{ secrets.GITHUB_TOKEN }} |
| 1596 | 1598 | ANTHROPIC_API_KEY: \${{ secrets.ANTHROPIC_API_KEY }} |
| @@ -298,7 +298,14 @@ export interface BuddyConfig { | ||
| 298 | 298 | |
| 299 | 299 | /** AI pull request review settings */ |
| 300 | 300 | review?: { |
| 301 | /** Enable AI review (default: false until you opt in) */ | |
| 301 | /** | |
| 302 | * Set to `false` to switch AI review off for this repository, including | |
| 303 | * when someone asks with `@buddy review`. | |
| 304 | * | |
| 305 | * Leaving it unset does not mean off: review already requires both a | |
| 306 | * workflow trigger and a provider key, so this is the override, not the | |
| 307 | * opt-in. | |
| 308 | */ | |
| 302 | 309 | enabled?: boolean |
| 303 | 310 | /** |
| 304 | 311 | * How thorough to be. `chill` reports only confident defects; |
| @@ -306,11 +313,14 @@ export interface BuddyConfig { | ||
| 306 | 313 | * be filtered downstream. |
| 307 | 314 | */ |
| 308 | 315 | profile?: 'chill' | 'assertive' |
| 309 | /** Review draft pull requests too (default: false) */ | |
| 316 | /** Review draft pull requests on an automatic trigger (default: false) */ | |
| 310 | 317 | drafts?: boolean |
| 311 | /** Review automatically on open and push (default: true when enabled) */ | |
| 318 | /** Review automatically on open and push (default: true) */ | |
| 312 | 319 | autoReview?: boolean |
| 313 | /** Skip pull requests whose title contains any of these, e.g. `wip` */ | |
| 320 | /** | |
| 321 | * Skip pull requests whose title contains any of these, e.g. `wip`. | |
| 322 | * Matched case-insensitively as a substring. | |
| 323 | */ | |
| 314 | 324 | ignoreTitleKeywords?: string[] |
| 315 | 325 | /** Skip pull requests opened by these users */ |
| 316 | 326 | ignoreUsernames?: string[] |
| @@ -0,0 +1,103 @@ | ||
| 1 | import type { BuddyConfig } from '../src/types' | |
| 2 | import { describe, expect, it } from 'bun:test' | |
| 3 | import { reviewSkipReason } from '../src/review/filters' | |
| 4 | ||
| 5 | /** | |
| 6 | * These five settings were typed, validated and documented long before | |
| 7 | * anything read them, so a repository that set `ai.review.enabled: false` | |
| 8 | * still got reviews. | |
| 9 | */ | |
| 10 | describe('review filters', () => { | |
| 11 | const pr = { title: 'Add retry to the fetch helper', author: 'alice', draft: false } | |
| 12 | ||
| 13 | /** Config carrying only the review settings under test. */ | |
| 14 | function withReview(review: NonNullable<NonNullable<BuddyConfig['ai']>['review']>): BuddyConfig { | |
| 15 | return { repository: { provider: 'github', owner: 'stacksjs', name: 'buddy' }, ai: { review } } | |
| 16 | } | |
| 17 | ||
| 18 | describe('enabled', () => { | |
| 19 | it('success case - an unset flag reviews, rather than silently refusing', () => { | |
| 20 | // Reviews already need a workflow trigger and a provider key. Treating | |
| 21 | // unset as off would disable review for every existing repository. | |
| 22 | expect(reviewSkipReason(withReview({}), pr, 'automatic')).toBeNull() | |
| 23 | expect(reviewSkipReason(withReview({}), pr, 'requested')).toBeNull() | |
| 24 | }) | |
| 25 | ||
| 26 | it('failure case - false refuses even an explicit request', () => { | |
| 27 | // `enabled` describes the repository, not one pull request, so asking | |
| 28 | // does not override it. | |
| 29 | const config = withReview({ enabled: false }) | |
| 30 | ||
| 31 | expect(reviewSkipReason(config, pr, 'automatic')).toContain('disabled') | |
| 32 | expect(reviewSkipReason(config, pr, 'requested')).toContain('disabled') | |
| 33 | }) | |
| 34 | }) | |
| 35 | ||
| 36 | describe('autoReview', () => { | |
| 37 | it('failure case - false stops the automatic trigger', () => { | |
| 38 | expect(reviewSkipReason(withReview({ autoReview: false }), pr, 'automatic')).toContain('Automatic review is off') | |
| 39 | }) | |
| 40 | ||
| 41 | it('success case - an explicit request still works', () => { | |
| 42 | expect(reviewSkipReason(withReview({ autoReview: false }), pr, 'requested')).toBeNull() | |
| 43 | }) | |
| 44 | }) | |
| 45 | ||
| 46 | describe('drafts', () => { | |
| 47 | const draft = { ...pr, draft: true } | |
| 48 | ||
| 49 | it('failure case - drafts are skipped on an automatic trigger by default', () => { | |
| 50 | expect(reviewSkipReason(withReview({}), draft, 'automatic')).toContain('draft') | |
| 51 | }) | |
| 52 | ||
| 53 | it('success case - opting in reviews them', () => { | |
| 54 | expect(reviewSkipReason(withReview({ drafts: true }), draft, 'automatic')).toBeNull() | |
| 55 | }) | |
| 56 | ||
| 57 | it('success case - asking for a review of a draft overrides the default', () => { | |
| 58 | expect(reviewSkipReason(withReview({}), draft, 'requested')).toBeNull() | |
| 59 | }) | |
| 60 | }) | |
| 61 | ||
| 62 | describe('ignoreTitleKeywords', () => { | |
| 63 | it('failure case - matches case-insensitively as a substring', () => { | |
| 64 | // `wip` has to catch `WIP:` and `[wip]` without anyone writing a regex. | |
| 65 | const config = withReview({ ignoreTitleKeywords: ['wip'] }) | |
| 66 | ||
| 67 | expect(reviewSkipReason(config, { ...pr, title: 'WIP: refactor' }, 'automatic')).toContain('wip') | |
| 68 | expect(reviewSkipReason(config, { ...pr, title: '[wip] refactor' }, 'automatic')).toContain('wip') | |
| 69 | }) | |
| 70 | ||
| 71 | it('success case - an unrelated title passes', () => { | |
| 72 | expect(reviewSkipReason(withReview({ ignoreTitleKeywords: ['wip'] }), pr, 'automatic')).toBeNull() | |
| 73 | }) | |
| 74 | ||
| 75 | it('edge case - an empty keyword does not match everything', () => { | |
| 76 | // A blank entry left in a config array would otherwise skip every PR. | |
| 77 | expect(reviewSkipReason(withReview({ ignoreTitleKeywords: ['', ' '] }), pr, 'automatic')).toBeNull() | |
| 78 | }) | |
| 79 | }) | |
| 80 | ||
| 81 | describe('ignoreUsernames', () => { | |
| 82 | it('failure case - skips a listed author, ignoring case', () => { | |
| 83 | const config = withReview({ ignoreUsernames: ['Dependabot[bot]'] }) | |
| 84 | ||
| 85 | expect(reviewSkipReason(config, { ...pr, author: 'dependabot[bot]' }, 'automatic')).toContain('dependabot[bot]') | |
| 86 | }) | |
| 87 | ||
| 88 | it('success case - an unlisted author passes', () => { | |
| 89 | expect(reviewSkipReason(withReview({ ignoreUsernames: ['bob'] }), pr, 'automatic')).toBeNull() | |
| 90 | }) | |
| 91 | ||
| 92 | it('edge case - a partial match is not a match', () => { | |
| 93 | // Substring matching here would let `bob` ignore `bobby`. | |
| 94 | expect(reviewSkipReason(withReview({ ignoreUsernames: ['ali'] }), pr, 'automatic')).toBeNull() | |
| 95 | }) | |
| 96 | }) | |
| 97 | ||
| 98 | it('edge case - no ai config at all reviews normally', () => { | |
| 99 | const config: BuddyConfig = { repository: { provider: 'github', owner: 'stacksjs', name: 'buddy' } } | |
| 100 | ||
| 101 | expect(reviewSkipReason(config, pr, 'automatic')).toBeNull() | |
| 102 | }) | |
| 103 | }) | |