diff --git a/.github/workflows/close-draft-prs-sweep.yml b/.github/workflows/close-draft-prs-sweep.yml new file mode 100644 index 000000000..160bea5f7 --- /dev/null +++ b/.github/workflows/close-draft-prs-sweep.yml @@ -0,0 +1,112 @@ +name: Close Draft PRs (sweep) + +# Companion to close-draft-prs.yml. That workflow runs per-PR on +# pull_request_target and is the fast path. GitHub does NOT dispatch +# pull_request_target for fork branches whose names look like a Git SHA, so a +# fork draft PR on a SHA-named branch can never be closed by any PR-triggered +# event (a pull_request run from a fork gets a read-only token). This scheduled +# sweep runs in the base-repo context with a write-capable token and enforces +# the identical policy on a timer, catching that evasion. See issue #761. + +on: + schedule: + - cron: '0 */6 * * *' + workflow_dispatch: + +concurrency: + group: close-draft-prs-sweep + cancel-in-progress: false + +permissions: + pull-requests: write + +jobs: + sweep-draft-prs: + name: Sweep open draft PRs + runs-on: ubuntu-latest + steps: + - name: Close non-maintainer draft PRs + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + // Maintainers may use draft PRs for internal coordination — same + // carve-out as close-draft-prs.yml. A scheduled run has no + // github.event.pull_request, so the carve-out is applied here as a + // Set membership test over author_association. + const MAINTAINER_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']); + const repoUrl = context.repo.owner + '/' + context.repo.repo; + + const commentBody = [ + '## Draft PRs are not accepted', + '', + 'This project only accepts completed pull requests. Draft PRs are automatically closed.', + '', + '**Why?** GSD requires all PRs to be ready for review when opened \u2014 with tests passing, the correct PR template used, and a linked approved issue. Draft PRs bypass these quality gates and create review overhead.', + '', + '### What to do instead', + '', + '1. Finish your implementation locally', + '2. Run `npm run test:coverage` and confirm all tests pass', + '3. Open a **non-draft** PR using the [correct template](https://github.com/' + repoUrl + '/blob/main/CONTRIBUTING.md#pull-request-guidelines)', + '', + 'See [CONTRIBUTING.md](https://github.com/' + repoUrl + '/blob/main/CONTRIBUTING.md) for the full process.', + ].join('\n'); + + const isEnforceableDraft = (pr) => + !!pr && pr.state === 'open' && pr.draft === true && + !MAINTAINER_ASSOCIATIONS.has(pr.author_association); + + const openPulls = await github.paginate(github.rest.pulls.list, { + owner: context.repo.owner, + repo: context.repo.repo, + state: 'open', + per_page: 100, + }); + + const candidates = openPulls.filter(isEnforceableDraft); + core.info('Sweep found ' + candidates.length + ' non-maintainer draft PR(s) of ' + openPulls.length + ' open.'); + + const failures = []; + + for (const candidate of candidates) { + try { + // Re-fetch immediately before mutating: the contributor may have + // marked the PR ready (or it may have closed) since pagination. + const { data: pr } = await github.rest.pulls.get({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: candidate.number, + }); + + if (!isEnforceableDraft(pr)) { + core.info('Skipping PR #' + candidate.number + ' — no longer an open non-maintainer draft.'); + continue; + } + + // Close FIRST so enforcement (the primary action) is never gated + // on the explanatory comment. A closed PR is never revisited by a + // later sweep, so this also prevents duplicate comments. + await github.rest.pulls.update({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number, + state: 'closed' + }); + + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: commentBody + }); + + core.info('Closed draft PR #' + pr.number + ': ' + pr.title); + } catch (err) { + failures.push(candidate.number); + core.warning('Failed to process draft PR #' + candidate.number + ': ' + err.message); + } + } + + if (failures.length > 0) { + core.setFailed('Sweep encountered errors on ' + failures.length + ' draft PR(s): ' + failures.join(', ')); + } diff --git a/tests/workflow-maintainer-skip.test.cjs b/tests/workflow-maintainer-skip.test.cjs index 60e78ff5a..3f91a6c6d 100644 --- a/tests/workflow-maintainer-skip.test.cjs +++ b/tests/workflow-maintainer-skip.test.cjs @@ -46,4 +46,36 @@ describe('PR policy workflow maintainer carve-outs', () => { assertMaintainerSkip(workflow); }); + + test('draft PR sweep enforces the same policy as the event-driven close', () => { + const workflow = readWorkflow('.github/workflows/close-draft-prs-sweep.yml'); + + // Timer-driven in base-repo context, plus a manual dispatch for testing. + // It must NOT be a fork-triggered event (no pull_request / pull_request_target trigger). + assert.match(workflow, /schedule:/); + assert.match(workflow, /cron:\s*'0 \*\/6 \* \* \*'/); + assert.match(workflow, /workflow_dispatch:/); + assert.doesNotMatch(workflow, /^\s*pull_request(_target)?:/m); + + // Write-capable token (needed to close PRs from base context). Tolerant of + // intervening blank lines or additional permission keys. + assert.match(workflow, /permissions:\s+pull-requests:\s*write/); + + // Identical maintainer carve-out to close-draft-prs.yml — a Set membership + // test over author_association, negated (no github.event.pull_request in a + // scheduled run). + assert.match(workflow, /new Set\(\['OWNER', 'MEMBER', 'COLLABORATOR'\]\)/); + assert.match(workflow, /!MAINTAINER_ASSOCIATIONS\.has\([^)]*\.author_association\)/); + + // Paginates over open PRs and filters to drafts. + assert.match(workflow, /github\.paginate\(github\.rest\.pulls\.list/); + assert.match(workflow, /state:\s*'open'/); + assert.match(workflow, /pr\.draft === true/); + + // Same user-facing policy message as close-draft-prs.yml (locks the core + // content so the sweep cannot silently drift to a weaker message). + assert.match(workflow, /## Draft PRs are not accepted/); + assert.match(workflow, /npm run test:coverage/); + assert.match(workflow, /CONTRIBUTING\.md#pull-request-guidelines/); + }); });