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
|
name: PR Gate
on:
pull_request_target:
types: [opened, closed, reopened, ready_for_review]
concurrency:
group: pr-gate-${{ github.event.pull_request.number }}
cancel-in-progress: false
jobs:
check-contributor:
if: github.repository == 'herdrdev/herdr'
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 KANGAL_USER_ID = 285672167;
const CI_ONLY_PR_AUTHOR_IDS = new Set([
49699333, // dependabot[bot]
41898282, // github-actions[bot]
]);
const REVIEW_TRIGGER_MARKER = '<!-- herdr:ai-review-trigger -->';
const COMMENT_MARKER = '<!-- herdr:pr-gate -->';
const pullNumber = context.payload.pull_request.number;
const defaultBranch = context.payload.repository.default_branch;
const { data: pr } = await github.rest.pulls.get({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pullNumber,
});
const prAuthor = pr.user.login;
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 [maintainersContent, approvedContributorsContent] = await Promise.all([
getTextFile('.github/MAINTAINERS'),
getTextFile('.github/APPROVED_CONTRIBUTORS'),
]);
const maintainers = parseUserList(maintainersContent);
const approvedContributors = parseUserList(approvedContributorsContent);
async function isVerifiedMaintainer(username) {
if (!username || !maintainers.has(username.toLowerCase())) return false;
return ['admin', 'maintain', 'write'].includes(await getPermission(username));
}
async function hasVerifiedRecovery() {
const events = await github.paginate(github.rest.issues.listEventsForTimeline, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullNumber,
per_page: 100,
});
const latestStateEvent = events.findLast(event =>
['closed', 'reopened'].includes(event.event));
return latestStateEvent?.event === 'reopened' &&
await isVerifiedMaintainer(latestStateEvent.actor?.login);
}
async function requestAiReviews() {
if (pr.draft) {
core.info(`PR #${pullNumber} is a draft; deferring AI reviews until ready`);
return;
}
const marker = REVIEW_TRIGGER_MARKER;
const comments = await github.paginate(github.rest.issues.listComments, {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullNumber,
per_page: 100,
});
if (comments.some(comment =>
comment.user?.id === KANGAL_USER_ID && comment.body?.includes(marker))) {
core.info(`AI reviews already requested for PR #${pullNumber}`);
return;
}
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: pullNumber,
body: [marker, '@coderabbitai review', '@greptileai'].join('\n'),
});
}
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?.body === body) return;
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) {
if (await hasVerifiedRecovery()) {
core.info(`PR #${pullNumber} was recovered by a verified maintainer; leaving it open`);
await requestAiReviews();
return;
}
const message = [
`Hi @${prAuthor}, thanks for your interest in contributing.`,
'',
'Herdr does not accept unsolicited implementation pull requests from contributors who are not listed in `.github/APPROVED_CONTRIBUTORS`.',
'',
reason,
'',
'If you encountered a reproducible bug, report the observed behavior through the bug issue template. A report does not reserve the work or authorize a pull request; accepted fixes are normally implemented by Herdr’s maintainer-controlled agents.',
'',
'Feature requests, behavior changes, and other proposals belong in GitHub Discussions. Do not open an issue merely to justify an implementation that was already written.',
'',
'If a maintainer explicitly wants this implementation, they can reopen the pull request. Reopening by anyone else will be closed again automatically.',
'',
`See https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md for the contribution policy.`,
].join('\n');
await upsertGateComment(message);
if (await hasVerifiedRecovery()) {
core.info(`PR #${pullNumber} was recovered while the gate was running; leaving it open`);
await requestAiReviews();
return;
}
await github.rest.pulls.update({
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: pullNumber,
state: 'closed',
});
}
if (pr.state === 'closed') return;
if (CI_ONLY_PR_AUTHOR_IDS.has(pr.user.id)) {
core.info(`Leaving CI-only bot PR open without automated AI review: ${prAuthor}`);
return;
}
if (await isVerifiedMaintainer(prAuthor)) {
core.info(`${prAuthor} is a verified maintainer`);
await requestAiReviews();
return;
}
if (approvedContributors.has(prAuthor.toLowerCase())) {
core.info(`${prAuthor} is in the approved contributors list`);
await requestAiReviews();
return;
}
await closePullRequest('The pull request author is not an approved contributor.');
|