feat(ci): defer issue-gate closes to a scheduled sweeper
Addresses review feedback on #1041. The gate now reads GitHub's resolved closing references (closingIssuesReferences) instead of regex-parsing the pull request body, so an issue linked through the sidebar Development panel counts, and a bare `#123` mention no longer does. It also no longer closes on the pull request event. It labels and explains; pr-sweeper.yml re-checks every six hours and closes only what is still failing 72 hours after the notice. That re-check is load-bearing: linking an issue via the sidebar fires no webhook, so an event-only gate could never observe a contributor complying that way. The sweeper also closes drafts from outside the org after 30 days. The shared check lives in .github/scripts/issue-gate.js so both workflows run identical logic, with a dependency-free self-check wired into static analysis. Its one regression guard: author_association CONTRIBUTOR stays gated, since GitHub assigns it to anyone who has previously committed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
cf7877f96f
commit
5a4e82d7df
|
|
@ -0,0 +1,247 @@
|
|||
'use strict';
|
||||
|
||||
/**
|
||||
* Issue gate — shared logic for `.github/workflows/issue-gate.yml` (immediate
|
||||
* feedback on pull request events) and `.github/workflows/pr-sweeper.yml`
|
||||
* (deferred re-check, close, and stale-draft cleanup).
|
||||
*
|
||||
* Both workflows `require` this file through actions/github-script, so it must
|
||||
* stay dependency-free: neither job runs an install step.
|
||||
*
|
||||
* See CONTRIBUTING.md for the policy this enforces.
|
||||
*/
|
||||
|
||||
const REQUIRED_LABEL = 'maintainer-approved';
|
||||
const GATE_LABEL = 'needs-approved-issue';
|
||||
const EXEMPT_LABEL = 'gate-exempt';
|
||||
const MARKER = '<!-- issue-gate -->';
|
||||
const DISCORD = 'http://discord.gg/honcho';
|
||||
|
||||
// Hours a labelled pull request has before the sweeper closes it. Measured from
|
||||
// the notice comment, so the clock starts when the author was actually told —
|
||||
// not when the pull request was opened.
|
||||
const GRACE_HOURS = 72;
|
||||
|
||||
// Days without activity before a draft from outside the org is closed.
|
||||
const DRAFT_STALE_DAYS = 30;
|
||||
|
||||
const hasLabel = (pr, name) => (pr.labels || []).some((l) => l.name === name);
|
||||
|
||||
// Write access to the repository. CONTRIBUTOR is deliberately absent: GitHub uses
|
||||
// it for "has previously committed to the repository", which describes every
|
||||
// returning outside contributor, not a maintainer. Do not add it.
|
||||
const WRITE_ACCESS = ['OWNER', 'MEMBER', 'COLLABORATOR'];
|
||||
|
||||
const CLOSING_ISSUES = `
|
||||
query($owner: String!, $repo: String!, $number: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $number) {
|
||||
closingIssuesReferences(first: 20) {
|
||||
nodes {
|
||||
number
|
||||
state
|
||||
labels(first: 50) { nodes { name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* Decide whether a pull request clears the gate.
|
||||
*
|
||||
* Reads GitHub's own resolved issue links rather than parsing the body, so both
|
||||
* `Fixes #123` and the sidebar "Development" link count. A bare `#123` mention
|
||||
* deliberately does not — that is a reference, not a claim to close.
|
||||
*
|
||||
* @returns {Promise<{passed: boolean, skipped?: string, issue?: number, reason?: string}>}
|
||||
*/
|
||||
async function checkGate({ github, owner, repo, pr }) {
|
||||
if (pr.state !== 'open') return { passed: true, skipped: 'pull request is not open' };
|
||||
if (pr.draft) return { passed: true, skipped: 'pull request is a draft' };
|
||||
if (pr.user && pr.user.type === 'Bot') return { passed: true, skipped: 'author is a bot' };
|
||||
if (WRITE_ACCESS.includes(pr.author_association)) {
|
||||
return { passed: true, skipped: `author_association is ${pr.author_association}` };
|
||||
}
|
||||
if (hasLabel(pr, EXEMPT_LABEL)) {
|
||||
return { passed: true, skipped: `carries the ${EXEMPT_LABEL} label` };
|
||||
}
|
||||
|
||||
const data = await github.graphql(CLOSING_ISSUES, { owner, repo, number: pr.number });
|
||||
const issues = data.repository.pullRequest.closingIssuesReferences.nodes;
|
||||
|
||||
if (issues.length === 0) {
|
||||
return { passed: false, reason: 'This pull request is not linked to an issue.' };
|
||||
}
|
||||
|
||||
const approved = issues.find(
|
||||
(i) => i.state === 'OPEN' && i.labels.nodes.some((l) => l.name === REQUIRED_LABEL),
|
||||
);
|
||||
if (approved) return { passed: true, issue: approved.number };
|
||||
|
||||
const detail = issues
|
||||
.map((i) => `#${i.number} (${i.state === 'CLOSED' ? 'closed' : 'not approved'})`)
|
||||
.join(', ');
|
||||
return {
|
||||
passed: false,
|
||||
reason:
|
||||
`The linked ${issues.length === 1 ? 'issue is' : 'issues are'} not open with the ` +
|
||||
`\`${REQUIRED_LABEL}\` label: ${detail}.`,
|
||||
};
|
||||
}
|
||||
|
||||
function noticeBody({ owner, repo, reason }) {
|
||||
return [
|
||||
MARKER,
|
||||
'Thanks for the contribution. This pull request does not clear our issue gate yet.',
|
||||
'',
|
||||
`**${reason}**`,
|
||||
'',
|
||||
`Every pull request to Honcho needs to be linked to an open 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](${DISCORD}) — maintainers are most active there, and it is by far the fastest route to a decision.`,
|
||||
`3. Once the issue has the label, link it: put \`Fixes #<number>\` in this pull request's description, or use **Development** in the sidebar.`,
|
||||
'',
|
||||
`**This will close automatically in ${GRACE_HOURS} hours if it is still unlinked.** Nothing is lost if that happens — link the issue, reopen, and it goes into the review queue.`,
|
||||
'',
|
||||
`See [CONTRIBUTING.md](https://github.com/${owner}/${repo}/blob/main/CONTRIBUTING.md) for the full process. If you think this is wrong, say so here and a maintainer will take a look.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
/** Every gate notice on a pull request, oldest first. */
|
||||
async function findNotices({ github, owner, repo, number }) {
|
||||
const comments = await github.paginate(github.rest.issues.listComments, {
|
||||
owner, repo, issue_number: number, per_page: 100,
|
||||
});
|
||||
return comments.filter((c) => (c.body || '').includes(MARKER));
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the gate label and delete the notice.
|
||||
*
|
||||
* Deleting matters: `runGate` posts a notice only when none exists, and the
|
||||
* sweeper measures grace from the notice timestamp. A notice left behind after
|
||||
* the gate clears would make a later re-block look weeks old and be closed with
|
||||
* no warning.
|
||||
*/
|
||||
async function clearGate({ github, owner, repo, pr }) {
|
||||
if (hasLabel(pr, GATE_LABEL)) {
|
||||
await github.rest.issues
|
||||
.removeLabel({ owner, repo, issue_number: pr.number, name: GATE_LABEL })
|
||||
.catch(() => {});
|
||||
}
|
||||
for (const notice of await findNotices({ github, owner, repo, number: pr.number })) {
|
||||
await github.rest.issues
|
||||
.deleteComment({ owner, repo, comment_id: notice.id })
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entry point for `.github/workflows/issue-gate.yml`.
|
||||
* Labels and explains. Never closes — that is the sweeper's job.
|
||||
*/
|
||||
async function runGate({ github, core, context }) {
|
||||
const pr = context.payload.pull_request;
|
||||
const { owner, repo } = context.repo;
|
||||
const result = await checkGate({ github, owner, repo, pr });
|
||||
|
||||
if (result.passed) {
|
||||
core.info(
|
||||
result.skipped ? `Skipping gate: ${result.skipped}` : `Gate passed via #${result.issue}`,
|
||||
);
|
||||
await clearGate({ github, owner, repo, pr });
|
||||
return;
|
||||
}
|
||||
|
||||
core.warning(`Gate failed: ${result.reason}`);
|
||||
await github.rest.issues.addLabels({
|
||||
owner, repo, issue_number: pr.number, labels: [GATE_LABEL],
|
||||
});
|
||||
|
||||
const notices = await findNotices({ github, owner, repo, number: pr.number });
|
||||
if (notices.length > 0) return;
|
||||
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: pr.number,
|
||||
body: noticeBody({ owner, repo, reason: result.reason }),
|
||||
});
|
||||
}
|
||||
|
||||
/** Entry point for `.github/workflows/pr-sweeper.yml`. */
|
||||
async function runSweep({ github, core, context, dryRun }) {
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
const act = async (what, fn) => {
|
||||
core.info(dryRun ? `[dry run] ${what}` : what);
|
||||
if (!dryRun) await fn();
|
||||
};
|
||||
|
||||
const close = (pr, body) => async () => {
|
||||
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body });
|
||||
await github.rest.pulls.update({ owner, repo, pull_number: pr.number, state: 'closed' });
|
||||
};
|
||||
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner, repo, state: 'open', per_page: 100,
|
||||
});
|
||||
core.info(`${prs.length} open pull requests${dryRun ? ' (dry run)' : ''}`);
|
||||
|
||||
// Re-check everything wearing the gate label. Never close blind: a pull request
|
||||
// linked through the sidebar fires no webhook, so the gate workflow cannot have
|
||||
// noticed it — this pass is the only thing that will.
|
||||
for (const pr of prs.filter((p) => hasLabel(p, GATE_LABEL))) {
|
||||
const result = await checkGate({ github, owner, repo, pr });
|
||||
|
||||
if (result.passed) {
|
||||
const why = result.skipped || `via #${result.issue}`;
|
||||
await act(`#${pr.number}: gate now clear (${why})`, async () => {
|
||||
await clearGate({ github, owner, repo, pr });
|
||||
await github.rest.issues.createComment({
|
||||
owner, repo, issue_number: pr.number,
|
||||
body: 'The issue link is in place — this pull request has cleared the gate and is waiting on review.',
|
||||
});
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const [notice] = await findNotices({ github, owner, repo, number: pr.number });
|
||||
if (!notice) {
|
||||
core.info(`#${pr.number}: labelled but never notified — leaving it for the gate workflow`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const hours = (Date.now() - Date.parse(notice.created_at)) / 3_600_000;
|
||||
if (hours < GRACE_HOURS) {
|
||||
core.info(`#${pr.number}: ${Math.round(GRACE_HOURS - hours)}h of grace left`);
|
||||
continue;
|
||||
}
|
||||
|
||||
await act(`#${pr.number}: closing — notified ${Math.round(hours)}h ago, still failing`, close(pr,
|
||||
`Closing this: ${GRACE_HOURS} hours have passed and the gate is still not clear. This is not a judgement on the code. Link an approved issue and reopen — it goes straight into the review queue.`,
|
||||
));
|
||||
}
|
||||
|
||||
// Stale drafts. The gate skips drafts entirely, so they never carry the label;
|
||||
// this pass keys off inactivity and re-applies the exemptions itself.
|
||||
for (const pr of prs.filter((p) => p.draft)) {
|
||||
if (WRITE_ACCESS.includes(pr.author_association)) continue;
|
||||
if (hasLabel(pr, EXEMPT_LABEL)) continue;
|
||||
|
||||
const days = (Date.now() - Date.parse(pr.updated_at)) / 86_400_000;
|
||||
if (days < DRAFT_STALE_DAYS) continue;
|
||||
|
||||
await act(`#${pr.number}: closing stale draft — ${Math.round(days)}d without activity`, close(pr,
|
||||
`Closing this draft after ${DRAFT_STALE_DAYS} days without activity, to keep the pull request list readable. Reopen whenever you pick it back up — nothing here is lost.`,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkGate, runGate, runSweep, noticeBody,
|
||||
REQUIRED_LABEL, GATE_LABEL, EXEMPT_LABEL, MARKER, GRACE_HOURS, DRAFT_STALE_DAYS,
|
||||
};
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
'use strict';
|
||||
|
||||
// Self-check for the gate decision logic. No framework, no install:
|
||||
// node .github/scripts/issue-gate.test.js
|
||||
// Covers checkGate() only — the side-effecting halves (runGate/runSweep) are
|
||||
// exercised against the real API via `pr-sweeper.yml`'s dry_run dispatch.
|
||||
|
||||
const assert = require('node:assert');
|
||||
const { checkGate, REQUIRED_LABEL, EXEMPT_LABEL } = require('./issue-gate.js');
|
||||
|
||||
const pull = (over = {}) => ({
|
||||
number: 1, state: 'open', draft: false,
|
||||
user: { type: 'User' }, author_association: 'NONE', labels: [],
|
||||
...over,
|
||||
});
|
||||
|
||||
// `linked` is the list of issues GitHub resolves as closing references.
|
||||
const stub = (linked) => ({
|
||||
graphql: async () => ({
|
||||
repository: { pullRequest: { closingIssuesReferences: {
|
||||
nodes: linked.map((i) => ({
|
||||
number: i.number, state: i.state || 'OPEN',
|
||||
labels: { nodes: (i.labels || []).map((name) => ({ name })) },
|
||||
})),
|
||||
} } },
|
||||
}),
|
||||
});
|
||||
|
||||
const run = (linked, over) =>
|
||||
checkGate({ github: stub(linked), owner: 'o', repo: 'r', pr: pull(over) });
|
||||
|
||||
const cases = [
|
||||
['no linked issue fails', () => run([]), (r) => r.passed === false],
|
||||
['linked but unapproved fails', () => run([{ number: 7 }]), (r) => r.passed === false],
|
||||
['linked and approved passes',
|
||||
() => run([{ number: 7, labels: [REQUIRED_LABEL] }]),
|
||||
(r) => r.passed === true && r.issue === 7],
|
||||
['approved but closed fails',
|
||||
() => run([{ number: 7, state: 'CLOSED', labels: [REQUIRED_LABEL] }]),
|
||||
(r) => r.passed === false],
|
||||
['picks the approved one out of several',
|
||||
() => run([{ number: 7 }, { number: 8, labels: [REQUIRED_LABEL] }]),
|
||||
(r) => r.passed === true && r.issue === 8],
|
||||
|
||||
// Exemptions.
|
||||
['maintainer skips', () => run([], { author_association: 'MEMBER' }), (r) => r.passed === true],
|
||||
['collaborator skips', () => run([], { author_association: 'COLLABORATOR' }), (r) => r.passed === true],
|
||||
['bot skips', () => run([], { user: { type: 'Bot' } }), (r) => r.passed === true],
|
||||
['draft skips', () => run([], { draft: true }), (r) => r.passed === true],
|
||||
[`${EXEMPT_LABEL} skips`, () => run([], { labels: [{ name: EXEMPT_LABEL }] }), (r) => r.passed === true],
|
||||
|
||||
// Regression guard: GitHub hands CONTRIBUTOR to anyone who has previously
|
||||
// committed, i.e. every returning outside contributor. It must stay gated.
|
||||
['CONTRIBUTOR is still gated',
|
||||
() => run([], { author_association: 'CONTRIBUTOR' }),
|
||||
(r) => r.passed === false],
|
||||
];
|
||||
|
||||
(async () => {
|
||||
let failed = 0;
|
||||
for (const [name, thunk, ok] of cases) {
|
||||
const result = await thunk();
|
||||
if (ok(result)) {
|
||||
console.log(` ok ${name}`);
|
||||
} else {
|
||||
failed++;
|
||||
console.log(` FAIL ${name} -> ${JSON.stringify(result)}`);
|
||||
}
|
||||
}
|
||||
assert.strictEqual(failed, 0, `${failed} case(s) failed`);
|
||||
console.log(`\n${cases.length} passed`);
|
||||
})();
|
||||
|
|
@ -1,20 +1,27 @@
|
|||
name: Issue Gate
|
||||
|
||||
# Closes pull requests that are not linked to an issue carrying the
|
||||
# `maintainer-approved` label. See CONTRIBUTING.md for the policy.
|
||||
# Labels pull requests that are not linked to an issue carrying the
|
||||
# `maintainer-approved` label, and comments explaining how to fix it.
|
||||
#
|
||||
# `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.
|
||||
# This workflow never closes anything. `pr-sweeper.yml` re-checks later and closes
|
||||
# only after the grace period — that gives contributors time to link an issue, and
|
||||
# gives maintainers time to wave through a one-line fix. It is also the only thing
|
||||
# that can notice a sidebar issue link, which fires no webhook of its own.
|
||||
#
|
||||
# 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.
|
||||
# `pull_request_target` is required so the job has write access on pull requests
|
||||
# from forks. It must therefore NEVER run code from the pull request. The checkout
|
||||
# below is safe because on `pull_request_target` actions/checkout defaults to the
|
||||
# BASE ref, which is repo-trusted code. Never point it at `pr.head.sha`.
|
||||
#
|
||||
# Not triggered on `synchronize`: re-running on every push would be noise.
|
||||
# Drafts are ignored until marked ready.
|
||||
|
||||
on:
|
||||
pull_request_target:
|
||||
types: [opened, edited, reopened, ready_for_review]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
|
|
@ -22,99 +29,9 @@ jobs:
|
|||
gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- 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',
|
||||
});
|
||||
const gate = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/issue-gate.js`);
|
||||
await gate.runGate({ github, core, context });
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
name: PR Sweeper
|
||||
|
||||
# Deferred half of the issue gate. Every six hours:
|
||||
#
|
||||
# 1. Re-check every pull request carrying `needs-approved-issue`. Clear the ones
|
||||
# that now link an approved issue; close the ones still failing 72h after they
|
||||
# were told. The re-check is the point — linking an issue through the sidebar
|
||||
# fires no webhook, so `issue-gate.yml` never sees it.
|
||||
# 2. Close drafts from outside the org after 30 days without activity.
|
||||
#
|
||||
# Runs on `schedule`, so it never touches pull request code and needs none of the
|
||||
# `pull_request_target` precautions. Dispatch manually with dry_run to see what it
|
||||
# would do before it does it.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 */6 * * *'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
dry_run:
|
||||
description: 'Log intended actions without closing anything'
|
||||
type: boolean
|
||||
default: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
sweep:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/github-script@v7
|
||||
env:
|
||||
DRY_RUN: ${{ github.event_name == 'workflow_dispatch' && inputs.dry_run || 'false' }}
|
||||
with:
|
||||
script: |
|
||||
const gate = require(`${process.env.GITHUB_WORKSPACE}/.github/scripts/issue-gate.js`);
|
||||
await gate.runSweep({ github, core, context, dryRun: process.env.DRY_RUN === 'true' });
|
||||
|
|
@ -26,3 +26,11 @@ jobs:
|
|||
run: uv sync --all-extras --dev
|
||||
- name: run basedpyright
|
||||
run: uv run basedpyright
|
||||
|
||||
issue-gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
# The gate runs from `pull_request_target`, where a crash is invisible until
|
||||
# a contributor's PR is silently ungated. Check it here instead.
|
||||
- run: node .github/scripts/issue-gate.test.js
|
||||
|
|
|
|||
|
|
@ -26,10 +26,12 @@ The rules below exist so that the work you do has somewhere to land — not to k
|
|||
|
||||
**Every pull request needs an issue, and that issue needs the `maintainer-approved` label.**
|
||||
|
||||
A pull request that is not linked to an issue, or that is linked to an issue without the
|
||||
label, will be closed without review. This is automated. We do this because an unreviewable
|
||||
backlog helps nobody: a PR against an unapproved issue is work you did that we may not be
|
||||
able to merge, no matter how good it is.
|
||||
A pull request that is not linked to an approved issue gets labelled
|
||||
`needs-approved-issue`, with a comment explaining why. You then have 72 hours to link one
|
||||
before it is closed automatically. Reopening costs nothing once the link is in place. This
|
||||
is automated. We do this because an unreviewable backlog helps nobody: a PR against an
|
||||
unapproved issue is work you did that we may not be able to merge, no matter how good it
|
||||
is.
|
||||
|
||||
So, in order:
|
||||
|
||||
|
|
@ -47,7 +49,8 @@ So, in order:
|
|||
issue tracker, and a five-minute conversation about what you want to build usually
|
||||
resolves whether it fits before either side spends real time on it.
|
||||
|
||||
4. **Then open the PR**, with `Fixes #123` in the body.
|
||||
4. **Then open the PR** and link the issue — either `Fixes #123` in the description, or
|
||||
**Development → link an issue** in the sidebar. Both work.
|
||||
|
||||
Small exceptions we will not be pedantic about: fixing a typo, a broken link, or an
|
||||
obviously wrong code sample. Open the PR, explain it in one line, and we will sort out the
|
||||
|
|
@ -317,8 +320,9 @@ If you have the choice, fork from your personal account.
|
|||
a screenshot, the failing case before and after. This is the section that most determines
|
||||
how fast your PR gets reviewed. Do not add sections to the template.
|
||||
|
||||
Include `Fixes #123` so the issue link is machine-readable — the automated gate reads the PR
|
||||
body.
|
||||
Link the issue so the gate can see it: `Fixes #123` in the description, or the
|
||||
**Development** section of the sidebar. The gate reads GitHub's own resolved issue links, so
|
||||
either route works — but a bare `#123` mention is only a reference and does not count.
|
||||
|
||||
### Review
|
||||
|
||||
|
|
|
|||
|
|
@ -15,10 +15,12 @@ The rules below exist so that the work you do has somewhere to land — not to k
|
|||
|
||||
**Every pull request needs an issue, and that issue needs the `maintainer-approved` label.**
|
||||
|
||||
A pull request that is not linked to an issue, or that is linked to an issue without the
|
||||
label, will be closed without review. This is automated. We do this because an unreviewable
|
||||
backlog helps nobody: a PR against an unapproved issue is work you did that we may not be
|
||||
able to merge, no matter how good it is.
|
||||
A pull request that is not linked to an approved issue gets labelled
|
||||
`needs-approved-issue`, with a comment explaining why. You then have 72 hours to link one
|
||||
before it is closed automatically. Reopening costs nothing once the link is in place. This
|
||||
is automated. We do this because an unreviewable backlog helps nobody: a PR against an
|
||||
unapproved issue is work you did that we may not be able to merge, no matter how good it
|
||||
is.
|
||||
|
||||
So, in order:
|
||||
|
||||
|
|
@ -36,7 +38,8 @@ So, in order:
|
|||
issue tracker, and a five-minute conversation about what you want to build usually
|
||||
resolves whether it fits before either side spends real time on it.
|
||||
|
||||
4. **Then open the PR**, with `Fixes #123` in the body.
|
||||
4. **Then open the PR** and link the issue — either `Fixes #123` in the description, or
|
||||
**Development → link an issue** in the sidebar. Both work.
|
||||
|
||||
Small exceptions we will not be pedantic about: fixing a typo, a broken link, or an
|
||||
obviously wrong code sample. Open the PR, explain it in one line, and we will sort out the
|
||||
|
|
@ -306,8 +309,9 @@ If you have the choice, fork from your personal account.
|
|||
a screenshot, the failing case before and after. This is the section that most determines
|
||||
how fast your PR gets reviewed. Do not add sections to the template.
|
||||
|
||||
Include `Fixes #123` so the issue link is machine-readable — the automated gate reads the PR
|
||||
body.
|
||||
Link the issue so the gate can see it: `Fixes #123` in the description, or the
|
||||
**Development** section of the sidebar. The gate reads GitHub's own resolved issue links, so
|
||||
either route works — but a bare `#123` mention is only a reference and does not count.
|
||||
|
||||
### Review
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue