Skip to content
Merged
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
29 changes: 29 additions & 0 deletions .claude/skills/dependabot-merge/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
---
name: dependabot-merge
description: Merge this repo's open Dependabot PRs deterministically — classify them, land non-lockfile PRs first, then lockfile PRs one at a time (rebase, fresh `web` pass on the new head, merge), then verify main with the local CI mirror. Use when asked to merge, land, process, or clear Dependabot PRs, dependency bumps, or dependency PRs.
---

# Dependabot merge

`docs/dependabot.md` is the source of truth; read it if anything below is unclear. This skill drives the routine path with `dependabot-merge.sh` (next to this file) and leaves every judgment call to you and the user.

## Rules

- Run the script and every `gh` call **outside the sandbox** (authenticated `gh` doesn't work inside it).
- Never `gh pr merge --admin` or otherwise bypass branch protection on a PR that touches `pnpm-lock.yaml`. Never force-push.
- Never comment `@dependabot rebase` on a failure mode 1 (broken lockfile) or failure mode 3 (package.json without lockfile) PR. It comes back in the same shape.

## Procedure

1. **Status** (read-only): `.claude/skills/dependabot-merge/dependabot-merge.sh status`. Show the user the table. Classes:
- `NONLOCK`: no lockfile change (Actions/workflow bumps). These merge first.
- `LOCK`: touches `pnpm-lock.yaml`. These merge strictly one at a time.
- `FM3`: changes `package.json` but not the lockfile (a security PR scoped to `/apps/web`). The script skips it. Find the green grouped PR carrying the same version and merge that instead. Dependabot then closes the FM3 PR on its own.
- `RED`: the `web` check failed on the current head. The script skips it. Read the failing log and report. A major bump hitting a held-major reason (eslint 10, TypeScript 7, react-table 9) means the `ignore:` list needs a look, not a fix.
- `MAJOR=yes`: skipped unless `--include-majors`. Review the changelog with the user first.
2. **Dry run**: `... merge --dry-run`. Confirm the order and skips with the user.
3. **Merge**: `... merge` (add `--include-majors` only if the user approved specific majors). This polls for minutes per PR, so run it in the background and wait for it to exit. It stops non-zero on a red check, `UNSTABLE`, too many rebases, or a timeout. Report the stop reason; don't loop on it.
- A timeout with the head SHA unchanged usually means Dependabot refused to rebase. Check the PR's comments for a lockfile parse error, which is failure mode 1.
4. **Verify**: `... verify` runs the local CI mirror on a temp worktree of `origin/main`. If it reports `ERR_PNPM_BROKEN_LOCKFILE`, follow failure mode 1 in the doc by hand (rebuild the lockfile on a fix branch, open a superseding PR, close the stuck ones).

Tunables (env): `POLL_SECS` (30), `PR_TIMEOUT_SECS` (1200), `MAX_REBASES` (3), `REPO`.
188 changes: 188 additions & 0 deletions .claude/skills/dependabot-merge/dependabot-merge.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
#!/usr/bin/env bash
# Deterministic Dependabot merge loop for this repo. Encodes the routine path of
# docs/dependabot.md; everything off that path stops and reports instead of guessing.
#
# dependabot-merge.sh [status] read-only table of open Dependabot PRs
# dependabot-merge.sh merge [--dry-run] [--include-majors]
# dependabot-merge.sh verify local CI mirror on a temp worktree of origin/main
#
# Needs authenticated `gh` and `jq` — run outside the sandbox.
set -euo pipefail

REPO="${REPO:-alchemydc/launchcontrol}"
POLL_SECS="${POLL_SECS:-30}"
PR_TIMEOUT_SECS="${PR_TIMEOUT_SECS:-1200}"
MAX_REBASES="${MAX_REBASES:-3}"

log() { printf '%s %s\n' "$(date +%H:%M:%S)" "$*" >&2; }
die() { log "STOP: $*"; exit 1; }

# Open Dependabot PR numbers, ascending.
list_prs() {
gh pr list --repo "$REPO" --state open --author app/dependabot --limit 100 \
--json number --jq '.[].number' | sort -n
}

pr_json() {
gh pr view "$1" --repo "$REPO" \
--json number,title,body,state,isDraft,headRefOid,mergeStateStatus,files
}

# "<status>/<conclusion>" of the `web` check run on a specific commit, or "none".
web_check() {
local out
out=$(gh api "repos/$REPO/commits/$1/check-runs?check_name=web" \
--jq '.check_runs | sort_by(.started_at) | last | if . == null then "none" else "\(.status)/\(.conclusion // "-")" end')
echo "${out:-none}"
}

# yes if any "from A to B" in title/body crosses a leading version number.
is_major() {
jq -r '[(.title + "\n" + (.body // "")) | scan("from v?([0-9]+)\\.[^ ]* to v?([0-9]+)\\.")
| select(.[0] != .[1])] | if length > 0 then "yes" else "no" end' <<<"$1"
}

# NONLOCK | LOCK | FM3 | RED
classify() {
local json=$1 web=$2 lock pkg
lock=$(jq '[.files[].path] | index("pnpm-lock.yaml") != null' <<<"$json")
pkg=$(jq '[.files[].path] | any(endswith("package.json"))' <<<"$json")
# FM3 first: those PRs are always red, and the remedy differs from a real failure.
if [[ $pkg == true && $lock == false ]]; then
echo FM3
elif [[ $web == completed/* && $web != completed/success && $web != completed/skipped ]]; then
echo RED
elif [[ $lock == true ]]; then
echo LOCK
else
echo NONLOCK
fi
}

cmd_status() {
local n json sha web
printf '%-5s %-8s %-9s %-22s %-5s %s\n' PR CLASS STATE WEB MAJOR TITLE
for n in $(list_prs); do
json=$(pr_json "$n")
sha=$(jq -r .headRefOid <<<"$json")
web=$(web_check "$sha")
printf '%-5s %-8s %-9s %-22s %-5s %s\n' "$n" "$(classify "$json" "$web")" \
"$(jq -r .mergeStateStatus <<<"$json")" "$web" "$(is_major "$json")" "$(jq -r .title <<<"$json")"
done
}

# Rebase until current, wait for a fresh `web` pass on the new head, merge.
merge_one() {
local n=$1 deadline rebases=0 requested_for="" json state mss sha web
deadline=$(( $(date +%s) + PR_TIMEOUT_SECS ))
log "#$n: start"
while :; do
(( $(date +%s) < deadline )) || die "#$n: timed out after ${PR_TIMEOUT_SECS}s (rebase refused? see docs/dependabot.md failure mode 1)"
json=$(pr_json "$n")
state=$(jq -r .state <<<"$json")
mss=$(jq -r .mergeStateStatus <<<"$json")
sha=$(jq -r .headRefOid <<<"$json")
case $state in
MERGED) log "#$n: merged"; return 0 ;;
CLOSED) log "#$n: closed by someone else, skipping"; return 0 ;;
esac
web=$(web_check "$sha")
case $web in
completed/success)
case $mss in
CLEAN)
log "#$n: CLEAN + web success on ${sha:0:7}, merging"
# A race (another PR landed) makes this fail; the next poll sees BEHIND.
gh pr merge "$n" --repo "$REPO" --merge || log "#$n: merge refused, re-polling" ;;
BEHIND|DIRTY)
if [[ $requested_for != "$sha" ]]; then
(( rebases < MAX_REBASES )) || die "#$n: still $mss after $MAX_REBASES rebases"
rebases=$((rebases + 1))
log "#$n: $mss, requesting rebase $rebases/$MAX_REBASES"
gh pr comment "$n" --repo "$REPO" --body "@dependabot rebase" >/dev/null
requested_for=$sha
fi ;;
UNSTABLE) die "#$n: web passed but another check failed (UNSTABLE)" ;;
*) log "#$n: $mss, waiting" ;;
esac ;;
completed/*) die "#$n: web check $web on ${sha:0:7} — fix or skip by hand" ;;
*)
# Pending or absent. A BEHIND PR with no run on its head still needs the rebase.
if [[ $mss == BEHIND || $mss == DIRTY ]] && [[ $web == none && $requested_for != "$sha" ]]; then
(( rebases < MAX_REBASES )) || die "#$n: still $mss after $MAX_REBASES rebases"
rebases=$((rebases + 1))
log "#$n: $mss with no web run, requesting rebase $rebases/$MAX_REBASES"
gh pr comment "$n" --repo "$REPO" --body "@dependabot rebase" >/dev/null
requested_for=$sha
else
log "#$n: $mss, web $web on ${sha:0:7}, waiting"
fi ;;
esac
sleep "$POLL_SECS"
done
}

cmd_merge() {
local dry=false majors=false n json web class major
local -a nonlock=() lock=()
for arg in "$@"; do
case $arg in
--dry-run) dry=true ;;
--include-majors) majors=true ;;
*) die "unknown merge flag: $arg" ;;
esac
done
for n in $(list_prs); do
json=$(pr_json "$n")
web=$(web_check "$(jq -r .headRefOid <<<"$json")")
class=$(classify "$json" "$web")
major=$(is_major "$json")
if [[ $(jq -r .isDraft <<<"$json") == true ]]; then log "#$n: skip (draft)"; continue; fi
case $class in
FM3) log "#$n: skip (FM3: package.json without lockfile — merge the grouped PR with the same version)"; continue ;;
RED) log "#$n: skip (RED: web $web — needs a human)"; continue ;;
esac
if [[ $major == yes && $majors == false ]]; then log "#$n: skip (major bump — review, then rerun with --include-majors)"; continue; fi
if [[ $class == NONLOCK ]]; then nonlock+=("$n"); else lock+=("$n"); fi
done
log "order: NONLOCK [${nonlock[*]:-}] then LOCK [${lock[*]:-}]"
$dry && { log "dry run, nothing changed"; return 0; }
for n in "${nonlock[@]}" "${lock[@]}"; do
merge_one "$n"
done
log "done; run '$0 verify' to check main"
}

cmd_verify() {
local root wt logf rc=0
root=$(git rev-parse --show-toplevel)
wt=$(mktemp -d "${TMPDIR:-/tmp}/dependabot-verify.XXXXXX")
logf="$wt.log"
# shellcheck disable=SC2064
trap "git -C '$root' worktree remove --force '$wt' >/dev/null 2>&1 || true" EXIT
git -C "$root" fetch origin main
git -C "$root" worktree add --detach "$wt" origin/main >/dev/null
log "verifying origin/main @ $(git -C "$wt" rev-parse --short HEAD) in $wt (log: $logf)"
(
cd "$wt"
set -x
pnpm install --frozen-lockfile
pnpm --filter web exec prisma generate
pnpm --filter web lint
pnpm --filter web typecheck
pnpm --filter web test
pnpm --filter web build
) 2>&1 | tee "$logf" || rc=$?
if grep -q ERR_PNPM_BROKEN_LOCKFILE "$logf"; then
die "broken lockfile on main — follow docs/dependabot.md failure mode 1 (do not @dependabot rebase)"
fi
(( rc == 0 )) || die "local CI mirror failed (exit $rc), see $logf"
log "main is healthy"
}

case "${1:-status}" in
status) cmd_status ;;
merge) shift; cmd_merge "$@" ;;
verify) cmd_verify ;;
*) die "usage: $0 [status | merge [--dry-run] [--include-majors] | verify]" ;;
esac
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
real_season_data/

# claude config / scratch
.claude/
.claude/*
!.claude/skills/

# deps & build output (workspace-wide)
node_modules/
Expand Down
3 changes: 3 additions & 0 deletions docs/dependabot.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ time — wait for each to fully merge before rebasing the next. When polling, ga
actually being current, not just green: require both `mergeStateStatus == CLEAN` **and** a
fresh `web` pass on the *new* head SHA (a stale pre-rebase run still shows `pass`).

`.claude/skills/dependabot-merge/dependabot-merge.sh` automates this whole section
(`status`, `merge [--dry-run]`, `verify`). Agents pick it up as the `dependabot-merge` skill.

After the batch, run the [local CI mirror](#local-ci-mirror-verification) against `main` to
confirm the lockfile is healthy.

Expand Down
Loading