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
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
|
name: PR Gate
on:
pull_request_target:
types: [opened, edited, reopened, synchronize]
jobs:
check-contributor:
runs-on: ubuntu-latest
permissions:
contents: read
issues: write
pull-requests: write
steps:
- name: Check pull request intake policy
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
github-token: ${{ secrets.KANGAL_GITHUB_TOKEN }}
script: |
const CANONICAL_REPOSITORIES = new Set([
'ogulcancelik/herdr',
'herdrdev/herdr',
]);
const KANGAL_USER_ID = 285672167;
const CI_ONLY_PR_AUTHOR_IDS = new Set([
49699333, // dependabot[bot]
41898282, // github-actions[bot]
]);
const MAX_EXTERNAL_CHANGED_FILES = 20;
const MAX_EXTERNAL_CHANGED_LINES = 1000;
const REVIEW_LABELS = ['ai-review'];
const MAINTAINER_APPROVED_LABEL = 'maintainer-approved';
const COMMENT_MARKER = '<!-- herdr:pr-gate -->';
const pullNumber = context.payload.pull_request.number;
const reopener = context.payload.sender?.login ?? null;
const action = context.payload.action;
const defaultBranch = context.payload.repository.default_branch;
const repositoryName = context.payload.repository.full_name.toLowerCase();
if (!CANONICAL_REPOSITORIES.has(repositoryName)) {
core.setFailed(`PR Gate only runs for ${[...CANONICAL_REPOSITORIES].join(' or ')}`);
return;
}
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pullNumber,
});
const prAuthor = pr.user.login;
const changedLines = pr.additions + pr.deletions;
const eventPullRequestState = context.payload.pull_request.state;
if (action === 'edited' &&
(eventPullRequestState !== 'open' || pr.state !== 'open')) {
core.info(`Ignoring edits to closed PR #${pullNumber}`);
return;
}
async function getPermission(username) {
try {
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner: context.repo.owner,
repo: context.repo.repo,
username,
});
return data.permission;
} catch {
return null;
}
}
async function getTextFile(path) {
const { data } = await github.rest.repos.getContent({
owner: context.repo.owner,
repo: context.repo.repo,
path,
ref: defaultBranch,
});
if (!('content' in data) || typeof data.content !== 'string') {
throw new Error(`Expected file content for ${path}`);
}
return Buffer.from(data.content, 'base64').toString('utf8');
}
function parseUserList(content) {
return new Set(content
.split('\n')
.map(line => line.trim().toLowerCase())
.filter(line => line && !line.startsWith('#')));
}
const maintainers = parseUserList(await getTextFile('.github/MAINTAINERS'));
async function isVerifiedMaintainer(username) {
if (!username || !maintainers.has(username.toLowerCase())) return false;
return ['admin', 'maintain', 'write'].includes(await getPermission(username));
}
async function currentLabels() {
const labels = await github.paginate(github.rest.issues.listLabelsOnIssue, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullNumber,
per_page: 100,
});
return new Set(labels.map(label => label.name));
}
async function addLabels(names) {
const labels = await currentLabels();
const missing = names.filter(name => !labels.has(name));
if (missing.length === 0) return;
await github.rest.issues.addLabels({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullNumber,
labels: missing,
});
}
async function removeLabels(names) {
const labels = await currentLabels();
for (const name of names) {
if (!labels.has(name)) continue;
await github.rest.issues.removeLabelForIssue({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullNumber,
name,
});
}
}
async function addReviewLabels() {
await addLabels(REVIEW_LABELS);
}
async function removeReviewLabels() {
await removeLabels(REVIEW_LABELS);
}
async function upsertGateComment(message) {
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullNumber,
per_page: 100,
});
const existing = comments.find(comment =>
comment.user?.id === KANGAL_USER_ID && comment.body?.includes(COMMENT_MARKER));
const body = `${COMMENT_MARKER}\n${message}`;
if (existing) {
await github.rest.issues.updateComment({
owner: context.repo.owner,
repo: context.repo.repo,
comment_id: existing.id,
body,
});
return;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullNumber,
body,
});
}
async function closePullRequest(reason, { removeApproval = false } = {}) {
const labels = await currentLabels();
if (!removeApproval && labels.has(MAINTAINER_APPROVED_LABEL)) {
core.info(`PR #${pullNumber} has a maintainer scope override; leaving it open`);
await addReviewLabels();
return;
}
await removeLabels(removeApproval
? [...REVIEW_LABELS, MAINTAINER_APPROVED_LABEL]
: REVIEW_LABELS);
const message = [
`Hi @${prAuthor}, thanks for your interest in contributing!`,
'',
`Herdr automatically admits focused bug fixes from contributors who are not maintainers when the title uses \`fix: ...\` or \`fix(scope): ...\` and the patch changes no more than ${MAX_EXTERNAL_CHANGED_FILES} files and ${MAX_EXTERNAL_CHANGED_LINES.toLocaleString('en-US')} total added or deleted lines.`,
'',
reason,
'',
'Feature requests, behavior changes, and other proposals belong in GitHub Discussions and require maintainer approval before a pull request.',
'',
'If this gate classified the pull request incorrectly, reply and tag a maintainer listed in `.github/MAINTAINERS`. A verified maintainer can reopen it; reopening by anyone else will be closed again automatically.',
'',
`Patch size: ${pr.changed_files} changed files, ${changedLines} changed lines.`,
'',
`See https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md for the contribution policy.`,
].join('\n');
await upsertGateComment(message);
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pullNumber,
state: 'closed',
});
}
if (action === 'reopened') {
if (!(await isVerifiedMaintainer(reopener))) {
await closePullRequest(
'This pull request was reopened by someone other than a verified maintainer.',
{ removeApproval: true },
);
return;
}
core.info(`${reopener} is a verified maintainer; leaving reopened PR #${pullNumber} open`);
if (CI_ONLY_PR_AUTHOR_IDS.has(pr.user.id)) {
await removeReviewLabels();
} else {
await addLabels([...REVIEW_LABELS, MAINTAINER_APPROVED_LABEL]);
}
return;
}
if (CI_ONLY_PR_AUTHOR_IDS.has(pr.user.id)) {
core.info(`Leaving CI-only bot PR open without automated AI review: ${prAuthor}`);
await removeReviewLabels();
return;
}
if (await isVerifiedMaintainer(prAuthor)) {
core.info(`${prAuthor} is a verified maintainer`);
await addReviewLabels();
return;
}
if ((await currentLabels()).has(MAINTAINER_APPROVED_LABEL)) {
core.info(`PR #${pullNumber} has a maintainer scope override`);
await addReviewLabels();
return;
}
const hasFixTitle = /^fix(?:\([^)]+\))?:\s+\S/.test(pr.title);
if (!hasFixTitle) {
await closePullRequest(
'Contributors who are not maintainers may submit only focused bug fixes. If this pull request fixes a bug, rename it to use a conventional `fix: ...` or `fix(scope): ...` title, then tag a maintainer to review and reopen it.',
);
return;
}
const exceedsBudget = pr.changed_files > MAX_EXTERNAL_CHANGED_FILES ||
changedLines > MAX_EXTERNAL_CHANGED_LINES;
if (exceedsBudget) {
await closePullRequest('The current patch exceeds the automatic intake budget and needs maintainer alignment before review.');
return;
}
core.info(`Admitting scoped pull request from ${prAuthor}: ${pr.changed_files} files, ${changedLines} lines`);
await addReviewLabels();
|