Skip to content

Commit 6771c04

Browse files
authored
Merge fork revision guard runner test
Deploy fork-only revision guard workflow test setup.
2 parents d02fb59 + 57cfe42 commit 6771c04

7 files changed

Lines changed: 369 additions & 0 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
const DEFAULT_MANAGED_LABELS = [
2+
'queue:junior-committer',
3+
'queue:committers',
4+
'queue:maintainers',
5+
'status: ready-to-merge',
6+
'status: failed checks',
7+
'open to community review',
8+
];
9+
10+
function parseManagedLabels(value) {
11+
if (typeof value !== 'string' || value.trim().length === 0) {
12+
return [...DEFAULT_MANAGED_LABELS];
13+
}
14+
15+
const customLabels = value
16+
.split(',')
17+
.map(label => label.trim())
18+
.filter(Boolean);
19+
20+
// Merge custom labels with defaults so that setting REVISION_GUARD_MANAGED_LABELS
21+
// adds to the managed set rather than silently discarding the default labels.
22+
const merged = new Set([...DEFAULT_MANAGED_LABELS, ...customLabels]);
23+
return [...merged];
24+
}
25+
26+
module.exports = {
27+
DEFAULT_MANAGED_LABELS,
28+
parseManagedLabels,
29+
};
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
function isBotAuthor(pr) {
2+
const login = pr?.user?.login || '';
3+
return pr?.user?.type === 'Bot' || login.endsWith('[bot]');
4+
}
5+
6+
function isDraft(pr) {
7+
return pr?.draft === true;
8+
}
9+
10+
async function convertToDraft(github, { owner, repo, pullNumber }) {
11+
await github.rest.pulls.update({
12+
owner,
13+
repo,
14+
pull_number: pullNumber,
15+
draft: true,
16+
});
17+
}
18+
19+
module.exports = {
20+
convertToDraft,
21+
isBotAuthor,
22+
isDraft,
23+
};
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
const { DEFAULT_MANAGED_LABELS, parseManagedLabels } = require('./constants');
2+
const { convertToDraft, isBotAuthor, isDraft } = require('./draft');
3+
const { getManagedLabels, getPresentManagedLabels, removeManagedLabels } = require('./labels');
4+
5+
module.exports = {
6+
DEFAULT_MANAGED_LABELS,
7+
parseManagedLabels,
8+
convertToDraft,
9+
isBotAuthor,
10+
isDraft,
11+
getManagedLabels,
12+
getPresentManagedLabels,
13+
removeManagedLabels,
14+
};
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
const { parseManagedLabels } = require('./constants');
2+
3+
function getManagedLabels() {
4+
return parseManagedLabels(process.env.REVISION_GUARD_MANAGED_LABELS);
5+
}
6+
7+
function getPresentManagedLabels(prLabels, managedLabels = getManagedLabels()) {
8+
const currentLabels = Array.isArray(prLabels)
9+
? prLabels
10+
.map(label => typeof label === 'string' ? label : label?.name)
11+
.filter(Boolean)
12+
: [];
13+
14+
return managedLabels.filter(label => currentLabels.includes(label));
15+
}
16+
17+
async function removeManagedLabels(github, { owner, repo, issueNumber, labels }) {
18+
for (const name of labels) {
19+
try {
20+
await github.rest.issues.removeLabel({
21+
owner,
22+
repo,
23+
issue_number: issueNumber,
24+
name,
25+
});
26+
} catch (error) {
27+
if (error?.status !== 404) {
28+
throw error;
29+
}
30+
}
31+
}
32+
}
33+
34+
module.exports = {
35+
getManagedLabels,
36+
getPresentManagedLabels,
37+
removeManagedLabels,
38+
};
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
const {
2+
convertToDraft,
3+
getPresentManagedLabels,
4+
isBotAuthor,
5+
isDraft,
6+
removeManagedLabels,
7+
} = require('./helpers');
8+
9+
module.exports = async function revisionGuard({ github, context, core }) {
10+
const payload = context?.payload;
11+
const repo = context?.repo;
12+
const reviewState = payload?.review?.state;
13+
const pr = payload?.pull_request;
14+
15+
if (
16+
!payload ||
17+
!repo?.owner ||
18+
!repo?.repo ||
19+
!pr ||
20+
typeof pr.number !== 'number' ||
21+
reviewState !== 'changes_requested'
22+
) {
23+
core?.info?.('Skipping revision guard due to missing or non-matching payload data.');
24+
return;
25+
}
26+
27+
if (isBotAuthor(pr)) {
28+
core?.info?.(`Skipping PR #${pr.number} because it is bot-authored.`);
29+
return;
30+
}
31+
32+
if (isDraft(pr)) {
33+
core?.info?.(`Skipping PR #${pr.number} because it is already a draft.`);
34+
return;
35+
}
36+
37+
try {
38+
await convertToDraft(github, {
39+
owner: repo.owner,
40+
repo: repo.repo,
41+
pullNumber: pr.number,
42+
});
43+
core?.info?.(`Converted PR #${pr.number} to draft.`);
44+
} catch (error) {
45+
core?.error?.(`Failed to convert PR #${pr.number} to draft: ${error.message}`);
46+
throw error;
47+
}
48+
49+
const labelsToRemove = getPresentManagedLabels(pr.labels);
50+
if (labelsToRemove.length === 0) {
51+
core?.info?.(`No managed labels to remove for PR #${pr.number}.`);
52+
return;
53+
}
54+
55+
try {
56+
await removeManagedLabels(github, {
57+
owner: repo.owner,
58+
repo: repo.repo,
59+
issueNumber: pr.number,
60+
labels: labelsToRemove,
61+
});
62+
core?.info?.(
63+
`Removed managed labels from PR #${pr.number}: ${labelsToRemove.join(', ')}.`
64+
);
65+
} catch (error) {
66+
core?.error?.(
67+
`Failed to remove labels from PR #${pr.number}: ${error.message}. ` +
68+
`Labels to remove: ${labelsToRemove.join(', ')}.`
69+
);
70+
// Don't re-throw; draft conversion succeeded and is the primary goal
71+
}
72+
};
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
const { describe, it, beforeEach, afterEach } = require('node:test');
2+
const assert = require('node:assert/strict');
3+
4+
function freshRequire() {
5+
const indexPath = require.resolve('./index.js');
6+
const helpersPath = require.resolve('./helpers/index.js');
7+
const labelsPath = require.resolve('./helpers/labels.js');
8+
const constantsPath = require.resolve('./helpers/constants.js');
9+
10+
delete require.cache[indexPath];
11+
delete require.cache[helpersPath];
12+
delete require.cache[labelsPath];
13+
delete require.cache[constantsPath];
14+
15+
return require('./index.js');
16+
}
17+
18+
function createGithubMock() {
19+
const removedLabels = [];
20+
const pullUpdates = [];
21+
22+
return {
23+
removedLabels,
24+
pullUpdates,
25+
rest: {
26+
issues: {
27+
removeLabel: async ({ name }) => {
28+
removedLabels.push(name);
29+
},
30+
},
31+
pulls: {
32+
update: async (params) => {
33+
pullUpdates.push(params);
34+
},
35+
},
36+
},
37+
};
38+
}
39+
40+
function createContext(overrides = {}) {
41+
return {
42+
repo: { owner: 'hiero-ledger', repo: 'hiero-sdk-python' },
43+
payload: {
44+
review: { state: 'changes_requested' },
45+
pull_request: {
46+
number: 42,
47+
node_id: 'PR_node_42',
48+
draft: false,
49+
user: { login: 'contributor', type: 'User' },
50+
labels: [
51+
{ name: 'queue:committers' },
52+
{ name: 'status: ready-to-merge' },
53+
{ name: 'some other label' },
54+
],
55+
},
56+
...overrides,
57+
},
58+
};
59+
}
60+
61+
describe('revision-guard index', () => {
62+
beforeEach(() => {
63+
delete process.env.REVISION_GUARD_MANAGED_LABELS;
64+
});
65+
66+
afterEach(() => {
67+
delete process.env.REVISION_GUARD_MANAGED_LABELS;
68+
});
69+
70+
it('converts a ready PR to draft and removes only managed labels', async () => {
71+
const handler = freshRequire();
72+
const github = createGithubMock();
73+
const context = createContext();
74+
75+
await handler({ github, context, core: { info() {} } });
76+
77+
assert.deepEqual(github.pullUpdates, [
78+
{ owner: 'hiero-ledger', repo: 'hiero-sdk-python', pull_number: 42, draft: true },
79+
]);
80+
assert.deepEqual(github.removedLabels, [
81+
'queue:committers',
82+
'status: ready-to-merge',
83+
]);
84+
});
85+
86+
it('skips bot-authored PRs', async () => {
87+
const handler = freshRequire();
88+
const github = createGithubMock();
89+
const context = createContext({
90+
pull_request: {
91+
number: 43,
92+
node_id: 'PR_node_43',
93+
draft: false,
94+
user: { login: 'github-actions[bot]', type: 'Bot' },
95+
labels: [{ name: 'queue:committers' }],
96+
},
97+
});
98+
99+
await handler({ github, context, core: { info() {} } });
100+
101+
assert.equal(github.pullUpdates.length, 0);
102+
assert.equal(github.removedLabels.length, 0);
103+
});
104+
105+
it('skips already-draft PRs', async () => {
106+
const handler = freshRequire();
107+
const github = createGithubMock();
108+
const context = createContext({
109+
pull_request: {
110+
number: 44,
111+
node_id: 'PR_node_44',
112+
draft: true,
113+
user: { login: 'contributor', type: 'User' },
114+
labels: [{ name: 'queue:committers' }],
115+
},
116+
});
117+
118+
await handler({ github, context, core: { info() {} } });
119+
120+
assert.equal(github.pullUpdates.length, 0);
121+
assert.equal(github.removedLabels.length, 0);
122+
});
123+
124+
it('uses configurable managed labels and still removes defaults', async () => {
125+
process.env.REVISION_GUARD_MANAGED_LABELS = 'custom: one, custom: two';
126+
const handler = freshRequire();
127+
const github = createGithubMock();
128+
const context = createContext({
129+
pull_request: {
130+
number: 45,
131+
node_id: 'PR_node_45',
132+
draft: false,
133+
user: { login: 'contributor', type: 'User' },
134+
labels: [
135+
{ name: 'custom: one' },
136+
{ name: 'queue:committers' },
137+
{ name: 'custom: two' },
138+
],
139+
},
140+
});
141+
142+
await handler({ github, context, core: { info() {} } });
143+
144+
// Draft conversion must also fire for configurable-label scenarios.
145+
assert.deepEqual(github.pullUpdates, [
146+
{ owner: 'hiero-ledger', repo: 'hiero-sdk-python', pull_number: 45, draft: true },
147+
]);
148+
// Custom labels AND the matching default (queue:committers) must both be removed.
149+
assert.deepEqual(github.removedLabels, ['queue:committers', 'custom: one', 'custom: two']);
150+
});
151+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
name: Revision Guard - Review Events
2+
3+
on:
4+
pull_request_review:
5+
types: [submitted]
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
guard:
12+
if: github.event.review.state == 'changes_requested'
13+
runs-on: ubuntu-latest
14+
permissions:
15+
pull-requests: write
16+
issues: write
17+
contents: read
18+
concurrency:
19+
group: revision-guard-review-${{ github.event.pull_request.number }}
20+
cancel-in-progress: false
21+
steps:
22+
- name: Harden Runner
23+
uses: step-security/harden-runner@8d3c67de8e2fe68ef647c8db1e6a09f647780f40 # v2.19.0
24+
with:
25+
egress-policy: audit
26+
27+
- name: Checkout repository
28+
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
29+
with:
30+
# Use trusted base commit, not PR head/merge ref, to prevent fork PRs
31+
# from executing untrusted changes with a write-scoped token.
32+
ref: ${{ github.event.pull_request.base.sha }}
33+
sparse-checkout: .github/scripts
34+
35+
- name: Handle changes requested
36+
env:
37+
REVISION_GUARD_MANAGED_LABELS: ${{ vars.REVISION_GUARD_MANAGED_LABELS }}
38+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
39+
with:
40+
script: |
41+
const handler = require('./.github/scripts/revision-guard/index.js');
42+
await handler({ github, context, core });

0 commit comments

Comments
 (0)