Skip to content

Commit d3607c6

Browse files
committed
feat(scripts): add PR pre-merge review helper script
Add a comprehensive local PR review tool that syncs base and head branches from origin, runs quality checks (shellcheck on shell scripts, yamllint on workflow files), optionally runs full quality gates, and opens VS Code diff tabs for all changed files to streamline the pre-merge review process.
1 parent 1572b70 commit d3607c6

1 file changed

Lines changed: 344 additions & 0 deletions

File tree

Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
#!/usr/bin/env bash
2+
# ============================================================
3+
# ACFS Local - PR Pre-Merge Review Helper
4+
#
5+
# Syncs base/head branches, runs pre-merge checks, and opens VS
6+
# Code diffs for all changed files so local review is faster.
7+
# ============================================================
8+
9+
set -euo pipefail
10+
11+
usage() {
12+
cat <<'EOF'
13+
Usage:
14+
./scripts/local/pr_premerge_review.sh [options]
15+
16+
Options:
17+
--base <branch> Base branch to merge into
18+
(default: local-desktop-installation-support)
19+
--head <branch> Head branch to review
20+
(default: current branch)
21+
--max-diffs <n> Max files to open in VS Code diff tabs (default: 200)
22+
--no-open-diff Skip opening VS Code diffs
23+
--full Run full quality gates (shellcheck + apps/web checks)
24+
--allow-dirty Allow running with uncommitted local changes
25+
-h, --help Show this help
26+
27+
Examples:
28+
./scripts/local/pr_premerge_review.sh
29+
./scripts/local/pr_premerge_review.sh --base local-desktop-installation-support --head feature/my-pr
30+
./scripts/local/pr_premerge_review.sh --full --max-diffs 40
31+
EOF
32+
}
33+
34+
log() {
35+
printf '[pre-merge] %s\n' "$*"
36+
}
37+
38+
warn() {
39+
printf '[pre-merge][warn] %s\n' "$*" >&2
40+
}
41+
42+
die() {
43+
printf '[pre-merge][error] %s\n' "$*" >&2
44+
exit 1
45+
}
46+
47+
require_command() {
48+
local cmd="$1"
49+
command -v "$cmd" >/dev/null 2>&1 || die "Missing required command: $cmd"
50+
}
51+
52+
BASE_BRANCH="local-desktop-installation-support"
53+
HEAD_BRANCH=""
54+
MAX_DIFFS=200
55+
OPEN_DIFF="true"
56+
RUN_FULL="false"
57+
ALLOW_DIRTY="false"
58+
YAMLLINT_BIN=""
59+
60+
while [[ $# -gt 0 ]]; do
61+
case "$1" in
62+
--base)
63+
[[ $# -ge 2 ]] || die "--base requires a value"
64+
BASE_BRANCH="$2"
65+
shift 2
66+
;;
67+
--head)
68+
[[ $# -ge 2 ]] || die "--head requires a value"
69+
HEAD_BRANCH="$2"
70+
shift 2
71+
;;
72+
--max-diffs)
73+
[[ $# -ge 2 ]] || die "--max-diffs requires a value"
74+
MAX_DIFFS="$2"
75+
shift 2
76+
;;
77+
--no-open-diff)
78+
OPEN_DIFF="false"
79+
shift
80+
;;
81+
--full)
82+
RUN_FULL="true"
83+
shift
84+
;;
85+
--allow-dirty)
86+
ALLOW_DIRTY="true"
87+
shift
88+
;;
89+
-h|--help)
90+
usage
91+
exit 0
92+
;;
93+
*)
94+
die "Unknown argument: $1"
95+
;;
96+
esac
97+
done
98+
99+
require_command git
100+
101+
REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || true)"
102+
[[ -n "$REPO_ROOT" ]] || die "Run this script inside a git repository."
103+
cd "$REPO_ROOT"
104+
105+
if [[ -z "$HEAD_BRANCH" ]]; then
106+
HEAD_BRANCH="$(git branch --show-current)"
107+
fi
108+
[[ -n "$HEAD_BRANCH" ]] || die "Could not determine head branch. Pass --head <branch>."
109+
[[ "$MAX_DIFFS" =~ ^[0-9]+$ ]] || die "--max-diffs must be a non-negative integer."
110+
111+
if [[ "$ALLOW_DIRTY" != "true" ]]; then
112+
if ! git diff --quiet || ! git diff --cached --quiet; then
113+
die "Working tree is not clean. Commit/stash first or rerun with --allow-dirty."
114+
fi
115+
fi
116+
117+
if ! git ls-remote --exit-code --heads origin "$BASE_BRANCH" >/dev/null 2>&1; then
118+
die "Branch origin/$BASE_BRANCH does not exist."
119+
fi
120+
121+
if ! git ls-remote --exit-code --heads origin "$HEAD_BRANCH" >/dev/null 2>&1; then
122+
die "Branch origin/$HEAD_BRANCH does not exist."
123+
fi
124+
125+
ensure_local_branch() {
126+
local branch="$1"
127+
if git show-ref --verify --quiet "refs/heads/$branch"; then
128+
git switch "$branch" >/dev/null
129+
else
130+
git switch -c "$branch" --track "origin/$branch" >/dev/null
131+
fi
132+
git pull --ff-only origin "$branch"
133+
}
134+
135+
ensure_yamllint() {
136+
if command -v yamllint >/dev/null 2>&1; then
137+
YAMLLINT_BIN="yamllint"
138+
return 0
139+
fi
140+
141+
if ! command -v python3 >/dev/null 2>&1; then
142+
return 1
143+
fi
144+
145+
local venv_dir="$REPO_ROOT/.tmp-yamllint-venv"
146+
if [[ ! -d "$venv_dir" ]]; then
147+
log "Creating temporary yamllint virtualenv: $venv_dir"
148+
python3 -m venv "$venv_dir"
149+
fi
150+
151+
# shellcheck disable=SC1091
152+
source "$venv_dir/bin/activate"
153+
pip install -q yamllint
154+
YAMLLINT_BIN="$venv_dir/bin/yamllint"
155+
}
156+
157+
run_shellcheck_on_changed_shell_files() {
158+
local changed_files=("$@")
159+
local shell_files=()
160+
local file=""
161+
162+
for file in "${changed_files[@]}"; do
163+
[[ "$file" == *.sh ]] || continue
164+
[[ -f "$file" ]] || continue
165+
shell_files+=("$file")
166+
done
167+
168+
if [[ ${#shell_files[@]} -eq 0 ]]; then
169+
log "No changed shell scripts to lint."
170+
return
171+
fi
172+
173+
if ! command -v shellcheck >/dev/null 2>&1; then
174+
warn "shellcheck not found. Skipping changed-shell lint."
175+
return
176+
fi
177+
178+
log "Running shellcheck on changed shell scripts..."
179+
shellcheck -x "${shell_files[@]}"
180+
}
181+
182+
run_yamllint_on_changed_workflows() {
183+
local changed_files=("$@")
184+
local workflow_files=()
185+
local file=""
186+
187+
for file in "${changed_files[@]}"; do
188+
[[ "$file" == .github/workflows/*.yml ]] || continue
189+
[[ -f "$file" ]] || continue
190+
workflow_files+=("$file")
191+
done
192+
193+
if [[ ${#workflow_files[@]} -eq 0 ]]; then
194+
log "No changed workflow YAML files to lint."
195+
return
196+
fi
197+
198+
if ! ensure_yamllint; then
199+
warn "Could not set up yamllint. Skipping workflow YAML lint."
200+
return
201+
fi
202+
203+
log "Running yamllint on changed workflow YAML files..."
204+
"$YAMLLINT_BIN" -c /dev/stdin "${workflow_files[@]}" <<'EOF'
205+
extends: default
206+
rules:
207+
line-length:
208+
max: 200
209+
key-ordering: disable
210+
comments:
211+
min-spaces-from-content: 1
212+
document-start: disable
213+
document-end: disable
214+
indentation:
215+
spaces: 2
216+
indent-sequences: whatever
217+
truthy:
218+
allowed-values: ['true', 'false', 'on', 'off']
219+
EOF
220+
}
221+
222+
run_full_quality_gates() {
223+
if [[ "$RUN_FULL" != "true" ]]; then
224+
return
225+
fi
226+
227+
if command -v shellcheck >/dev/null 2>&1; then
228+
log "Running full shellcheck gate..."
229+
shopt -s globstar nullglob
230+
# shellcheck disable=SC2207
231+
local files=(install.sh scripts/**/*.sh)
232+
shellcheck "${files[@]}"
233+
shopt -u globstar nullglob
234+
else
235+
warn "shellcheck not found. Skipping full shellcheck gate."
236+
fi
237+
238+
if command -v bun >/dev/null 2>&1; then
239+
log "Running apps/web gates: type-check, lint, build..."
240+
(
241+
cd apps/web
242+
bun run type-check
243+
bun run lint
244+
bun run build
245+
)
246+
else
247+
warn "bun not found. Skipping apps/web gates."
248+
fi
249+
}
250+
251+
open_vscode_diffs() {
252+
local changed_files=("$@")
253+
254+
if [[ "$OPEN_DIFF" != "true" ]]; then
255+
return
256+
fi
257+
258+
if ! command -v code >/dev/null 2>&1; then
259+
warn "VS Code CLI 'code' not found. Skipping automatic diff open."
260+
return
261+
fi
262+
263+
local safe_base="${BASE_BRANCH//\//_}"
264+
local safe_head="${HEAD_BRANCH//\//_}"
265+
local diff_root
266+
diff_root="$(mktemp -d "${TMPDIR:-/tmp}/acfs-pr-review.${safe_base}-vs-${safe_head}.XXXXXX")"
267+
local file=""
268+
local opened=0
269+
270+
log "Preparing VS Code diff files in: $diff_root"
271+
for file in "${changed_files[@]}"; do
272+
if [[ "$opened" -ge "$MAX_DIFFS" ]]; then
273+
warn "Reached --max-diffs limit ($MAX_DIFFS)."
274+
break
275+
fi
276+
277+
local left_path="$diff_root/$safe_base/$file"
278+
local right_path="$diff_root/$safe_head/$file"
279+
mkdir -p "$(dirname "$left_path")" "$(dirname "$right_path")"
280+
281+
if git cat-file -e "${BASE_BRANCH}:${file}" 2>/dev/null; then
282+
git show "${BASE_BRANCH}:${file}" > "$left_path"
283+
else
284+
: > "$left_path"
285+
fi
286+
287+
if git cat-file -e "${HEAD_BRANCH}:${file}" 2>/dev/null; then
288+
git show "${HEAD_BRANCH}:${file}" > "$right_path"
289+
else
290+
: > "$right_path"
291+
fi
292+
293+
if ! code --reuse-window --diff "$left_path" "$right_path" >/dev/null 2>&1; then
294+
warn "Failed to open VS Code diff for: $file"
295+
fi
296+
297+
opened=$((opened + 1))
298+
done
299+
300+
log "Opened $opened VS Code diff tabs."
301+
log "Diff orientation: left=$BASE_BRANCH right=$HEAD_BRANCH"
302+
log "Temporary diff files are in: $diff_root"
303+
}
304+
305+
log "Fetching latest refs from origin..."
306+
git fetch origin
307+
308+
log "Syncing base branch: $BASE_BRANCH"
309+
ensure_local_branch "$BASE_BRANCH"
310+
311+
log "Syncing head branch: $HEAD_BRANCH"
312+
ensure_local_branch "$HEAD_BRANCH"
313+
314+
log "Reviewing commits in $HEAD_BRANCH not in $BASE_BRANCH:"
315+
git log --oneline --decorate "$BASE_BRANCH..$HEAD_BRANCH" || true
316+
317+
mapfile -d '' -t CHANGED_FILES < <(git diff --name-only -z "$BASE_BRANCH...$HEAD_BRANCH")
318+
if [[ ${#CHANGED_FILES[@]} -eq 0 ]]; then
319+
log "No file differences found between $BASE_BRANCH and $HEAD_BRANCH."
320+
exit 0
321+
fi
322+
323+
log "Changed files (${#CHANGED_FILES[@]}):"
324+
for file in "${CHANGED_FILES[@]}"; do
325+
printf ' - %s\n' "$file"
326+
done
327+
328+
log "Running whitespace sanity check..."
329+
git diff --check "$BASE_BRANCH...$HEAD_BRANCH"
330+
331+
run_shellcheck_on_changed_shell_files "${CHANGED_FILES[@]}"
332+
run_yamllint_on_changed_workflows "${CHANGED_FILES[@]}"
333+
run_full_quality_gates
334+
open_vscode_diffs "${CHANGED_FILES[@]}"
335+
336+
cat <<EOF
337+
338+
Pre-merge checklist complete.
339+
340+
Next merge steps:
341+
git switch $BASE_BRANCH
342+
git merge --no-ff $HEAD_BRANCH
343+
git push origin $BASE_BRANCH
344+
EOF

0 commit comments

Comments
 (0)