diff --git a/.claude/skills/dependabot-merge/SKILL.md b/.claude/skills/dependabot-merge/SKILL.md new file mode 100644 index 0000000..b2d5b6f --- /dev/null +++ b/.claude/skills/dependabot-merge/SKILL.md @@ -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`. diff --git a/.claude/skills/dependabot-merge/dependabot-merge.sh b/.claude/skills/dependabot-merge/dependabot-merge.sh new file mode 100755 index 0000000..8f8bb85 --- /dev/null +++ b/.claude/skills/dependabot-merge/dependabot-merge.sh @@ -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 +} + +# "/" 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 diff --git a/.gitignore b/.gitignore index f59dd57..d7f1b9f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,8 @@ real_season_data/ # claude config / scratch -.claude/ +.claude/* +!.claude/skills/ # deps & build output (workspace-wide) node_modules/ diff --git a/docs/dependabot.md b/docs/dependabot.md index 594b9a0..daa828b 100644 --- a/docs/dependabot.md +++ b/docs/dependabot.md @@ -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.