121 lines
5.8 KiB
YAML
121 lines
5.8 KiB
YAML
name: Issue Gate
|
|
|
|
# Closes pull requests that are not linked to an issue carrying the
|
|
# `maintainer-approved` label. See CONTRIBUTING.md for the policy.
|
|
#
|
|
# `pull_request_target` is required so the job has write access on PRs from
|
|
# forks. This workflow must therefore NEVER check out or execute code from the
|
|
# pull request — it only calls the GitHub API.
|
|
#
|
|
# Not triggered on `synchronize`: re-running the gate on every push to an
|
|
# in-flight PR would be noise. Drafts are ignored until marked ready.
|
|
|
|
on:
|
|
pull_request_target:
|
|
types: [opened, edited, reopened, ready_for_review]
|
|
|
|
permissions:
|
|
issues: write
|
|
pull-requests: write
|
|
|
|
jobs:
|
|
gate:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/github-script@v7
|
|
with:
|
|
script: |
|
|
const pr = context.payload.pull_request;
|
|
const { owner, repo } = context.repo;
|
|
const GATE_LABEL = 'needs-approved-issue';
|
|
const EXEMPT_LABEL = 'gate-exempt';
|
|
const REQUIRED_LABEL = 'maintainer-approved';
|
|
const MARKER = '<!-- issue-gate -->';
|
|
|
|
const skip = (why) => core.info(`Skipping gate: ${why}`);
|
|
|
|
if (pr.state !== 'open') return skip('pull request is not open');
|
|
if (pr.draft) return skip('pull request is a draft');
|
|
if (pr.user.type === 'Bot') return skip('author is a bot');
|
|
if (['OWNER', 'MEMBER', 'COLLABORATOR'].includes(pr.author_association)) {
|
|
return skip(`author_association is ${pr.author_association}`);
|
|
}
|
|
if ((pr.labels || []).some((l) => l.name === EXEMPT_LABEL)) {
|
|
return skip(`pull request carries the ${EXEMPT_LABEL} label`);
|
|
}
|
|
|
|
// Collect candidate issue numbers from the PR body. HTML comments are
|
|
// stripped first so the commented-out `Fixes #XXX` hint in the template
|
|
// never counts. Any `#123` is treated as a candidate, not just the
|
|
// closing keywords — being generous here only risks letting a PR
|
|
// through, while being strict risks closing a legitimate one.
|
|
const body = (pr.body || '').replace(/<!--[\s\S]*?-->/g, '');
|
|
const numbers = new Set();
|
|
for (const m of body.matchAll(/#(\d+)\b/g)) numbers.add(Number(m[1]));
|
|
const urlPattern = new RegExp(`github\\.com/${owner}/${repo}/issues/(\\d+)`, 'gi');
|
|
for (const m of body.matchAll(urlPattern)) numbers.add(Number(m[1]));
|
|
|
|
let approved = null;
|
|
const seen = [];
|
|
for (const n of [...numbers].slice(0, 10)) {
|
|
let issue;
|
|
try {
|
|
({ data: issue } = await github.rest.issues.get({ owner, repo, issue_number: n }));
|
|
} catch (e) {
|
|
if (e.status === 404) { seen.push(`#${n} (not found)`); continue; }
|
|
throw e;
|
|
}
|
|
if (issue.pull_request) { seen.push(`#${n} (is a pull request)`); continue; }
|
|
if (issue.labels.some((l) => (l.name || l) === REQUIRED_LABEL)) { approved = n; break; }
|
|
seen.push(`#${n} (not approved)`);
|
|
}
|
|
|
|
if (approved) {
|
|
core.info(`Gate passed via #${approved}`);
|
|
if ((pr.labels || []).some((l) => l.name === GATE_LABEL)) {
|
|
await github.rest.issues.removeLabel({
|
|
owner, repo, issue_number: pr.number, name: GATE_LABEL,
|
|
}).catch(() => {});
|
|
}
|
|
return;
|
|
}
|
|
|
|
const reason = numbers.size === 0
|
|
? 'This pull request does not reference an issue in its description.'
|
|
: `The referenced ${seen.length === 1 ? 'issue does' : 'issues do'} not have the \`${REQUIRED_LABEL}\` label: ${seen.join(', ')}.`;
|
|
|
|
core.warning(`Gate failed: ${reason}`);
|
|
|
|
await github.rest.issues.addLabels({
|
|
owner, repo, issue_number: pr.number, labels: [GATE_LABEL],
|
|
});
|
|
|
|
const comments = await github.paginate(github.rest.issues.listComments, {
|
|
owner, repo, issue_number: pr.number, per_page: 100,
|
|
});
|
|
if (!comments.some((c) => (c.body || '').includes(MARKER))) {
|
|
await github.rest.issues.createComment({
|
|
owner, repo, issue_number: pr.number,
|
|
body: [
|
|
MARKER,
|
|
'Thanks for the contribution. Closing this for now, because it does not clear our issue gate.',
|
|
'',
|
|
`**${reason}**`,
|
|
'',
|
|
`Every pull request to Honcho needs to be linked to an issue carrying the \`${REQUIRED_LABEL}\` label. We do this so the review queue only holds work we have already agreed should be built — it means nobody spends time on a change we cannot merge.`,
|
|
'',
|
|
'To get this moving:',
|
|
'',
|
|
`1. Find or open an issue describing the change. [Approved issues are here](https://github.com/${owner}/${repo}/issues?q=is%3Aissue+is%3Aopen+label%3A${REQUIRED_LABEL}).`,
|
|
'2. Make the case for it in [Discord](http://discord.gg/honcho) — maintainers are most active there, and it is by far the fastest route to a decision.',
|
|
`3. Once the issue has the \`${REQUIRED_LABEL}\` label, add \`Fixes #<number>\` to this pull request's description and reopen it.`,
|
|
'',
|
|
`See [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md) for the full process. If you think this was closed in error, comment here and a maintainer will take a look.`,
|
|
].join('\n'),
|
|
});
|
|
}
|
|
|
|
await github.rest.pulls.update({
|
|
owner, repo, pull_number: pr.number, state: 'closed',
|
|
});
|