Skip to content

Commit a035438

Browse files
committed
CI: add PR size and commit message quality checks
Add pr-hygiene workflow with: - PR size check (warn at 300 lines, fail at 500) - Commit title length check (<= 80 chars) - No fixup/squash/WIP commits check - Commit body required for 20+ line changes All commit checks skip merge commits via --no-merges. Scripts live in .github/scripts/pr-hygiene/. Closes #77
1 parent 37f496c commit a035438

6 files changed

Lines changed: 192 additions & 1 deletion

File tree

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
#!/usr/bin/env bash
2+
# Check that commits with 20+ lines changed include a non-empty body.
3+
# Skips merge commits.
4+
#
5+
# Usage: check-commit-body.sh <base-sha>
6+
set -euo pipefail
7+
8+
base="${1:?Usage: check-commit-body.sh <base-sha>}"
9+
errors=0
10+
11+
for commit in $(git log --no-merges --format=%H "${base}..HEAD"); do
12+
short=$(git log -1 --format=%h "$commit")
13+
body=$(git log -1 --format=%b "$commit")
14+
15+
lines_changed=$(git diff-tree --no-commit-id --numstat -r "$commit" \
16+
| awk '{ s += $1 + $2 } END { print s+0 }')
17+
body_trimmed=$(echo "$body" | sed '/^$/d' | head -1)
18+
19+
if [ "$lines_changed" -ge 20 ] && [ -z "$body_trimmed" ]; then
20+
echo "::error::Commit $short: $lines_changed lines changed but no commit body."
21+
echo " Commits with 20+ lines changed should include a description."
22+
errors=$((errors + 1))
23+
fi
24+
done
25+
26+
if [ "$errors" -gt 0 ]; then
27+
echo "Found $errors commit(s) missing a body."
28+
exit 1
29+
fi
30+
31+
echo "All large commits have a body."
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/usr/bin/env bash
2+
# Check that no fixup/squash/WIP commits remain in the PR.
3+
# Skips merge commits.
4+
#
5+
# Usage: check-no-fixup.sh <base-sha>
6+
set -euo pipefail
7+
8+
base="${1:?Usage: check-no-fixup.sh <base-sha>}"
9+
errors=0
10+
11+
for commit in $(git log --no-merges --format=%H "${base}..HEAD"); do
12+
subject=$(git log -1 --format=%s "$commit")
13+
short=$(git log -1 --format=%h "$commit")
14+
15+
case "$subject" in
16+
fixup!*|squash!*|WIP:*|WIP\ *|wip:*|wip\ *)
17+
echo "::error::Commit $short: fixup/squash/WIP commits must be cleaned up before merge."
18+
echo " Title: $subject"
19+
errors=$((errors + 1))
20+
;;
21+
esac
22+
done
23+
24+
if [ "$errors" -gt 0 ]; then
25+
echo "Found $errors fixup/WIP commit(s)."
26+
exit 1
27+
fi
28+
29+
echo "No fixup/squash/WIP commits found."
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
#!/usr/bin/env bash
2+
# Check that a PR does not exceed a configurable line-change threshold.
3+
#
4+
# Excludes generated files (lock files, etc.) from the count.
5+
# Exits 0 on success, 1 on failure, and prints a warning when the
6+
# soft threshold is exceeded but the hard threshold is not.
7+
#
8+
# Environment variables (optional):
9+
# PR_SIZE_WARN - soft threshold (default: 300)
10+
# PR_SIZE_FAIL - hard threshold (default: 500)
11+
#
12+
# Usage: check-pr-size.sh <base-sha>
13+
set -euo pipefail
14+
15+
base="${1:?Usage: check-pr-size.sh <base-sha>}"
16+
warn_threshold="${PR_SIZE_WARN:-300}"
17+
fail_threshold="${PR_SIZE_FAIL:-500}"
18+
19+
# Patterns for generated/vendored files to exclude from the count.
20+
exclude_patterns=(
21+
"*.lock"
22+
"uv.lock"
23+
"poetry.lock"
24+
"package-lock.json"
25+
"yarn.lock"
26+
"pnpm-lock.yaml"
27+
"Cargo.lock"
28+
)
29+
30+
# Build the pathspec exclusion arguments.
31+
pathspec_args=()
32+
for pattern in "${exclude_patterns[@]}"; do
33+
pathspec_args+=(":(exclude)${pattern}")
34+
done
35+
36+
# Count added + removed lines, excluding generated files.
37+
diff_stat=$(git diff --numstat "${base}...HEAD" -- . "${pathspec_args[@]}")
38+
39+
total=0
40+
while IFS=$'\t' read -r added removed _path; do
41+
# Binary files show "-" for added/removed; skip them.
42+
if [ "$added" = "-" ] || [ "$removed" = "-" ]; then
43+
continue
44+
fi
45+
total=$((total + added + removed))
46+
done <<< "$diff_stat"
47+
48+
echo "Total lines changed (excluding generated files): $total"
49+
50+
if [ "$total" -gt "$fail_threshold" ]; then
51+
echo "::error::PR is too large ($total lines changed, threshold: $fail_threshold)."
52+
echo "Consider splitting into smaller, focused PRs."
53+
exit 1
54+
fi
55+
56+
if [ "$total" -gt "$warn_threshold" ]; then
57+
echo "::warning::PR is getting large ($total lines changed, warn threshold: $warn_threshold)."
58+
echo "Consider whether this can be split into smaller PRs."
59+
fi
60+
61+
echo "PR size check passed."
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#!/usr/bin/env bash
2+
# Check that commit titles do not exceed 80 characters.
3+
# Skips merge commits.
4+
#
5+
# Usage: check-title-length.sh <base-sha>
6+
set -euo pipefail
7+
8+
base="${1:?Usage: check-title-length.sh <base-sha>}"
9+
errors=0
10+
11+
for commit in $(git log --no-merges --format=%H "${base}..HEAD"); do
12+
subject=$(git log -1 --format=%s "$commit")
13+
short=$(git log -1 --format=%h "$commit")
14+
15+
title_len=${#subject}
16+
if [ "$title_len" -gt 80 ]; then
17+
echo "::error::Commit $short: title is $title_len chars (max 80)."
18+
echo " Title: $subject"
19+
errors=$((errors + 1))
20+
fi
21+
done
22+
23+
if [ "$errors" -gt 0 ]; then
24+
echo "Found $errors title length error(s)."
25+
exit 1
26+
fi
27+
28+
echo "All commit titles are within 80 characters."

.github/workflows/pr-hygiene.yaml

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
name: PR hygiene checks
2+
on:
3+
pull_request:
4+
types: [assigned, opened, synchronize, reopened]
5+
branches:
6+
- main
7+
merge_group:
8+
types: [checks_requested]
9+
10+
jobs:
11+
check-pr-size:
12+
name: Check PR size
13+
runs-on: ubuntu-latest
14+
steps:
15+
- uses: actions/checkout@v6
16+
with:
17+
fetch-depth: 0
18+
- name: Verify PR is not too large
19+
run: >
20+
.github/scripts/pr-hygiene/check-pr-size.sh
21+
"${{ github.event.pull_request.base.sha }}"
22+
23+
check-commit-messages:
24+
name: Check commit messages
25+
runs-on: ubuntu-latest
26+
steps:
27+
- uses: actions/checkout@v6
28+
with:
29+
fetch-depth: 0
30+
- name: Check title length
31+
run: >
32+
.github/scripts/pr-hygiene/check-title-length.sh
33+
"${{ github.event.pull_request.base.sha }}"
34+
- name: Check no fixup/WIP commits
35+
run: >
36+
.github/scripts/pr-hygiene/check-no-fixup.sh
37+
"${{ github.event.pull_request.base.sha }}"
38+
- name: Check commit body on large changes
39+
run: >
40+
.github/scripts/pr-hygiene/check-commit-body.sh
41+
"${{ github.event.pull_request.base.sha }}"

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,8 @@ lint-fix: ## Lint and fix code using ruff
5252

5353
.PHONY: lint-shell
5454
lint-shell: ## Lint shell scripts using shellcheck
55-
shellcheck .github/scripts/*.sh
55+
shellcheck .github/scripts/*.sh \
56+
.github/scripts/pr-hygiene/*.sh
5657

5758
.PHONY: typecheck
5859
typecheck: ## Run mypy type checker

0 commit comments

Comments
 (0)