-
Notifications
You must be signed in to change notification settings - Fork 2.9k
262 lines (237 loc) · 12.7 KB
/
Copy pathpr-approval-agent.yml
File metadata and controls
262 lines (237 loc) · 12.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
name: PR Approval Agent
on:
pull_request:
types: [labeled, ready_for_review, synchronize]
permissions:
contents: read
pull-requests: write
concurrency:
group: pr-approval-${{ github.event.pull_request.number }}
cancel-in-progress: true
jobs:
review:
# Write access is required to apply the stamphog label, so no
# additional author_association check is needed.
# Triggers: explicit `stamphog` label, ready_for_review with the
# label already present, or `synchronize` where decide-delta
# asked for re-review (or itself failed — fail closed for safety).
needs: [decide-delta, dismiss]
if: >-
always()
&& !github.event.pull_request.draft
&& (
github.event.label.name == 'stamphog'
|| (github.event.action == 'ready_for_review' && contains(github.event.pull_request.labels.*.name, 'stamphog'))
|| needs.decide-delta.outputs.run_review == 'true'
|| needs.decide-delta.result == 'failure'
)
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Get app token
id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ secrets.GH_APP_PR_APPROVAL_AGENT_APP_ID }}
private-key: ${{ secrets.GH_APP_PR_APPROVAL_AGENT_PRIVATE_KEY }}
# Always run the approval script from master — hardcoded so a PR
# targeting a non-master branch can't supply a tampered script.
- name: Checkout master (blobless, full history)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.app-token.outputs.token }}
ref: master
filter: blob:none
fetch-depth: 0
- name: Fetch PR head ref
run: git fetch --filter=blob:none origin pull/${{ github.event.pull_request.number }}/head
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
version: '0.11.14' # pinned: unpinned setup-uv calls GH API on every job, exhausts rate limit
enable-cache: false
- name: Run review
env:
# Dedicated key so a rotation of the shared ANTHROPIC_API_KEY
# doesn't break stamphog (and vice versa). The env var name
# stays ANTHROPIC_API_KEY — that's what the Claude Agent SDK reads.
ANTHROPIC_API_KEY: ${{ secrets.STAMPHOG_ANTHROPIC_API_KEY }}
POSTHOG_API_KEY: ${{ secrets.POSTHOG_API_TOKEN }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
uv run tools/pr-approval-agent/review_pr.py \
${{ github.event.pull_request.number }} \
--repo ${{ github.repository }} \
--output-json /tmp/review.json
- name: Post review
if: always()
env:
# Use GITHUB_TOKEN for approvals so github-actions[bot] is the
# reviewer — its approvals count toward branch protection rules,
# unlike GitHub App bot approvals which show author_association NONE.
GH_TOKEN_APPROVE: ${{ github.token }}
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
PR=${{ github.event.pull_request.number }}
REPO=${{ github.repository }}
VERDICT=$(jq -r '.final_verdict // ""' /tmp/review.json 2>/dev/null || echo "")
REASONING=$(jq -r '.reviewer.reasoning // ""' /tmp/review.json 2>/dev/null || echo "")
REVIEWED_SHA=$(jq -r '.head_sha // ""' /tmp/review.json 2>/dev/null || echo "")
# Lock the review to the sha the LLM actually saw — `gh pr
# review` records against the head at API-call time, which
# drifts mid-LLM-roundtrip if the author force-pushes.
SHA_ARGS=()
if [ -n "$REVIEWED_SHA" ]; then
SHA_ARGS=(-f "commit_id=$REVIEWED_SHA")
fi
if [ "$VERDICT" = "APPROVED" ]; then
GH_TOKEN="$GH_TOKEN_APPROVE" gh api \
-X POST "repos/$REPO/pulls/$PR/reviews" \
"${SHA_ARGS[@]}" \
-f event=APPROVE \
-f body="$REASONING"
elif [ -n "$REASONING" ]; then
gh api \
-X POST "repos/$REPO/pulls/$PR/reviews" \
"${SHA_ARGS[@]}" \
-f event=COMMENT \
-f body="$REASONING"
else
gh pr comment "$PR" \
--body "Review agent failed — check the [workflow run](${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}) and re-apply the label to retry." \
--repo "$REPO"
fi
# A substantive non-APPROVED verdict (REFUSE/ESCALATE)
# removes the label, breaking the auto-rerun loop until a
# human re-applies it after addressing the feedback. ERROR
# means the review agent couldn't reach its LLM backend
# (auth/credit/outage) — that's not a verdict on the PR, so
# keep the label and let it retry rather than silently
# dropping it across every queued PR during an outage.
if [ "$VERDICT" != "APPROVED" ] && [ "$VERDICT" != "ERROR" ]; then
gh pr edit "$PR" --remove-label stamphog \
--repo "$REPO"
fi
- name: Upload evidence
if: always()
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6.0.0
with:
name: review-${{ github.event.pull_request.number }}
path: /tmp/review.json
retention-days: 30
# Defense-in-depth: master ruleset has dismiss_stale_reviews_on_push=false
# and require_last_push_approval=false, so a stale bot approval could
# otherwise inherit malicious commits. Two-step gate: decide-delta
# classifies the new commits since the last bot approval, dismiss only
# runs when the delta is non-trivial. Trivial deltas (test/docs/lockfile
# /generated paths and clean merges from the base branch) retain the
# prior approval — a comment on the PR records the reason. The stamphog
# label stays sticky across pushes; the review job's existing
# non-APPROVED label-strip is the auto-loop's escape hatch.
decide-delta:
if: >-
github.event.action == 'synchronize'
&& !github.event.pull_request.draft
&& contains(github.event.pull_request.labels.*.name, 'stamphog')
runs-on: ubuntu-latest
timeout-minutes: 5
outputs:
dismiss_approval: ${{ steps.decide.outputs.dismiss_approval }}
run_review: ${{ steps.decide.outputs.run_review }}
reason: ${{ steps.decide.outputs.reason }}
last_approved_sha: ${{ steps.decide.outputs.last_approved_sha }}
steps:
- name: Get app token
id: app-token
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
with:
client-id: ${{ secrets.GH_APP_PR_APPROVAL_AGENT_APP_ID }}
private-key: ${{ secrets.GH_APP_PR_APPROVAL_AGENT_PRIVATE_KEY }}
- name: Checkout master (full history)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ steps.app-token.outputs.token }}
ref: master
filter: blob:none
fetch-depth: 0
- name: Fetch PR head
run: git fetch --filter=blob:none origin pull/${{ github.event.pull_request.number }}/head
- name: Install uv
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
version: '0.11.14' # pinned: unpinned setup-uv calls GH API on every job, exhausts rate limit
enable-cache: false
- name: Decide retain vs dismiss
id: decide
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
BASE_REF: origin/${{ github.event.pull_request.base.ref }}
run: |
set -euo pipefail
decision=$(uv run tools/pr-approval-agent/dismiss_check.py)
echo "$decision"
echo "dismiss_approval=$(echo "$decision" | jq -r .dismiss_approval)" >> "$GITHUB_OUTPUT"
echo "run_review=$(echo "$decision" | jq -r .run_review)" >> "$GITHUB_OUTPUT"
echo "reason=$(echo "$decision" | jq -r .reason)" >> "$GITHUB_OUTPUT"
echo "last_approved_sha=$(echo "$decision" | jq -r '.last_approved_sha // ""')" >> "$GITHUB_OUTPUT"
# Only post the comment on actual retention reasons — not on
# no_prior_approval (nothing to retain) or empty_delta (HEAD
# didn't move, comment would be noise).
- name: Note retained approval
if: contains(fromJSON('["trivial_paths", "merge_only", "mixed_trivial"]'), steps.decide.outputs.reason)
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
PR: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
REASON: ${{ steps.decide.outputs.reason }}
run: |
gh pr comment "$PR" --repo "$REPO" \
--body "Retaining stamphog approval — delta since last review classified as \`$REASON\`."
dismiss:
needs: decide-delta
# Fail closed on three cases:
# - decide-delta said dismiss (smart path)
# - decide-delta failed (uv install / checkout / fetch timeout)
# - decide-delta was skipped (label removed out-of-band) — mirrors
# the pre-PR unconditional dismiss-on-push behavior so a stale
# bot approval can't outlive the label under master ruleset's
# dismiss_stale_reviews_on_push=false / require_last_push_approval=false
# Explicit synchronize + draft gates stop spurious dismissal on
# labeled / ready_for_review events where decide-delta's result is
# also 'skipped'.
if: >-
always()
&& github.event.action == 'synchronize'
&& !github.event.pull_request.draft
&& (
needs.decide-delta.outputs.dismiss_approval == 'true'
|| needs.decide-delta.result == 'failure'
|| needs.decide-delta.result == 'skipped'
)
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- name: Dismiss stale bot approvals
env:
# Same identity (github-actions[bot]) that posted the approval.
GH_TOKEN: ${{ github.token }}
PR: ${{ github.event.pull_request.number }}
REPO: ${{ github.repository }}
REASON: ${{ needs.decide-delta.outputs.reason || (needs.decide-delta.result == 'skipped' && 'label_absent') || 'decide_delta_failed' }}
run: |
set -euo pipefail
# Only dismiss APPROVED reviews made by github-actions[bot] —
# human reviews and non-approval reviews are untouched.
mapfile -t REVIEW_IDS < <(
gh api "repos/$REPO/pulls/$PR/reviews" --paginate \
--jq '.[] | select(.user.login == "github-actions[bot]" and .state == "APPROVED") | .id'
)
for id in "${REVIEW_IDS[@]}"; do
[ -z "$id" ] && continue
gh api -X PUT "repos/$REPO/pulls/$PR/reviews/$id/dismissals" \
-f message="New commits pushed (delta classified \`$REASON\`) — stamphog approval dismissed; re-review running automatically." \
-f event=DISMISS
done