mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
ci: gate pull request review by scope
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# GitHub usernames approved to contribute, one per line.
|
||||
# This does not grant maintainer authority; see .github/MAINTAINERS.
|
||||
# GitHub usernames that bypass automated PR intake, one per line.
|
||||
# This does not approve feature scope or grant maintainer authority; see .github/MAINTAINERS.
|
||||
Edmund-a7
|
||||
othavioquiliao
|
||||
edheltzel
|
||||
|
||||
@@ -8,7 +8,7 @@ body:
|
||||
|
||||
Issues are only for reproducible bugs and maintainer-created or maintainer-converted work items. Feature requests, ideas, questions, contribution proposals, and direction checks belong in [Discussions](https://github.com/ogulcancelik/herdr/discussions).
|
||||
|
||||
New PRs from unapproved contributors are auto-closed by default. Do not open a PR unless a maintainer has approved the work and approved you to contribute.
|
||||
Unapproved contributors may open focused PRs when the patch changes no more than 20 files and 1,000 total added or deleted lines. Features and larger changes require maintainer approval first.
|
||||
|
||||
Keep this short. If it does not fit on one screen, it is too long. Write in your own voice.
|
||||
|
||||
|
||||
+178
-64
@@ -2,7 +2,7 @@ name: PR Gate
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, reopened]
|
||||
types: [opened, reopened, synchronize]
|
||||
|
||||
jobs:
|
||||
check-contributor:
|
||||
@@ -12,124 +12,238 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Check if contributor is approved
|
||||
- name: Check pull request intake policy
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
github-token: ${{ secrets.KANGAL_GITHUB_TOKEN }}
|
||||
script: |
|
||||
const prAuthor = context.payload.pull_request.user.login;
|
||||
const CANONICAL_REPOSITORY = 'ogulcancelik/herdr';
|
||||
const KANGAL_USER_ID = 285672167;
|
||||
const CI_ONLY_PR_AUTHOR_IDS = new Set([
|
||||
49699333, // dependabot[bot]
|
||||
41898282, // github-actions[bot]
|
||||
]);
|
||||
const MAX_UNAPPROVED_CHANGED_FILES = 20;
|
||||
const MAX_UNAPPROVED_CHANGED_LINES = 1000;
|
||||
const REVIEW_LABELS = ['greptile-review', 'coderabbit-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 maintainerPermissions = ['admin', 'maintain', 'write'];
|
||||
const repositoryName = context.payload.repository.full_name.toLowerCase();
|
||||
|
||||
if (prAuthor.endsWith('[bot]') || prAuthor === 'dependabot[bot]') {
|
||||
console.log(`Skipping bot: ${prAuthor}`);
|
||||
if (repositoryName !== CANONICAL_REPOSITORY) {
|
||||
core.setFailed(`PR Gate only runs for ${CANONICAL_REPOSITORY}`);
|
||||
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;
|
||||
|
||||
async function getPermission(username) {
|
||||
try {
|
||||
const { data: permissionLevel } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
username,
|
||||
});
|
||||
return permissionLevel.permission;
|
||||
return data.permission;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function getTextFile(path) {
|
||||
const { data: fileContent } = await github.rest.repos.getContent({
|
||||
const { data } = await github.rest.repos.getContent({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
path,
|
||||
ref: defaultBranch,
|
||||
});
|
||||
|
||||
if (!('content' in fileContent) || typeof fileContent.content !== 'string') {
|
||||
if (!('content' in data) || typeof data.content !== 'string') {
|
||||
throw new Error(`Expected file content for ${path}`);
|
||||
}
|
||||
|
||||
return Buffer.from(fileContent.content, 'base64').toString('utf8');
|
||||
return Buffer.from(data.content, 'base64').toString('utf8');
|
||||
}
|
||||
|
||||
async function addGreptileReviewLabel() {
|
||||
function parseUserList(content) {
|
||||
return new Set(content
|
||||
.split('\n')
|
||||
.map(line => line.trim().toLowerCase())
|
||||
.filter(line => line && !line.startsWith('#')));
|
||||
}
|
||||
|
||||
const [maintainersContent, approvedContent] = await Promise.all([
|
||||
getTextFile('.github/MAINTAINERS'),
|
||||
getTextFile('.github/APPROVED_CONTRIBUTORS'),
|
||||
]);
|
||||
const maintainers = parseUserList(maintainersContent);
|
||||
const approvedContributors = parseUserList(approvedContent);
|
||||
|
||||
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: context.payload.pull_request.number,
|
||||
labels: ['greptile-review'],
|
||||
issue_number: pullNumber,
|
||||
labels: missing,
|
||||
});
|
||||
}
|
||||
|
||||
async function closePullRequest(message) {
|
||||
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: context.payload.pull_request.number,
|
||||
body: message,
|
||||
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 unapproved pull requests when the patch changes no more than ${MAX_UNAPPROVED_CHANGED_FILES} files and ${MAX_UNAPPROVED_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. Membership in `.github/APPROVED_CONTRIBUTORS` bypasses this automatic intake gate, but does not grant maintainer authority or guarantee that a change will be accepted.',
|
||||
'',
|
||||
'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: context.payload.pull_request.number,
|
||||
pull_number: pullNumber,
|
||||
state: 'closed',
|
||||
});
|
||||
}
|
||||
|
||||
const authorPermission = await getPermission(prAuthor);
|
||||
if (maintainerPermissions.includes(authorPermission)) {
|
||||
console.log(`${prAuthor} is a collaborator with ${authorPermission} access`);
|
||||
await addGreptileReviewLabel();
|
||||
return;
|
||||
}
|
||||
|
||||
if (action === 'reopened') {
|
||||
const reopenerPermission = reopener ? await getPermission(reopener) : null;
|
||||
if (maintainerPermissions.includes(reopenerPermission)) {
|
||||
console.log(`${reopener} reopened this PR with ${reopenerPermission} access; leaving it open`);
|
||||
await addGreptileReviewLabel();
|
||||
if (!(await isVerifiedMaintainer(reopener))) {
|
||||
await closePullRequest(
|
||||
'This pull request was reopened by someone other than a verified maintainer.',
|
||||
{ removeApproval: true },
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const approvedContent = await getTextFile('.github/APPROVED_CONTRIBUTORS');
|
||||
const approvedList = approvedContent
|
||||
.split('\n')
|
||||
.map(line => line.trim().toLowerCase())
|
||||
.filter(line => line && !line.startsWith('#'));
|
||||
const isApprovedContributor = approvedList.includes(prAuthor.toLowerCase());
|
||||
|
||||
if (isApprovedContributor) {
|
||||
console.log(`${prAuthor} is in the approved contributors list`);
|
||||
await addGreptileReviewLabel();
|
||||
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;
|
||||
}
|
||||
|
||||
console.log(`${prAuthor} is not approved, closing PR`);
|
||||
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;
|
||||
}
|
||||
|
||||
const message = [
|
||||
`Hi @${prAuthor}, thanks for your interest in contributing!`,
|
||||
'',
|
||||
'New contributors need maintainer approval before opening PRs. This keeps review time focused on accepted work and avoids wasted effort.',
|
||||
'',
|
||||
'Herdr is opinionated about how it should look, feel, and work. Feature requests, ideas, questions, contribution proposals, and product-direction checks belong in Discussions, not issues.',
|
||||
'',
|
||||
'**Next steps:**',
|
||||
'1. If this is a feature or behavior change, open a Discussion describing what you want to change and why',
|
||||
'2. If the work is accepted, a maintainer may convert the discussion into an issue or create a new issue for it',
|
||||
'3. If this is a reproducible bug, open a bug report using the issue template',
|
||||
'4. Wait for maintainer approval on an accepted issue before opening a PR',
|
||||
' Maintainers approve first-time PR paths with `/approve @your-github-username` on the accepted issue.',
|
||||
'5. Keep it concise and write in your own voice',
|
||||
'',
|
||||
'A discussion, issue, branch, or proposed implementation does not reserve the work and does not mean the PR path is approved.',
|
||||
'',
|
||||
`This PR will be closed automatically. See https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${defaultBranch}/CONTRIBUTING.md for more details.`,
|
||||
].join('\n');
|
||||
if (await isVerifiedMaintainer(prAuthor)) {
|
||||
core.info(`${prAuthor} is a verified maintainer`);
|
||||
await addReviewLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
await closePullRequest(message);
|
||||
if (approvedContributors.has(prAuthor.toLowerCase())) {
|
||||
core.info(`${prAuthor} is in the approved contributors list`);
|
||||
await addReviewLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
if ((await currentLabels()).has(MAINTAINER_APPROVED_LABEL)) {
|
||||
core.info(`PR #${pullNumber} has a maintainer scope override`);
|
||||
await addReviewLabels();
|
||||
return;
|
||||
}
|
||||
|
||||
const exceedsBudget = pr.changed_files > MAX_UNAPPROVED_CHANGED_FILES ||
|
||||
changedLines > MAX_UNAPPROVED_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();
|
||||
|
||||
Reference in New Issue
Block a user