|
| 1 | +# Factory approve — auto-approval for trivial PRs |
| 2 | + |
| 3 | +Auto-approves very simple PRs (copy changes, styling, small self-contained tweaks) so they |
| 4 | +don't need another engineer's review. Deterministic safety gates run first; only if they all |
| 5 | +pass does Claude judge the diff, and only a unanimous `approve` makes the `apify-factory` |
| 6 | +account post an approving review. It never requests changes and never merges. |
| 7 | + |
| 8 | +## How to use |
| 9 | + |
| 10 | +Add the `factory-approve` label to a PR against `develop` (you can add it on a draft — it |
| 11 | +waits until the PR is ready). The label is a human opt-in flag the bot never touches: while |
| 12 | +it's on, every push is re-reviewed; remove it to opt out. |
| 13 | + |
| 14 | +- **Approve** → `apify-factory` posts an approving review, locked to the reviewed commit. |
| 15 | +- **Reject / error** → `apify-factory` posts the report as a new PR comment and any stale |
| 16 | + factory approval is dismissed. The label stays on, so the next push re-reviews. |
| 17 | + |
| 18 | +Two situations make a run stand down silently (no review, no comment, no LLM cost): |
| 19 | + |
| 20 | +- **A human review is active** — an approval means the factory has nothing to add; a |
| 21 | + changes-requested means a human owns the review conversation now. |
| 22 | +- **The content is unchanged since the last factory verdict** — the diff (with hunk line |
| 23 | + numbers normalized away) and title are fingerprinted into each posted verdict, so |
| 24 | + develop-syncs, rebases, and empty pushes don't trigger a paid re-review. Any real content |
| 25 | + change (including whitespace) produces a new fingerprint and a full review, and policy |
| 26 | + changes invalidate all stored fingerprints. |
| 27 | + |
| 28 | +Every outcome is rendered with the same fixed template (`scripts/report.mts`): status, the |
| 29 | +reviewer's one-sentence reason, a gates/reviewers result table with a link to the run, and — |
| 30 | +for non-approvals — a collapsed "Details and next steps" section with the rejecting |
| 31 | +reviewer's full explanation. Each run folds the previous report comment as outdated instead |
| 32 | +of editing or deleting it, so the timeline stays clean and the history stays honest. The bot |
| 33 | +never edits the PR description. |
| 34 | + |
| 35 | +## How it works |
| 36 | + |
| 37 | +1. **Static gates** (`scripts/prepare_review.mts`) — all must pass or the PR is rejected |
| 38 | + without calling Claude: trusted author + trigger, open & mergeable, targets `develop`, |
| 39 | + ≤5 files / ≤100 lines, JS/TS only, no denied paths, no risky added lines, Conventional |
| 40 | + Commit title. |
| 41 | +2. **LLM verdict** (`anthropics/claude-code-action`, run twice — two independent reviewers, |
| 42 | + the second adversarial; both must approve). Each judges whether the change needs a human |
| 43 | + (databases, security, money, config, public contracts, infra, privacy) and is free of |
| 44 | + correctness bugs, then writes a strict `{"verdict","reason"}` file. The model can only read |
| 45 | + the code and write its verdict — it cannot touch the PR. |
| 46 | +3. **Post** (`scripts/post_verdict.mts`) — the only place GitHub is written to. Approves as |
| 47 | + `apify-factory` (with a separate token), or dismisses stale approvals and posts the report |
| 48 | + as a new comment (folding older report comments as outdated). Fails closed: any crash, |
| 49 | + missing/invalid verdict, or unknown state → no approval. |
| 50 | + |
| 51 | +## Configure |
| 52 | + |
| 53 | +The built-in policy (`scripts/policy.mts`) is the generic org-wide baseline: base `develop`, |
| 54 | +≤5 files / ≤100 lines, `.js`/`.ts` only (no `.json`), modifications plus added test files, |
| 55 | +Conventional Commit titles, authors and actors from `apify/product-engineering`, and two |
| 56 | +reviewers (`claude-sonnet-5` + `claude-opus-4-8`, the second adversarial). |
| 57 | + |
| 58 | +A consuming repository tunes it through the optional `policy` input — a JSON document of |
| 59 | +overrides in the workflow file: |
| 60 | + |
| 61 | +```yaml |
| 62 | +- uses: apify/actions/factory-approve@v1 |
| 63 | + with: |
| 64 | + # ...tokens... |
| 65 | + policy: | |
| 66 | + { |
| 67 | + "baseBranch": "main", |
| 68 | + "denyGlobs": ["infra/**", "**/billing/**"], |
| 69 | + "authorGate": { "teamSlugs": ["tooling"] } |
| 70 | + } |
| 71 | +``` |
| 72 | +
|
| 73 | +Overrides can tighten anything, but can only loosen what is explicitly loosenable: |
| 74 | +
|
| 75 | +- **Replaceable**: `label`, `factoryLogin`, `baseBranch`, `allowedExtensions`, |
| 76 | + `allowedAddedFileGlobs`, `prTitleRegex` (as a string), `authorGate.org`, `authorGate.teamSlugs`, |
| 77 | + `authorGate.extraUsers`, `llm.reviewerModels` (1–2 models; the last is adversarial), and |
| 78 | + `denyGlobs` — the repo tier only. A core tier of supply-chain paths (`.github/**`, dependency |
| 79 | + manifests, lockfiles, env files, Dockerfiles, migrations, secrets) is always kept. |
| 80 | +- **Clamped**: `maxChangedFiles` (≤10), `maxChangedLines` (≤300), `llm.maxTurns` (≤50), |
| 81 | + `llm.maxDiffChars` (≤120000), `llm.maxReasonChars` (≤600), `llm.maxDetailsChars` (≤4000). |
| 82 | + Values above a ceiling are config errors, not silent clamps. |
| 83 | +- **Append-only**: `denyGlobsAdd`, `riskyContentPatternsAdd` (`{ id, description, regex }`, regex |
| 84 | + as a string), `authorGate.deniedUsersAdd`. The built-in patterns and denied accounts can never |
| 85 | + be removed, and `factoryLogin` is always denied. |
| 86 | + |
| 87 | +Everything else — the static check set, unanimity, fail-closed semantics, the report format, the |
| 88 | +comment lifecycle — is not configurable. Unknown keys, wrong types, or out-of-range values fail |
| 89 | +closed: the run reports "could not finish" and approves nothing, at zero LLM cost. Changing |
| 90 | +overrides also changes the review fingerprint, so previously memoized verdicts get a fresh |
| 91 | +review. Full design rationale: [docs/policy-overrides-spec.md](docs/policy-overrides-spec.md). |
| 92 | + |
| 93 | +The reviewer's instructions (what needs a human vs. what's approvable) live in |
| 94 | +`scripts/prompt.mts` and are deliberately not overridable. |
| 95 | + |
| 96 | +## Setup |
| 97 | + |
| 98 | +1. **Secrets**: `APIFY_FACTORY_GITHUB_TOKEN` (the `apify-factory` account, `repo` + `read:org`) |
| 99 | + and `FACTORY_APPROVE_ANTHROPIC_API_KEY` (Anthropic key). |
| 100 | +2. **Label**: create `factory-approve` in the repo. |
| 101 | +3. **Branch protection**: confirm one `apify-factory` approval actually makes these PRs |
| 102 | + mergeable, and enable "dismiss stale approvals when new commits are pushed". |
| 103 | + |
| 104 | +## Testing |
| 105 | + |
| 106 | +Replay the whole pipeline (static gates + the dual-reviewer LLM step) over recent human PRs before |
| 107 | +rolling out — reads only, posts nothing. Requires the `claude` CLI installed and authenticated; run |
| 108 | +it from a `develop` checkout so the reviewer's Read/Grep context matches CI: |
| 109 | + |
| 110 | +```bash |
| 111 | +GITHUB_TOKEN=… FACTORY_GITHUB_TOKEN=… \ |
| 112 | + node backtest/backtest.mts --repo apify/apify-core --last 200 |
| 113 | +``` |
| 114 | + |
| 115 | +Add `--output results.jsonl` to record a per-PR line for later inspection, and |
| 116 | +`--policy overrides.json` to replay the exact JSON document a repo would pass to the `policy` |
| 117 | +input before enabling it. |
0 commit comments