Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/protection.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"CI / audit",
"CI / api-integration",
"CodeQL / analyze",
"PR Size / Check PR diff size",
"Vercel Preview / deploy"
]
},
Expand Down
187 changes: 187 additions & 0 deletions .github/workflows/pr-size.yml
Original file line number Diff line number Diff line change
@@ -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
Comment on lines +17 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Require the new size-check status before merge

The repository's branch protection is restored from .github/protection.json and docs/repo-config.md enumerates the required status checks, but this commit only adds the optional PR Size / Check PR diff size job and does not add that context to the protected checks. In the current protected-branch setup, a PR over 1000 lines can still merge once the existing required checks pass, so the advertised hard limit is not actually enforced.

Useful? React with 👍 / 👎.

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 = '<!-- tariffshield-pr-size-check -->';
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}.`);
5 changes: 5 additions & 0 deletions .prsize-ignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package-lock.json
Cargo.lock
*.snap
dist/
target/
8 changes: 8 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions docs/repo-config.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down