Skip to content

Commit d55b230

Browse files
committed
feat: add revision guard draft conversion flow
1 parent 065b62b commit d55b230

7 files changed

Lines changed: 328 additions & 0 deletions

File tree

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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+
return value
16+
.split(',')
17+
.map(label => label.trim())
18+
.filter(Boolean);
19+
}
20+
21+
module.exports = {
22+
DEFAULT_MANAGED_LABELS,
23+
parseManagedLabels,
24+
};
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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, pullRequestId) {
11+
await github.graphql(
12+
`
13+
mutation($pullRequestId: ID!) {
14+
convertPullRequestToDraft(input: { pullRequestId: $pullRequestId }) {
15+
pullRequest {
16+
isDraft
17+
}
18+
}
19+
}
20+
`,
21+
{ pullRequestId }
22+
);
23+
}
24+
25+
module.exports = {
26+
convertToDraft,
27+
isBotAuthor,
28+
isDraft,
29+
};
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: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
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 reviewState = context.payload.review?.state;
11+
const pr = context.payload.pull_request;
12+
13+
if (!pr || reviewState !== 'changes_requested') {
14+
return;
15+
}
16+
17+
if (isBotAuthor(pr)) {
18+
core?.info?.(`Skipping PR #${pr.number} because it is bot-authored.`);
19+
return;
20+
}
21+
22+
if (isDraft(pr)) {
23+
core?.info?.(`Skipping PR #${pr.number} because it is already a draft.`);
24+
return;
25+
}
26+
27+
await convertToDraft(github, pr.node_id);
28+
core?.info?.(`Converted PR #${pr.number} to draft.`);
29+
30+
const labelsToRemove = getPresentManagedLabels(pr.labels);
31+
if (labelsToRemove.length === 0) {
32+
core?.info?.(`No managed labels to remove for PR #${pr.number}.`);
33+
return;
34+
}
35+
36+
await removeManagedLabels(github, {
37+
owner: context.repo.owner,
38+
repo: context.repo.repo,
39+
issueNumber: pr.number,
40+
labels: labelsToRemove,
41+
});
42+
core?.info?.(
43+
`Removed managed labels from PR #${pr.number}: ${labelsToRemove.join(', ')}.`
44+
);
45+
};
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
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+
21+
return {
22+
removedLabels,
23+
graphqlCalls: [],
24+
graphql: async function (_query, variables) {
25+
this.graphqlCalls.push(variables);
26+
},
27+
rest: {
28+
issues: {
29+
removeLabel: async ({ name }) => {
30+
removedLabels.push(name);
31+
},
32+
},
33+
},
34+
};
35+
}
36+
37+
function createContext(overrides = {}) {
38+
return {
39+
repo: { owner: 'hiero-ledger', repo: 'hiero-sdk-python' },
40+
payload: {
41+
review: { state: 'changes_requested' },
42+
pull_request: {
43+
number: 42,
44+
node_id: 'PR_node_42',
45+
draft: false,
46+
user: { login: 'contributor', type: 'User' },
47+
labels: [
48+
{ name: 'queue:committers' },
49+
{ name: 'status: ready-to-merge' },
50+
{ name: 'some other label' },
51+
],
52+
},
53+
...overrides,
54+
},
55+
};
56+
}
57+
58+
describe('revision-guard index', () => {
59+
beforeEach(() => {
60+
delete process.env.REVISION_GUARD_MANAGED_LABELS;
61+
});
62+
63+
afterEach(() => {
64+
delete process.env.REVISION_GUARD_MANAGED_LABELS;
65+
});
66+
67+
it('converts a ready PR to draft and removes only managed labels', async () => {
68+
const handler = freshRequire();
69+
const github = createGithubMock();
70+
const context = createContext();
71+
72+
await handler({ github, context, core: { info() {} } });
73+
74+
assert.deepEqual(github.graphqlCalls, [{ pullRequestId: 'PR_node_42' }]);
75+
assert.deepEqual(github.removedLabels, [
76+
'queue:committers',
77+
'status: ready-to-merge',
78+
]);
79+
});
80+
81+
it('skips bot-authored PRs', async () => {
82+
const handler = freshRequire();
83+
const github = createGithubMock();
84+
const context = createContext({
85+
pull_request: {
86+
number: 43,
87+
node_id: 'PR_node_43',
88+
draft: false,
89+
user: { login: 'github-actions[bot]', type: 'Bot' },
90+
labels: [{ name: 'queue:committers' }],
91+
},
92+
});
93+
94+
await handler({ github, context, core: { info() {} } });
95+
96+
assert.equal(github.graphqlCalls.length, 0);
97+
assert.equal(github.removedLabels.length, 0);
98+
});
99+
100+
it('skips already-draft PRs', async () => {
101+
const handler = freshRequire();
102+
const github = createGithubMock();
103+
const context = createContext({
104+
pull_request: {
105+
number: 44,
106+
node_id: 'PR_node_44',
107+
draft: true,
108+
user: { login: 'contributor', type: 'User' },
109+
labels: [{ name: 'queue:committers' }],
110+
},
111+
});
112+
113+
await handler({ github, context, core: { info() {} } });
114+
115+
assert.equal(github.graphqlCalls.length, 0);
116+
assert.equal(github.removedLabels.length, 0);
117+
});
118+
119+
it('uses configurable managed labels', async () => {
120+
process.env.REVISION_GUARD_MANAGED_LABELS = 'custom: one, custom: two';
121+
const handler = freshRequire();
122+
const github = createGithubMock();
123+
const context = createContext({
124+
pull_request: {
125+
number: 45,
126+
node_id: 'PR_node_45',
127+
draft: false,
128+
user: { login: 'contributor', type: 'User' },
129+
labels: [
130+
{ name: 'custom: one' },
131+
{ name: 'queue:committers' },
132+
{ name: 'custom: two' },
133+
],
134+
},
135+
});
136+
137+
await handler({ github, context, core: { info() {} } });
138+
139+
assert.deepEqual(github.removedLabels, ['custom: one', 'custom: two']);
140+
});
141+
});
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
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: hl-sdk-py-lin-md
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+
sparse-checkout: .github/scripts
31+
32+
- name: Handle changes requested
33+
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
34+
with:
35+
script: |
36+
const handler = require('./.github/scripts/revision-guard/index.js');
37+
await handler({ github, context, core });

0 commit comments

Comments
 (0)