Skip to content
This repository was archived by the owner on Apr 15, 2026. It is now read-only.

Commit 7a91971

Browse files
committed
chore: add review enforcement workflow for product team
1 parent d2b6daa commit 7a91971

2 files changed

Lines changed: 269 additions & 0 deletions

File tree

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/**
2+
* Finds an open Dependabot PR from a workflow_run event.
3+
* Iterates all associated PRs (not just the first) to handle
4+
* cases where multiple PRs are linked to a single workflow run.
5+
*
6+
* @param {object} options
7+
* @param {object} options.github - The octokit github client
8+
* @param {object} options.context - The actions context
9+
* @param {object} options.core - The actions core toolkit
10+
* @returns {Promise<{prNumber: number, pr: object} | null>}
11+
*/
12+
module.exports = async function getDependabotPr({ github, context, core }) {
13+
const workflowRun = context.payload.workflow_run;
14+
const prs = workflowRun.pull_requests || [];
15+
16+
if (prs.length === 0) {
17+
core.info("No pull requests associated with this workflow run.");
18+
return null;
19+
}
20+
21+
for (const candidate of prs) {
22+
const { data: pr } = await github.rest.pulls.get({
23+
owner: context.repo.owner,
24+
repo: context.repo.repo,
25+
pull_number: candidate.number,
26+
});
27+
if (pr.user?.login === "dependabot[bot]" && pr.state === "open") {
28+
return { prNumber: candidate.number, pr };
29+
}
30+
}
31+
32+
return null;
33+
};
Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,236 @@
1+
name: Enforce Review Policy
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize, reopened]
6+
pull_request_review:
7+
types: [submitted, dismissed]
8+
workflow_run:
9+
workflows: ["Test"]
10+
types: [completed]
11+
12+
jobs:
13+
enforce-review:
14+
runs-on: ubuntu-latest
15+
if: github.event_name == 'pull_request' || github.event_name == 'pull_request_review'
16+
permissions:
17+
pull-requests: write
18+
steps:
19+
- name: Enforce review policy
20+
uses: actions/github-script@v7
21+
with:
22+
script: |
23+
const pr = context.payload.pull_request;
24+
const event = context.eventName;
25+
const isDependabot = pr.user?.login === 'dependabot[bot]';
26+
27+
// Skip for Dependabot on all events
28+
if (isDependabot) {
29+
core.info('Dependabot PR detected - skipping review enforcement.');
30+
return;
31+
}
32+
33+
// On opened, no reviews exist yet - pass without enforcing
34+
if (event === 'pull_request' && context.payload.action === 'opened') {
35+
core.info('PR opened - waiting for review, not enforcing yet.');
36+
return;
37+
}
38+
39+
// On synchronize or pull_request_review: check current approval status
40+
core.info(`${event}/${context.payload.action} - checking current approval status...`);
41+
42+
// If this is a review submission event, check the review in the payload first
43+
if (event === 'pull_request_review') {
44+
const submittedReview = context.payload.review;
45+
if (submittedReview?.state === 'APPROVED') {
46+
const isNonBazReviewer = submittedReview.user?.login !== 'baz-reviewer' && submittedReview.user?.type === 'User';
47+
if (isNonBazReviewer) {
48+
core.info(`Approval received from ${submittedReview.user.login} - approval requirement satisfied.`);
49+
return;
50+
}
51+
}
52+
if (submittedReview?.state === 'DISMISSED') {
53+
core.info('Review dismissed - will check if other approvals exist.');
54+
}
55+
}
56+
57+
// Fetch reviews with retry for eventual consistency
58+
let reviews;
59+
let attempts = 0;
60+
while (attempts < 3) {
61+
const result = await github.rest.pulls.listReviews({
62+
owner: context.repo.owner,
63+
repo: context.repo.repo,
64+
pull_number: pr.number
65+
});
66+
reviews = result.data;
67+
68+
// If we have reviews or this is not a review event, stop retrying
69+
if (reviews.length > 0 || event !== 'pull_request_review') {
70+
break;
71+
}
72+
73+
// Wait before retry
74+
attempts++;
75+
if (attempts < 3) {
76+
core.info(`No reviews found yet (attempt ${attempts}/3) - retrying...`);
77+
await new Promise(resolve => setTimeout(resolve, 1000));
78+
}
79+
}
80+
81+
const approvals = reviews.filter(r => r.state === 'APPROVED');
82+
83+
// On synchronize/reopened, if no reviews exist yet, don't block the PR
84+
if (event === 'pull_request' && reviews.length === 0) {
85+
core.info('No reviews yet - waiting for review, not enforcing.');
86+
return;
87+
}
88+
89+
const hasNonBazApproval = approvals.some(
90+
r => r.user?.login &&
91+
r.user.login !== 'baz-reviewer' &&
92+
r.user.type === 'User'
93+
);
94+
95+
if (!hasNonBazApproval) {
96+
core.setFailed('At least one approval from a non-baz-reviewer is required.');
97+
}
98+
99+
dependabot-auto-merge:
100+
runs-on: ubuntu-latest
101+
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'success'
102+
# Note: permissions here scope the default GITHUB_TOKEN, but this job uses
103+
# GALILEO_AUTOMATION_GITHUB_TOKEN (a PAT) whose scopes are managed separately.
104+
permissions:
105+
pull-requests: write
106+
contents: write
107+
steps:
108+
- name: Checkout repository
109+
uses: actions/checkout@v4
110+
- name: Auto-approve and merge Dependabot PRs
111+
uses: actions/github-script@v7
112+
with:
113+
github-token: ${{ secrets.GALILEO_AUTOMATION_GITHUB_TOKEN }}
114+
script: |
115+
const getDependabotPr = require('./.github/scripts/get-dependabot-pr.js');
116+
const result = await getDependabotPr({ github, context, core });
117+
if (!result) {
118+
core.info('No open Dependabot PR found - skipping auto-merge.');
119+
return;
120+
}
121+
const { prNumber, pr } = result;
122+
123+
core.info(`Dependabot PR #${prNumber} CI passed - auto-approving and merging.`);
124+
125+
// Check for existing approval to keep this idempotent
126+
const { data: authUser } = await github.rest.users.getAuthenticated();
127+
const { data: reviews } = await github.rest.pulls.listReviews({
128+
owner: context.repo.owner,
129+
repo: context.repo.repo,
130+
pull_number: prNumber,
131+
});
132+
const hasExistingApproval = reviews.some(
133+
r => r.user?.login === authUser.login && r.state === 'APPROVED'
134+
);
135+
136+
if (hasExistingApproval) {
137+
core.info(`PR #${prNumber} already approved by ${authUser.login} - skipping approval.`);
138+
} else {
139+
await github.rest.pulls.createReview({
140+
owner: context.repo.owner,
141+
repo: context.repo.repo,
142+
pull_number: prNumber,
143+
event: 'APPROVE',
144+
body: 'Auto-approved: Dependabot PR with passing CI.'
145+
});
146+
core.info(`Auto-approved PR #${prNumber}`);
147+
}
148+
149+
// Re-fetch PR state after approval and wait for it to stabilize
150+
// (GitHub may return 'unknown' initially, or the state may still be stale)
151+
let mergeablePr;
152+
for (let attempt = 0; attempt < 5; attempt++) {
153+
await new Promise(resolve => setTimeout(resolve, 3000));
154+
const { data: refreshed } = await github.rest.pulls.get({
155+
owner: context.repo.owner,
156+
repo: context.repo.repo,
157+
pull_number: prNumber
158+
});
159+
mergeablePr = refreshed;
160+
core.info(`Attempt ${attempt + 1}/5: mergeable=${mergeablePr.mergeable}, mergeable_state=${mergeablePr.mergeable_state}`);
161+
if (mergeablePr.mergeable_state !== 'unknown') break;
162+
}
163+
164+
if (mergeablePr.mergeable === false || mergeablePr.mergeable_state === 'dirty') {
165+
core.info(`PR #${prNumber} is not mergeable (state: ${mergeablePr.mergeable_state}) - requesting human review.`);
166+
const requestProductReviewer = require('./.github/scripts/request-product-reviewer.js');
167+
await requestProductReviewer({ github, context, core, prNumber, teamSlug: '"product"' });
168+
return;
169+
}
170+
171+
// If PR is in clean status, merge directly; otherwise enable auto-merge to wait for checks
172+
if (mergeablePr.mergeable_state === 'clean') {
173+
core.info(`PR #${prNumber} is in clean status - merging directly.`);
174+
await github.rest.pulls.merge({
175+
owner: context.repo.owner,
176+
repo: context.repo.repo,
177+
pull_number: prNumber,
178+
merge_method: 'squash'
179+
});
180+
core.info(`Merged PR #${prNumber}`);
181+
} else {
182+
core.info(`PR #${prNumber} is in ${mergeablePr.mergeable_state} status - enabling auto-merge.`);
183+
try {
184+
await github.graphql(`
185+
mutation($pullRequestId: ID!) {
186+
enablePullRequestAutoMerge(input: { pullRequestId: $pullRequestId, mergeMethod: SQUASH }) {
187+
clientMutationId
188+
}
189+
}
190+
`, { pullRequestId: mergeablePr.node_id });
191+
core.info(`Auto-merge enabled for PR #${prNumber}`);
192+
} catch (error) {
193+
if (error.message?.includes('already enabled')) {
194+
core.info(`Auto-merge already enabled for PR #${prNumber} - skipping.`);
195+
} else if (error.message?.includes('in clean status')) {
196+
core.info(`PR #${prNumber} became clean during processing - merging directly.`);
197+
await github.rest.pulls.merge({
198+
owner: context.repo.owner,
199+
repo: context.repo.repo,
200+
pull_number: prNumber,
201+
merge_method: 'squash'
202+
});
203+
core.info(`Merged PR #${prNumber}`);
204+
} else {
205+
throw error;
206+
}
207+
}
208+
}
209+
210+
dependabot-ci-failure:
211+
runs-on: ubuntu-latest
212+
if: github.event_name == 'workflow_run' && github.event.workflow_run.conclusion == 'failure'
213+
# Note: permissions here scope the default GITHUB_TOKEN, but this job uses
214+
# GALILEO_AUTOMATION_GITHUB_TOKEN (a PAT) whose scopes are managed separately.
215+
permissions:
216+
contents: read
217+
pull-requests: write
218+
steps:
219+
- name: Checkout repository
220+
uses: actions/checkout@v4
221+
- name: Request review from random team member on CI failure
222+
uses: actions/github-script@v7
223+
with:
224+
github-token: ${{ secrets.GALILEO_AUTOMATION_GITHUB_TOKEN }}
225+
script: |
226+
const getDependabotPr = require('./.github/scripts/get-dependabot-pr.js');
227+
const result = await getDependabotPr({ github, context, core });
228+
if (!result) {
229+
core.info('No open Dependabot PR found - skipping CI failure notification.');
230+
return;
231+
}
232+
const { prNumber, pr } = result;
233+
234+
core.info(`Dependabot PR #${prNumber} CI failed - requesting review from random team member.`);
235+
const requestProductReviewer = require('./.github/scripts/request-product-reviewer.js');
236+
await requestProductReviewer({ github, context, core, prNumber, teamSlug: '"product"' });

0 commit comments

Comments
 (0)