Skip to content

Commit fb0252a

Browse files
authored
feat(ci): add workflow to validate size and titles of pull requests, as well as number of linked issues (#1487)
1 parent e5729cf commit fb0252a

File tree

1 file changed

+227
-0
lines changed

1 file changed

+227
-0
lines changed

.github/workflows/bouncer.yml

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
name: 🏀 Bouncer
2+
# aka 🚪 Supervisor
3+
4+
env:
5+
# additions only
6+
MAX_ADDITIONS_FORKS: 500
7+
# on rare occasions maintainers need to edit a lot of things at once
8+
MAX_ADDITIONS_DIRECT_BRANCHES: 800
9+
# many target issues usually mean bigger pull requests
10+
MAX_ISSUES_PER_PR: 3
11+
# the name of this workflow file wrapped in backticks
12+
MSG_PREFIX: "`bouncer.yml`"
13+
14+
on:
15+
pull_request_target: # do NOT use actions/checkout!
16+
# any branches
17+
branches: ["**"]
18+
# on creation, on new commits, and description edits
19+
types: [opened, synchronize, edited]
20+
21+
concurrency:
22+
group: ${{ github.workflow }}-${{ github.ref }}-bouncer
23+
cancel-in-progress: true
24+
25+
permissions:
26+
contents: read
27+
pull-requests: write
28+
29+
jobs:
30+
enforce-smaller-requests:
31+
name: "PR is manageable"
32+
if: github.event.action != 'edited'
33+
runs-on: ubuntu-latest
34+
steps:
35+
- name: Set MAX_ADDITIONS for forks
36+
if: |
37+
github.event_name == 'pull_request_target' &&
38+
github.event.pull_request.head.repo.fork == true ||
39+
github.event.pull_request.head.repo.full_name != github.repository
40+
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
41+
with:
42+
script: core.exportVariable('MAX_ADDITIONS', '${{ env.MAX_ADDITIONS_FORKS }}')
43+
44+
- name: Set MAX_ADDITIONS for direct branches
45+
if: |
46+
github.event.pull_request.head.repo.fork != true ||
47+
github.event.pull_request.head.repo.full_name == github.repository
48+
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
49+
with:
50+
script: core.exportVariable('MAX_ADDITIONS', '${{ env.MAX_ADDITIONS_DIRECT_BRANCHES }}')
51+
52+
- name: Remove prior comments by their common prefix
53+
if: github.event.action == 'synchronize'
54+
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
55+
with:
56+
# This JavaScript code cannot be moved to a separate file because
57+
# this workflow must NOT checkout the repository for security reasons
58+
#
59+
# The same note applies to all JS code in this file
60+
script: |
61+
await exec.exec('sleep 0.5s');
62+
const comments = await github.rest.issues.listComments({
63+
owner: context.repo.owner,
64+
repo: context.repo.repo,
65+
issue_number: context.payload.pull_request.number,
66+
});
67+
for (const comment of comments.data) {
68+
await exec.exec('sleep 0.5s');
69+
const isHidden = (await github.graphql(`
70+
query($nodeId: ID!) {
71+
node(id: $nodeId) {
72+
... on IssueComment {
73+
isMinimized
74+
}
75+
}
76+
}
77+
`, { nodeId: comment.node_id }))?.node?.isMinimized;
78+
if (isHidden) { continue; }
79+
if (
80+
comment.user.login === 'github-actions[bot]' &&
81+
comment.body.startsWith('${{ env.MSG_PREFIX }}')
82+
) {
83+
console.log('Comment node_id:', comment.node_id);
84+
await exec.exec('sleep 0.5s');
85+
console.log(await github.graphql(`
86+
mutation($subjectId: ID!) {
87+
minimizeComment(input: {
88+
subjectId: $subjectId,
89+
classifier: OUTDATED
90+
}) {
91+
minimizedComment {
92+
isMinimized
93+
minimizedReason
94+
}
95+
}
96+
}
97+
`, {
98+
subjectId: comment.node_id,
99+
}));
100+
}
101+
}
102+
103+
- name: Check if a number of additions modulo filtered files is within the threshold
104+
id: stats
105+
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
106+
with:
107+
script: |
108+
const maxAdditions = Number(process.env.MAX_ADDITIONS ?? '500');
109+
await exec.exec('sleep 0.5s');
110+
const { data: files } = await github.rest.pulls.listFiles({
111+
owner: context.repo.owner,
112+
repo: context.repo.repo,
113+
pull_number: context.payload.pull_request.number,
114+
per_page: 100,
115+
});
116+
const filtered = files.filter(
117+
(f) =>
118+
![
119+
'package-lock.json',
120+
'ecosystem/api/toncenter/v2.json',
121+
'ecosystem/api/toncenter/v3.yaml',
122+
'ecosystem/api/toncenter/smc-index.json',
123+
'tvm/instructions.mdx',
124+
].includes(f.filename) && !f.filename.endsWith('.py'),
125+
);
126+
// NOTE: consider looking for .changes
127+
const additions = filtered.reduce((acc, it) => acc + it.additions, 0);
128+
if (additions > maxAdditions) {
129+
core.setOutput('trigger', 'true');
130+
} else {
131+
core.setOutput('trigger', 'false');
132+
}
133+
134+
- name: ${{ steps.stats.outputs.trigger == 'true' && 'An opened PR is too big to be reviewed at once!' || 'No comments' }}
135+
if: github.event.action == 'opened' && steps.stats.outputs.trigger == 'true'
136+
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
137+
with:
138+
script: |
139+
await exec.exec('sleep 0.5s');
140+
await github.rest.issues.createComment({
141+
owner: context.repo.owner,
142+
repo: context.repo.repo,
143+
issue_number: context.payload.pull_request.number,
144+
body: [
145+
'${{ env.MSG_PREFIX }}',
146+
'Thank you for the contribution!',
147+
[
148+
'Unfortunately, it is too large, with over ${{ env.MAX_ADDITIONS }} added lines,',
149+
'excluding some generated or otherwise special files.',
150+
'Thus, this pull request is challenging to review and iterate on.',
151+
].join(' '),
152+
[
153+
'Please split the PR into several smaller ones and consider',
154+
'reverting any unrelated changes, writing less, or approaching',
155+
'the problem in the issue from a different angle.',
156+
].join(' '),
157+
'I look forward to your next submissions. If you still intend to proceed as is, then you are at the mercy of the reviewers.',
158+
].join('\n\n'),
159+
});
160+
process.exit(1);
161+
162+
- name: ${{ steps.stats.outputs.trigger == 'true' && 'Some change in the PR made it too big!' || 'No comments' }}
163+
if: github.event.action == 'synchronize' && steps.stats.outputs.trigger == 'true'
164+
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
165+
with:
166+
script: |
167+
await exec.exec('sleep 0.5s');
168+
await github.rest.issues.createComment({
169+
owner: context.repo.owner,
170+
repo: context.repo.repo,
171+
issue_number: context.payload.pull_request.number,
172+
body: [
173+
'${{ env.MSG_PREFIX }}',
174+
[
175+
'The most recent commit has made this PR go over ${{ env.MAX_ADDITIONS }} added lines.',
176+
'Please, decrease the size of this pull request or consider splitting it into several smaller requests.'
177+
].join(' '),
178+
'Until then, the CI will be marked as failed.',
179+
].join('\n\n'),
180+
});
181+
process.exit(1);
182+
183+
enforce-better-descriptions:
184+
name: "Title and description"
185+
if: github.event.action == 'opened' || github.event.action == 'edited'
186+
runs-on: ubuntu-latest
187+
steps:
188+
# pr title check
189+
- name: "Check that the title conforms to the simplified version of Conventional Commits"
190+
if: ${{ !cancelled() }}
191+
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
192+
with:
193+
script: |
194+
const title = context.payload.pull_request.title;
195+
const pattern = /^(revert: )?(feat|fix|chore|refactor|test)(?:\/(feat|fix|chore|refactor|test))?!?(\(.+?\))?!?: [a-z].{1,200}/;
196+
const matches = title.match(pattern) !== null;
197+
if (!matches) {
198+
core.setFailed([
199+
'Title of this pull request does not conform to the simplified version of Conventional Commits used in the documentation',
200+
`Received: ${title}`,
201+
'Expected to find a type of: feat, fix, chore, refactor, or test, followed by the parts outlined here: https://www.conventionalcommits.org/en/v1.0.0/',
202+
].join('\n'));
203+
process.exit(1);
204+
}
205+
206+
# pr close issue limits
207+
- name: "Check that there is no more than ${{ env.MAX_ISSUES_PER_PR }} linked issues"
208+
if: ${{ !cancelled() }}
209+
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
210+
with:
211+
script: |
212+
const maxIssuesAllowed = Number(process.env.MAX_ISSUES_PER_PR ?? '3');
213+
const body = context.payload.pull_request.body || '';
214+
const closePatterns = /\b(?:close[sd]?|fixes|fixed|resolve[sd]?|towards)\s+(?:https?:\/\/github\.com\/|[a-z0-9\-\_\/]*#\d+)/gi;
215+
const issueCount = [...body.matchAll(closePatterns)].length;
216+
if (issueCount > maxIssuesAllowed) {
217+
core.setFailed(`This pull request attempts to close ${issueCount} issues, while the maximum number allowed is ${maxIssuesAllowed}.`);
218+
process.exit(1);
219+
}
220+
if (issueCount === 0) {
221+
core.setFailed([
222+
'This pull request does not resolve any issues — no close patterns found in the description.',
223+
'Please, specify an issue by writing `Closes #that-issue-number` in the description of this PR.',
224+
'If there is no such issue, create a new one: https://github.com/ton-org/docs/issues/1366#issuecomment-3560650817',
225+
].join(' '));
226+
process.exit(1);
227+
}

0 commit comments

Comments
 (0)