Skip to content

Commit 133267b

Browse files
committed
CI: add PR size and commit message quality checks
Add pr-hygiene workflow with two checks: - PR size: warns at 300 lines, fails at 500 (configurable via PR_SIZE_WARN/PR_SIZE_FAIL env vars), excludes lock files - Commit messages: enforces title <= 80 chars, rejects fixup/WIP commits, requires body for commits with 20+ lines changed Update Makefile lint-shell target to include new scripts. Closes #77
1 parent 37f496c commit 133267b

4 files changed

Lines changed: 152 additions & 1 deletion

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
#!/usr/bin/env bash
2+
# Validate commit message quality for all commits in a PR.
3+
#
4+
# Checks:
5+
# 1. Title (first line) must be <= 80 characters
6+
# 2. No fixup!/squash!/WIP commits in the final PR
7+
# 3. Commits with 20+ lines changed must have a non-empty body
8+
#
9+
# Usage: check-commit-messages.sh <base-sha>
10+
set -euo pipefail
11+
12+
base="${1:?Usage: check-commit-messages.sh <base-sha>}"
13+
errors=0
14+
15+
for commit in $(git log --format=%H "${base}..HEAD"); do
16+
# Get the full commit message.
17+
subject=$(git log -1 --format=%s "$commit")
18+
body=$(git log -1 --format=%b "$commit")
19+
short=$(git log -1 --format=%h "$commit")
20+
21+
# --- Check 1: Title length ---
22+
title_len=${#subject}
23+
if [ "$title_len" -gt 80 ]; then
24+
echo "::error::Commit $short: title is $title_len chars (max 80)."
25+
echo " Title: $subject"
26+
errors=$((errors + 1))
27+
fi
28+
29+
# --- Check 2: No fixup/squash/WIP commits ---
30+
case "$subject" in
31+
fixup!*|squash!*|WIP:*|WIP\ *|wip:*|wip\ *)
32+
echo "::error::Commit $short: fixup/squash/WIP commits must be cleaned up before merge."
33+
echo " Title: $subject"
34+
errors=$((errors + 1))
35+
;;
36+
esac
37+
38+
# --- Check 3: Large commits need a body ---
39+
lines_changed=$(git diff-tree --no-commit-id --numstat -r "$commit" \
40+
| awk '{ s += $1 + $2 } END { print s+0 }')
41+
body_trimmed=$(echo "$body" | sed '/^$/d' | head -1)
42+
if [ "$lines_changed" -ge 20 ] && [ -z "$body_trimmed" ]; then
43+
echo "::error::Commit $short: $lines_changed lines changed but no commit body."
44+
echo " Commits with 20+ lines changed should include a description."
45+
errors=$((errors + 1))
46+
fi
47+
done
48+
49+
# --- Summary ---
50+
51+
if [ "$errors" -gt 0 ]; then
52+
echo "Found $errors commit message error(s)."
53+
exit 1
54+
fi
55+
56+
echo "All commit message checks passed."
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."

.github/workflows/pr-hygiene.yaml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
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: Verify commit message quality
31+
run: >
32+
.github/scripts/pr-hygiene/check-commit-messages.sh
33+
"${{ 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)