Skip to content

Commit b2e7fac

Browse files
committed
chore: require human approvals for bot-created prs
1 parent 7d100dc commit b2e7fac

1 file changed

Lines changed: 84 additions & 0 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
name: Bot PR human approval gate
2+
3+
on:
4+
pull_request_target:
5+
types: [opened, reopened, ready_for_review, synchronize]
6+
pull_request_review:
7+
types: [submitted, dismissed]
8+
9+
concurrency:
10+
group: bot-pr-human-approval-${{ github.event.pull_request.number }}
11+
cancel-in-progress: true
12+
13+
permissions:
14+
pull-requests: read
15+
16+
jobs:
17+
require-human-approvals:
18+
name: Require 2 human approvals for bot PRs
19+
runs-on: ubuntu-latest
20+
steps:
21+
- name: Evaluate bot PR approvals
22+
uses: actions/github-script@v7
23+
with:
24+
script: |
25+
const minimumHumanApprovals = 2
26+
const pullRequest = context.payload.pull_request
27+
28+
if (!pullRequest) {
29+
core.setFailed("This workflow requires a pull request payload.")
30+
return
31+
}
32+
33+
const authorLogin = pullRequest.user?.login ?? "unknown"
34+
const isBotAuthor =
35+
pullRequest.user?.type === "Bot" || authorLogin.endsWith("[bot]")
36+
37+
if (!isBotAuthor) {
38+
core.notice(`Skipping approval gate because ${authorLogin} is not a bot account.`)
39+
return
40+
}
41+
42+
if (pullRequest.draft) {
43+
core.notice(`Skipping approval gate for draft bot PR #${pullRequest.number}.`)
44+
return
45+
}
46+
47+
const reviews = await github.paginate(github.rest.pulls.listReviews, {
48+
owner: context.repo.owner,
49+
repo: context.repo.repo,
50+
pull_number: pullRequest.number,
51+
per_page: 100,
52+
})
53+
54+
const opinionatedStates = new Set(["APPROVED", "CHANGES_REQUESTED", "DISMISSED"])
55+
const latestOpinionatedReviewByUser = new Map()
56+
57+
for (const review of reviews) {
58+
if (!review.user?.login || !review.submitted_at || !opinionatedStates.has(review.state)) {
59+
continue
60+
}
61+
62+
const priorReview = latestOpinionatedReviewByUser.get(review.user.login)
63+
if (!priorReview || new Date(review.submitted_at) >= new Date(priorReview.submitted_at)) {
64+
latestOpinionatedReviewByUser.set(review.user.login, review)
65+
}
66+
}
67+
68+
const humanApprovals = [...latestOpinionatedReviewByUser.values()].filter((review) => {
69+
return review.state === "APPROVED" && review.user?.type === "User"
70+
})
71+
72+
if (humanApprovals.length < minimumHumanApprovals) {
73+
const approverList =
74+
humanApprovals.map((review) => `@${review.user.login}`).join(", ") || "none yet"
75+
76+
core.setFailed(
77+
`Bot PR #${pullRequest.number} has ${humanApprovals.length}/${minimumHumanApprovals} human approvals. Current human approvers: ${approverList}.`,
78+
)
79+
return
80+
}
81+
82+
core.notice(
83+
`Bot PR #${pullRequest.number} has ${humanApprovals.length} human approvals and satisfies the gate.`,
84+
)

0 commit comments

Comments
 (0)