Skip to content

Commit a3a7489

Browse files
committed
fix: use workflow_run to remove assignees with write token on fork PRs
pull_request_review events receive a read-only GITHUB_TOKEN for PRs from forked repositories, so removeAssignees would silently 403 for every real contribution. The workflow_run pattern is the standard GitHub-recommended fix: a lightweight capture workflow fires on pull_request_review (read-only token is sufficient — it only writes an artifact), and the main workflow's workflow_run job picks it up with a full write token regardless of fork status. Changes: - Add capture-pr-review.yml: triggered by pull_request_review, writes reviewer login and PR number to a JSON artifact, uploads with a 1-day retention so workflow_run can consume it - on-review.yml: remove pull_request_review trigger; add workflow_run trigger and a new remove-reviewer-from-assignees job that downloads the artifact, checks out base-branch code, and calls the named export directly; add actions: read permission for artifact download; tighten add job if-condition to exclude workflow_run events - bot-pr-add-reviewers-as-assignees.js: accept optional explicit reviewer/prNumber params in removeReviewerFromAssignees so it can be called from the workflow_run job without a pull_request_review context; export the function as a named export; drop pull_request_review routing from the entry point (now dead code) - Tests: call removeReviewerFromAssignees via named export with explicit params matching the new production path; add missing/invalid param guard tests; add routing test for the now-unhandled pull_request_review event Signed-off-by: Mounil Kanakhara <mounilkankhara@gmail.com>
1 parent 595b1ab commit a3a7489

4 files changed

Lines changed: 183 additions & 62 deletions

File tree

.github/scripts/__tests__/jest/bot-pr-add-reviewers-as-assignees.test.js

Lines changed: 76 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,12 @@
55

66
describe('Bot: Add Reviewers as Assignees', () => {
77
let handler;
8+
let removeReviewerFromAssignees;
89

910
beforeAll(() => {
10-
handler = require('../../bot-pr-add-reviewers-as-assignees.js');
11+
const mod = require('../../bot-pr-add-reviewers-as-assignees.js');
12+
handler = mod;
13+
removeReviewerFromAssignees = mod.removeReviewerFromAssignees;
1114
});
1215

1316
beforeEach(() => {
@@ -36,20 +39,8 @@ describe('Bot: Add Reviewers as Assignees', () => {
3639
}
3740
});
3841

39-
const createMockReviewContext = ({ reviewerLogin, assignees = [] } = {}) => ({
40-
repo: { owner: 'hiero-ledger', repo: 'hiero-sdk-python' },
41-
eventName: 'pull_request_review',
42-
payload: {
43-
action: 'submitted',
44-
review: {
45-
user: { login: reviewerLogin }
46-
},
47-
pull_request: {
48-
number: 123,
49-
assignees
50-
}
51-
}
52-
});
42+
// Minimal context for remove flow: only repo is required when explicit params are passed
43+
const minimalContext = { repo: { owner: 'hiero-ledger', repo: 'hiero-sdk-python' } };
5344

5445
const createMockGithub = (state) => ({
5546
rest: {
@@ -247,56 +238,81 @@ describe('Bot: Add Reviewers as Assignees', () => {
247238
await expect(handler({ github: errorMock, context: ctx })).rejects.toHaveProperty('status', 500);
248239
});
249240

250-
// ─── Remove flow ─────────────────────────────────────────────────────────────
241+
// ─── Remove flow (named export — called from workflow_run job) ────────────────
251242

252-
test('removes reviewer from assignees when they submit a review', async () => {
243+
test('removes reviewer from assignees when called with explicit params', async () => {
253244
const state = createTestState();
254245
state.currentPrData = { assignees: [{ login: 'alice' }, { login: 'bob' }] };
255-
const ctx = createMockReviewContext({
256-
reviewerLogin: 'alice',
257-
assignees: [{ login: 'alice' }, { login: 'bob' }]
258-
});
259246

260-
await handler({ github: createMockGithub(state), context: ctx });
247+
await removeReviewerFromAssignees({
248+
github: createMockGithub(state),
249+
context: minimalContext,
250+
reviewer: 'alice',
251+
prNumber: 123
252+
});
261253

262254
expect(state.removeAssigneesCalls).toHaveLength(1);
263255
expect(state.removeAssigneesCalls[0].assignees).toEqual(['alice']);
264256
expect(state.addAssigneesCalls).toHaveLength(0);
265257
});
266258

267-
test('does not call removeAssignees when reviewer is not an assignee', async () => {
259+
test('does not remove when reviewer is not an assignee', async () => {
268260
const state = createTestState();
269261
state.currentPrData = { assignees: [{ login: 'alice' }] };
270-
const ctx = createMockReviewContext({
271-
reviewerLogin: 'carol',
272-
assignees: [{ login: 'alice' }]
273-
});
274262

275-
await handler({ github: createMockGithub(state), context: ctx });
263+
await removeReviewerFromAssignees({
264+
github: createMockGithub(state),
265+
context: minimalContext,
266+
reviewer: 'carol',
267+
prNumber: 123
268+
});
276269

277270
expect(state.removeAssigneesCalls).toHaveLength(0);
278-
expect(state.addAssigneesCalls).toHaveLength(0);
279271
});
280272

281-
test('review submitted by someone who was never a reviewer is a no-op', async () => {
273+
test('is a no-op when reviewer was never an assignee', async () => {
282274
const state = createTestState();
283275
state.currentPrData = { assignees: [] };
284-
const ctx = createMockReviewContext({
285-
reviewerLogin: 'outsider',
286-
assignees: []
276+
277+
await removeReviewerFromAssignees({
278+
github: createMockGithub(state),
279+
context: minimalContext,
280+
reviewer: 'outsider',
281+
prNumber: 123
287282
});
288283

289-
await handler({ github: createMockGithub(state), context: ctx });
284+
expect(state.removeAssigneesCalls).toHaveLength(0);
285+
});
286+
287+
test('skips when reviewer is missing', async () => {
288+
const state = createTestState();
289+
290+
await removeReviewerFromAssignees({
291+
github: createMockGithub(state),
292+
context: minimalContext,
293+
reviewer: undefined,
294+
prNumber: 123
295+
});
290296

291297
expect(state.removeAssigneesCalls).toHaveLength(0);
298+
expect(state.pullsGetCalls).toBe(0);
292299
});
293300

294-
test('gracefully handles 403 permission errors on remove', async () => {
295-
const ctx = createMockReviewContext({
296-
reviewerLogin: 'alice',
297-
assignees: [{ login: 'alice' }]
301+
test('skips when prNumber is invalid', async () => {
302+
const state = createTestState();
303+
304+
await removeReviewerFromAssignees({
305+
github: createMockGithub(state),
306+
context: minimalContext,
307+
reviewer: 'alice',
308+
prNumber: 0
298309
});
299310

311+
expect(state.removeAssigneesCalls).toHaveLength(0);
312+
expect(state.pullsGetCalls).toBe(0);
313+
});
314+
315+
test('gracefully handles 403 permission errors on remove', async () => {
300316
const errorMock = {
301317
rest: {
302318
pulls: { get: async () => ({ data: { assignees: [{ login: 'alice' }] } }) },
@@ -311,15 +327,12 @@ describe('Bot: Add Reviewers as Assignees', () => {
311327
}
312328
};
313329

314-
await expect(handler({ github: errorMock, context: ctx })).resolves.not.toThrow();
330+
await expect(
331+
removeReviewerFromAssignees({ github: errorMock, context: minimalContext, reviewer: 'alice', prNumber: 123 })
332+
).resolves.not.toThrow();
315333
});
316334

317335
test('rethrows non-403 errors on remove', async () => {
318-
const ctx = createMockReviewContext({
319-
reviewerLogin: 'alice',
320-
assignees: [{ login: 'alice' }]
321-
});
322-
323336
const errorMock = {
324337
rest: {
325338
pulls: { get: async () => ({ data: { assignees: [{ login: 'alice' }] } }) },
@@ -334,6 +347,24 @@ describe('Bot: Add Reviewers as Assignees', () => {
334347
}
335348
};
336349

337-
await expect(handler({ github: errorMock, context: ctx })).rejects.toHaveProperty('status', 500);
350+
await expect(
351+
removeReviewerFromAssignees({ github: errorMock, context: minimalContext, reviewer: 'alice', prNumber: 123 })
352+
).rejects.toHaveProperty('status', 500);
353+
});
354+
355+
// ─── Routing ─────────────────────────────────────────────────────────────────
356+
357+
test('unhandled event logs warning and does nothing', async () => {
358+
const state = createTestState();
359+
const ctx = {
360+
repo: { owner: 'hiero-ledger', repo: 'hiero-sdk-python' },
361+
eventName: 'pull_request_review',
362+
payload: { action: 'submitted' }
363+
};
364+
365+
await handler({ github: createMockGithub(state), context: ctx });
366+
367+
expect(state.addAssigneesCalls).toHaveLength(0);
368+
expect(state.removeAssigneesCalls).toHaveLength(0);
338369
});
339370
});

.github/scripts/bot-pr-add-reviewers-as-assignees.js

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
* @fileoverview
55
* Manages requested individual reviewers as PR assignees.
66
*
7-
* - On `review_requested`: adds the reviewer as an assignee (no cap).
8-
* - On `pull_request_review` submitted: removes the reviewer from assignees.
7+
* - On `review_requested` (pull_request_target): adds the reviewer as an assignee (no cap).
8+
* - On `workflow_run` (triggered by Bot - Capture PR Review): removes the reviewer from
9+
* assignees using a write-token that works for fork PRs. The reviewer login and PR number
10+
* are read from the artifact written by the capture workflow.
911
* - Team reviewers are intentionally ignored (only individual users are assigned).
1012
*/
1113

@@ -125,18 +127,22 @@ async function addReviewersAsAssignees({ github, context }) {
125127
}
126128

127129
/**
128-
* Removes a reviewer from PR assignees when they submit their review.
129-
* Any review state (approved, changes_requested, commented) triggers removal.
130+
* Removes a reviewer from PR assignees after they submit their review.
131+
* Called from the workflow_run removal job via named export, passing reviewer
132+
* login and PR number read from the capture workflow's artifact.
133+
*
134+
* Explicit params take priority; falls back to context.payload for direct calls.
130135
*
131136
* @param {Object} params
132137
* @param {Object} params.github - GitHub Octokit client instance
133138
* @param {Object} params.context - GitHub Actions context object
139+
* @param {string} [params.reviewer] - Reviewer login (overrides context payload)
140+
* @param {number} [params.prNumber] - PR number (overrides context payload)
134141
*/
135-
async function removeReviewerFromAssignees({ github, context }) {
142+
async function removeReviewerFromAssignees({ github, context, reviewer: explicitReviewer, prNumber: explicitPrNumber }) {
136143
try {
137-
const reviewer = context.payload.review?.user?.login;
138-
const pr = context.payload.pull_request;
139-
const prNumber = pr?.number;
144+
const reviewer = explicitReviewer ?? context.payload?.review?.user?.login;
145+
const prNumber = explicitPrNumber ?? context.payload?.pull_request?.number;
140146

141147
if (!reviewer || !Number.isInteger(prNumber) || prNumber <= 0) {
142148
logger.warn('Missing reviewer login or PR number. Skipping removal.');
@@ -146,8 +152,7 @@ async function removeReviewerFromAssignees({ github, context }) {
146152
const owner = context.repo.owner;
147153
const repo = context.repo.repo;
148154

149-
// Fetch live PR data rather than relying on the event payload snapshot,
150-
// which may be stale if review_requested ran concurrently and added the assignee after this event fired.
155+
// Fetch live PR data to avoid acting on a stale event payload snapshot.
151156
const livePr = (await github.rest.pulls.get({ owner, repo, pull_number: prNumber })).data;
152157
const currentAssignees = (livePr.assignees || []).map(a => a.login);
153158

@@ -178,7 +183,8 @@ async function removeReviewerFromAssignees({ github, context }) {
178183
}
179184

180185
/**
181-
* Entry point. Routes to add or remove flow based on the triggering event.
186+
* Entry point for the add flow (pull_request_target + workflow_dispatch).
187+
* The remove flow is invoked directly via the named export from the workflow_run job.
182188
*
183189
* @param {Object} params
184190
* @param {Object} params.github - GitHub Octokit client instance
@@ -189,9 +195,7 @@ module.exports = async ({ github, context }) => {
189195
const { eventName } = context;
190196
const action = context.payload.action;
191197

192-
if (eventName === 'pull_request_review' && action === 'submitted') {
193-
await removeReviewerFromAssignees({ github, context });
194-
} else if (
198+
if (
195199
(eventName === 'pull_request_target' && action === 'review_requested') ||
196200
eventName === 'workflow_dispatch'
197201
) {
@@ -200,3 +204,5 @@ module.exports = async ({ github, context }) => {
200204
logger.warn(`Unhandled event: ${eventName} / ${action}. Skipping.`);
201205
}
202206
};
207+
208+
module.exports.removeReviewerFromAssignees = removeReviewerFromAssignees;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
name: Bot - Capture PR Review
2+
3+
on:
4+
pull_request_review:
5+
types:
6+
- submitted
7+
8+
permissions:
9+
contents: read
10+
11+
jobs:
12+
capture:
13+
name: Capture Review Metadata
14+
runs-on: hl-sdk-py-lin-md
15+
if: "!contains(github.actor, '[bot]')"
16+
17+
steps:
18+
- name: Harden Runner
19+
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
20+
with:
21+
egress-policy: audit
22+
23+
- name: Write review metadata
24+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
25+
with:
26+
script: |
27+
const fs = require('fs');
28+
fs.mkdirSync('review-data', { recursive: true });
29+
const reviewer = context.payload.review?.user?.login ?? null;
30+
const pr_number = context.payload.pull_request?.number ?? null;
31+
fs.writeFileSync('review-data/review.json', JSON.stringify({ reviewer, pr_number }));
32+
33+
- name: Upload review metadata
34+
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
35+
with:
36+
name: review-data-${{ github.run_id }}
37+
path: review-data/review.json
38+
retention-days: 1

.github/workflows/on-review.yml

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,11 @@ on:
44
pull_request_target:
55
types:
66
- review_requested
7-
pull_request_review:
7+
workflow_run:
8+
workflows:
9+
- "Bot - Capture PR Review"
810
types:
9-
- submitted
11+
- completed
1012
workflow_dispatch:
1113
inputs:
1214
pr_number:
@@ -18,12 +20,18 @@ permissions:
1820
contents: read
1921
pull-requests: write
2022
issues: write
23+
actions: read
2124

2225
jobs:
2326
add-reviewers-as-assignees:
2427
name: Add Reviewers as Assignees
2528
runs-on: hl-sdk-py-lin-md
26-
if: "!contains(github.actor, '[bot]')"
29+
if: |
30+
!contains(github.actor, '[bot]') &&
31+
(
32+
(github.event_name == 'pull_request_target' && github.event.action == 'review_requested') ||
33+
github.event_name == 'workflow_dispatch'
34+
)
2735
2836
concurrency:
2937
group: reviewer-assignee-${{ github.event.pull_request.number || inputs.pr_number || github.run_id }}
@@ -47,3 +55,41 @@ jobs:
4755
script: |
4856
const script = require('./.github/scripts/bot-pr-add-reviewers-as-assignees.js');
4957
await script({ github, context });
58+
59+
remove-reviewer-from-assignees:
60+
name: Remove Reviewer from Assignees
61+
runs-on: hl-sdk-py-lin-md
62+
if: |
63+
github.event_name == 'workflow_run' &&
64+
github.event.workflow_run.conclusion == 'success'
65+
66+
steps:
67+
- name: Harden Runner
68+
uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0
69+
with:
70+
egress-policy: audit
71+
72+
- name: Download review metadata
73+
id: download
74+
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
75+
with:
76+
name: review-data-${{ github.event.workflow_run.id }}
77+
run-id: ${{ github.event.workflow_run.id }}
78+
github-token: ${{ secrets.GITHUB_TOKEN }}
79+
continue-on-error: true
80+
81+
- name: Checkout repository
82+
if: steps.download.outcome == 'success'
83+
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
84+
with:
85+
persist-credentials: false
86+
87+
- name: Run Remove Reviewer from Assignees
88+
if: steps.download.outcome == 'success'
89+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
90+
with:
91+
script: |
92+
const fs = require('fs');
93+
const { reviewer, pr_number: prNumber } = JSON.parse(fs.readFileSync('review.json', 'utf8'));
94+
const { removeReviewerFromAssignees } = require('./.github/scripts/bot-pr-add-reviewers-as-assignees.js');
95+
await removeReviewerFromAssignees({ github, context, reviewer, prNumber });

0 commit comments

Comments
 (0)