From e58479739aa2e37659c0ae2df0be28dcba304d29 Mon Sep 17 00:00:00 2001 From: Richard Carillo Date: Thu, 22 Oct 2026 09:42:33 -0400 Subject: [PATCH] feat(ci): enforce PR template --- .github/workflows/pr_template_check.yml | 136 ++++++++++++++++++++++++ CONTRIBUTING.md | 10 +- 2 files changed, 142 insertions(+), 4 deletions(-) create mode 100644 .github/workflows/pr_template_check.yml diff --git a/.github/workflows/pr_template_check.yml b/.github/workflows/pr_template_check.yml new file mode 100644 index 000000000..0a79d5718 --- /dev/null +++ b/.github/workflows/pr_template_check.yml @@ -0,0 +1,136 @@ +name: PR template check + +# This check flags external pull requests that do not use the pull request template, and +# closes them if the template is still missing after a grace period. +# +# pull_request_target is required so the token can label, comment on, and close +# pull requests opened from forks. This is safe only because the workflow never +# checks out or runs code from the pull request; it reads the PR body via the +# event payload. Do not add an actions/checkout step to this workflow. +on: # zizmor: ignore[dangerous-triggers] PR code is never checked out or executed + pull_request_target: + types: + - opened + - edited + - reopened + - ready_for_review + - converted_to_draft + schedule: + - cron: "0 18 * * *" # 1 PM EST + workflow_dispatch: + +permissions: {} + +env: + LABEL: needs-template + GRACE_DAYS: "3" + MARKER: "" + +jobs: + check: + if: >- + github.event_name == 'pull_request_target' && + github.event.pull_request.user.type != 'Bot' && + !contains(fromJSON('["OWNER", "MEMBER"]'), github.event.pull_request.author_association) + runs-on: ubuntu-latest + permissions: + issues: write # Labels and comments on PRs use the issues API. + pull-requests: write + concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + steps: + - name: Check PR body against template + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { LABEL, GRACE_DAYS, MARKER } = process.env; + const pr = context.payload.pull_request; + const issue = { ...context.repo, issue_number: pr.number }; + + // Remove HTML comments so untouched template placeholders don't count as content. + const body = (pr.body || '').replace(//g, ''); + + // Only the parts above the template's "You can erase any parts of this template" + // note are required. These must match .github/PULL_REQUEST_TEMPLATE.md. + const problems = []; + + // The change summary runs until the "All Submissions:" checklist or the next heading. + const summary = body.match(/^###\s+Change summary\s*$([\s\S]*?)(?=^\s*All Submissions:|^###|(?![\s\S]))/mi); + if (!summary) { + problems.push('the `### Change summary` section is missing'); + } else if (summary[1].trim() === '') { + problems.push('the `### Change summary` section is empty'); + } + + if (!/^\s*All Submissions:\s*$/mi.test(body)) { + problems.push('the `All Submissions:` checklist is missing'); + } + + const { data: comments } = await github.rest.issues.listComments({ ...issue, per_page: 100 }); + const existing = comments.find((c) => c.user.type === 'Bot' && c.body.includes(MARKER)); + + // Drafts are not checked. Clearing the label means the grace period starts + // fresh when the pull request is marked ready for review. + if (problems.length === 0 || pr.draft) { + await github.rest.issues.removeLabel({ ...issue, name: LABEL }).catch((e) => { + if (e.status !== 404) throw e; + }); + if (existing) { + await github.rest.issues.deleteComment({ ...context.repo, comment_id: existing.id }); + } + return; + } + + const templateUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/blob/${pr.base.ref}/.github/PULL_REQUEST_TEMPLATE.md`; + const message = [ + MARKER, + `Thanks for the contribution! This pull request doesn't follow our [pull request template](${templateUrl}):`, + '', + ...problems.map((p) => `- ${p}`), + '', + `Please edit the description to use the template. If it is still missing after ${GRACE_DAYS} days, this pull request will be closed automatically.`, + ].join('\n'); + + await github.rest.issues.addLabels({ ...issue, labels: [LABEL] }); + if (existing) { + await github.rest.issues.updateComment({ ...context.repo, comment_id: existing.id, body: message }); + } else { + await github.rest.issues.createComment({ ...issue, body: message }); + } + + close: + if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' + runs-on: ubuntu-latest + permissions: + issues: write # Comments on PRs use the issues API. + pull-requests: write + steps: + - name: Close PRs still missing the template + uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 + with: + script: | + const { LABEL, GRACE_DAYS } = process.env; + const cutoff = Date.now() - Number(GRACE_DAYS) * 24 * 60 * 60 * 1000; + + const prs = await github.paginate(github.rest.pulls.list, { + ...context.repo, + state: 'open', + per_page: 100, + }); + + for (const pr of prs.filter((p) => !p.draft && p.labels.some((l) => l.name === LABEL))) { + const issue = { ...context.repo, issue_number: pr.number }; + const events = await github.paginate(github.rest.issues.listEvents, { ...issue, per_page: 100 }); + const labeled = events.filter((e) => e.event === 'labeled' && e.label?.name === LABEL).pop(); + if (!labeled || new Date(labeled.created_at).getTime() > cutoff) { + continue; + } + + core.info(`Closing #${pr.number}: labeled ${LABEL} at ${labeled.created_at}`); + await github.rest.issues.createComment({ + ...issue, + body: `Closing because the pull request template is still missing after ${GRACE_DAYS} days. Feel free to open a new pull request that uses the template.`, + }); + await github.rest.pulls.update({ ...context.repo, pull_number: pr.number, state: 'closed' }); + } diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9c7b4d38f..0a87d43a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,8 +1,9 @@ # Contributing -We're happy to receive feature requests and PRs. If your change is nontrivial, -please open an [issue](https://github.com/fastly/cli/issues/new) to discuss the -idea and implementation strategy before submitting a PR. +We're happy to receive feature requests and PRs that address real problems +users run into while using the CLI. If your change is nontrivial, please open an +[issue](https://github.com/fastly/cli/issues/new) to discuss the idea and +implementation strategy before submitting a PR. 1. Fork the repository. 2. Create an `upstream` remote. @@ -15,5 +16,6 @@ idea and implementation strategy before submitting a PR. 1. You may need to install [golangci-lint](https://golangci-lint.run/welcome/install/) if you don't have it installed 6. Add your changes to `CHANGELOG.md` in [Commitizen](https://commitizen-tools.github.io/commitizen/) style message 7. Open a pull request against `upstream main`. - 1. Once you have marked your PR as `Ready for Review` please do not force push to the branch + 1. Fill out the [pull request template](./.github/PULL_REQUEST_TEMPLATE.md). PRs that are ready for review but do not follow the template are labeled `needs-template` and closed automatically after 3 days. + 2. Once you have marked your PR as `Ready for Review` please do not force push to the branch 8. Celebrate :tada:!