-
Notifications
You must be signed in to change notification settings - Fork 6
136 lines (124 loc) · 6.35 KB
/
Copy pathpr-gate.yml
File metadata and controls
136 lines (124 loc) · 6.35 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
name: PR gate
# Filters pull requests from outside contributors before a maintainer reads
# them. Many arrive from accounts that open hundreds of unrequested PRs a
# month; the rules below are the ones in docs/CONTRIBUTING.md, so a PR that
# follows the guide passes and one that ignores it is closed with a comment.
#
# pull_request_target runs in the context of the base branch, with a token
# that can comment and close. That is safe only because this workflow never
# checks out or runs code from the pull request: it reads the PR through the
# API and nothing else. Keep it that way.
on:
pull_request_target: # zizmor: ignore[dangerous-triggers] -- no PR code is checked out or run
types: [opened, reopened, edited, synchronize, labeled]
permissions: {}
# Serialize runs per PR, so two quick events (opened + edited) do not both
# find no earlier comment and post two.
concurrency:
group: pr-gate-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
gate:
# Maintainers, collaborators and bots (Renovate, Dependabot) are exempt.
if: >-
github.event.pull_request.user.type != 'Bot' &&
!contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'), github.event.pull_request.author_association)
runs-on: ubuntu-latest
permissions:
issues: read
pull-requests: write
steps:
- name: Check the contribution rules
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
with:
script: |
const pr = context.payload.pull_request;
const { owner, repo } = context.repo;
const author = pr.user.login.toLowerCase();
const marker = '<!-- pr-gate -->';
const guide = `https://github.com/${owner}/${repo}/blob/master/docs/CONTRIBUTING.md`;
if (pr.state !== 'open') return;
const comments = await github.paginate(github.rest.issues.listComments, {
owner, repo, issue_number: pr.number, per_page: 100,
});
const previous = comments.find(
(c) => c.user.login === 'github-actions[bot]' && c.body.startsWith(marker),
);
const upsert = async (body) => {
if (previous) {
await github.rest.issues.updateComment({ owner, repo, comment_id: previous.id, body });
} else {
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
}
};
if (pr.labels.some((label) => label.name === 'skip-pr-gate')) {
core.info('skip-pr-gate label present; not checking.');
if (previous) await upsert(`${marker}\nA maintainer waived this check.`);
return;
}
// 1. The PR must close an issue that a maintainer assigned to its author.
const escaped = `${owner}/${repo}`.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
const closing = new RegExp(
`\\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?):?\\s+(?:#|${escaped}#|https?://github\\.com/${escaped}/issues/)(\\d+)\\b`,
'gi',
);
// Capped: each reference costs an API call from the repository's shared quota.
const linked = [...new Set([...(pr.body ?? '').matchAll(closing)].map((m) => Number(m[1])))].slice(0, 10);
const blocking = [];
if (linked.length === 0) {
blocking.push('The description does not close an issue (`Fixes #123`).');
} else {
let assigned = false;
for (const number of linked) {
if (assigned) break;
try {
const { data: issue } = await github.rest.issues.get({ owner, repo, issue_number: number });
if (!issue.pull_request && issue.assignees.some((a) => a.login.toLowerCase() === author)) {
assigned = true;
}
} catch (error) {
if (error.status !== 404 && error.status !== 410) throw error;
}
}
if (!assigned) {
blocking.push(
`You are not assigned to ${linked.map((n) => `#${n}`).join(', ')}. ` +
'Ask on the issue first; a maintainer assigns it to you before you open a PR.',
);
}
}
// 2. A change to the package needs tests and a changelog fragment.
const files = await github.paginate(github.rest.pulls.listFiles, {
owner, repo, pull_number: pr.number, per_page: 100,
});
const changed = files.filter((f) => f.status !== 'removed').map((f) => f.filename);
const missing = [];
if (files.some((f) => f.filename.startsWith('fastapi_cachex/'))) {
if (!changed.some((name) => name.startsWith('tests/'))) {
missing.push('The change to `fastapi_cachex/` comes without a test under `tests/`.');
}
if (!changed.some((name) => /^changelog\.d\/\d+\.[a-z]+(\.\d+)?\.md$/.test(name))) {
missing.push('The change to `fastapi_cachex/` comes without a changelog fragment (`changelog.d/<issue>.<section>.md`).');
}
}
if (blocking.length === 0 && missing.length === 0) {
if (previous) await upsert(`${marker}\nThe contribution rules are met now. Thanks!`);
return;
}
const list = [...blocking, ...missing].map((line) => `- ${line}`).join('\n');
if (blocking.length > 0) {
await upsert(
`${marker}\nThanks for the pull request. This project only takes pull requests for ` +
`issues that have been assigned to the author, so I am closing this one:\n\n${list}\n\n` +
`See the [contributing guide](${guide}). Once a maintainer has assigned the issue to you, ` +
'open a new pull request.',
);
await github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: 'closed' });
core.setFailed('Closed: the pull request does not close an issue assigned to its author.');
} else {
await upsert(
`${marker}\nThanks for the pull request. Before review it still needs:\n\n${list}\n\n` +
`See the [contributing guide](${guide}). Push the missing pieces to this branch and the check reruns.`,
);
core.setFailed('The pull request is missing tests or a changelog fragment.');
}