248 lines
10 KiB
YAML
248 lines
10 KiB
YAML
name: PR Gate
|
|
|
|
on:
|
|
pull_request_target:
|
|
types: [opened, edited, reopened, synchronize]
|
|
|
|
jobs:
|
|
check-contributor:
|
|
if: github.repository == 'herdrdev/herdr' || github.repository == 'ogulcancelik/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 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 { 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();
|