diff --git a/.github/protection.json b/.github/protection.json index 5879d46..3e72b40 100644 --- a/.github/protection.json +++ b/.github/protection.json @@ -8,6 +8,7 @@ "CI / audit", "CI / api-integration", "CodeQL / analyze", + "PR Size / Check PR diff size", "Vercel Preview / deploy" ] }, diff --git a/.github/workflows/pr-size.yml b/.github/workflows/pr-size.yml new file mode 100644 index 0000000..675044a --- /dev/null +++ b/.github/workflows/pr-size.yml @@ -0,0 +1,187 @@ +name: PR Size + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + +permissions: + contents: read + issues: write + pull-requests: write + +env: + PR_SIZE_WARN: 400 + PR_SIZE_FAIL: 1000 + +jobs: + size: + name: Check PR diff size + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Count changed lines + id: count + shell: bash + env: + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + node <<'NODE' + const { execFileSync } = require('node:child_process'); + const fs = require('node:fs'); + + const base = process.env.BASE_SHA; + const head = process.env.HEAD_SHA; + const ignorePath = '.prsize-ignore'; + + let ignoreText = ''; + try { + ignoreText = execFileSync('git', ['show', `${base}:${ignorePath}`], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }); + } catch { + ignoreText = ''; + } + + const patterns = ignoreText + .split(/\r?\n/) + .map((line) => line.trim()) + .filter((line) => line && !line.startsWith('#')); + + function escapeRegex(value) { + return value.replace(/[|\\{}()[\]^$+?.]/g, '\\$&'); + } + + function globToRegex(pattern) { + let anchored = pattern.startsWith('/'); + let directoryOnly = pattern.endsWith('/'); + let body = pattern.replace(/^\/+/, '').replace(/\/+$/, ''); + let regex = ''; + + for (let i = 0; i < body.length; i += 1) { + const char = body[i]; + const next = body[i + 1]; + if (char === '*' && next === '*') { + regex += '.*'; + i += 1; + } else if (char === '*') { + regex += '[^/]*'; + } else if (char === '?') { + regex += '[^/]'; + } else { + regex += escapeRegex(char); + } + } + + if (directoryOnly) { + regex += '(?:/.*)?'; + } + + return new RegExp(anchored ? `^${regex}$` : `(^|/)${regex}$`); + } + + const matchers = patterns.map(globToRegex); + const isIgnored = (file) => matchers.some((matcher) => matcher.test(file)); + + const output = execFileSync('git', ['diff', '--numstat', base, head], { + encoding: 'utf8', + }); + + let total = 0; + const countedFiles = []; + const ignoredFiles = []; + + for (const line of output.trim().split(/\r?\n/).filter(Boolean)) { + const [added, deleted, file] = line.split(/\t/); + if (!file) { + continue; + } + if (isIgnored(file)) { + ignoredFiles.push(file); + continue; + } + const addedLines = Number.parseInt(added, 10); + const deletedLines = Number.parseInt(deleted, 10); + if (Number.isNaN(addedLines) || Number.isNaN(deletedLines)) { + continue; + } + total += addedLines + deletedLines; + countedFiles.push(file); + } + + fs.appendFileSync(process.env.GITHUB_OUTPUT, `lines=${total}\n`); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `counted_files=${countedFiles.length}\n`); + fs.appendFileSync(process.env.GITHUB_OUTPUT, `ignored_files=${ignoredFiles.length}\n`); + console.log(`PR size: ${total} changed lines`); + console.log(`Counted files: ${countedFiles.length}`); + console.log(`Ignored files: ${ignoredFiles.length}`); + NODE + + - name: Enforce PR size policy + uses: actions/github-script@v7 + env: + LINE_COUNT: ${{ steps.count.outputs.lines }} + COUNTED_FILES: ${{ steps.count.outputs.counted_files }} + IGNORED_FILES: ${{ steps.count.outputs.ignored_files }} + with: + script: | + const marker = ''; + const warnAt = Number(process.env.PR_SIZE_WARN || 400); + const failAt = Number(process.env.PR_SIZE_FAIL || 1000); + const lines = Number(process.env.LINE_COUNT || 0); + const countedFiles = Number(process.env.COUNTED_FILES || 0); + const ignoredFiles = Number(process.env.IGNORED_FILES || 0); + const { owner, repo } = context.repo; + const pull_number = context.payload.pull_request.number; + const labels = context.payload.pull_request.labels.map((label) => label.name); + const bypassed = labels.includes('large-pr-approved'); + + async function upsertComment(body) { + const comments = await github.rest.issues.listComments({ + owner, + repo, + issue_number: pull_number, + per_page: 100, + }); + const previous = comments.data.find((comment) => comment.body?.includes(marker)); + if (previous) { + await github.rest.issues.updateComment({ + owner, + repo, + comment_id: previous.id, + body, + }); + return; + } + await github.rest.issues.createComment({ + owner, + repo, + issue_number: pull_number, + body, + }); + } + + if (bypassed) { + core.notice(`PR size check bypassed by large-pr-approved label (${lines} changed lines).`); + return; + } + + if (lines >= failAt) { + await upsertComment(`${marker}\nThis PR is very large (${lines} lines). The hard limit is ${failAt} changed lines. Please split this into smaller PRs or ask a maintainer to apply the \`large-pr-approved\` label for an explicit bypass.\n\nCounted files: ${countedFiles}. Ignored generated files: ${ignoredFiles}.`); + core.setFailed(`PR has ${lines} changed lines, which meets or exceeds the ${failAt}-line hard limit.`); + return; + } + + if (lines >= warnAt) { + await upsertComment(`${marker}\nThis PR is large (${lines} lines). Consider splitting it into smaller PRs for easier review.\n\nCounted files: ${countedFiles}. Ignored generated files: ${ignoredFiles}.`); + core.warning(`PR has ${lines} changed lines, which meets or exceeds the ${warnAt}-line warning threshold.`); + return; + } + + core.notice(`PR size is ${lines} changed lines; below warning threshold ${warnAt}.`); diff --git a/.prsize-ignore b/.prsize-ignore new file mode 100644 index 0000000..482b6e3 --- /dev/null +++ b/.prsize-ignore @@ -0,0 +1,5 @@ +package-lock.json +Cargo.lock +*.snap +dist/ +target/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 192842c..479ece2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -35,6 +35,14 @@ All PRs must target the `main` branch. When you open a PR, GitHub will pre-popul - The CI suite (type-check, lint, contract tests, audit) must pass. - Keep PRs focused — one feature or fix per PR. Large refactors should be discussed in an issue first. +### Pull request size + +The PR size check counts added plus deleted lines against the pull request base. PRs at 400 changed lines or more receive an automated warning comment, and PRs at 1000 changed lines or more fail the check so they can be split before review. + +Generated files are excluded through [`.prsize-ignore`](.prsize-ignore), including lockfiles, snapshots, `dist/`, and `target/`. If an emergency or mechanical change must exceed the hard limit, a maintainer can apply the `large-pr-approved` label to bypass the check explicitly. + +When a change is likely to be large, split it by reviewable behavior: schema first, API follow-up second, UI last; or land preparatory refactors before the feature branch. + --- ## Conventional Commits diff --git a/docs/repo-config.md b/docs/repo-config.md index 6853c08..9b9903e 100644 --- a/docs/repo-config.md +++ b/docs/repo-config.md @@ -29,6 +29,7 @@ These workflow jobs must pass before a PR can merge: | `CI / audit` | `.github/workflows/ci.yml` | `audit` | | `CI / api-integration` | `.github/workflows/ci.yml` | `api-integration` | | `CodeQL / analyze` | `.github/workflows/codeql.yml` | `analyze` | +| `PR Size / Check PR diff size` | `.github/workflows/pr-size.yml` | `Check PR diff size` | | `Vercel Preview / deploy` | `.github/workflows/preview-deploy.yml` | `deploy` | ## Restoring branch protection via `gh` CLI