diff --git a/.dev/PROMPT_dependency_batch.md b/.dev/PROMPT_dependency_batch.md new file mode 100644 index 0000000..4927f30 --- /dev/null +++ b/.dev/PROMPT_dependency_batch.md @@ -0,0 +1,107 @@ +0a. Read @dependency-rules.md — this is the spec. Apply the **Phase 3 — Batch** section literally. +0b. Read @CLAUDE.md and @CONTRIBUTING.md (repo root) for repo conventions referenced by the rules. + +## Part 1 · Eligibility + +1. **List the ready set.** Query open PRs labeled `ralphie:ready-to-merge`, excluding any on a `chore/batch-*` head ref (so the loop never recursively batches its own batches): + + ``` + gh pr list --state open --label "ralphie:ready-to-merge" \ + --json number,title,headRefName,files \ + --jq '[.[] | select(.headRefName | startswith("chore/batch-") | not)]' + ``` + +2. **Threshold check.** If the result has fewer than 2 PRs, exit silently — there's nothing to batch. Print: `Batch skipped: ready PR(s), need ≥2.` + +3. **Overlap check.** For each pair in the ready set, compute the intersection of their `files[].path` lists. If **no** pair shares any file path, exit silently — these PRs cannot conflict on merge, so batching adds no value. Print: `Batch skipped: ready PRs but file sets are disjoint — no merge conflict will occur.` + + In practice, dependency PRs always touch `package.json` + `pnpm-lock.yaml`, so this case is rare — but honor the gate. It catches `dependabot/github_actions/*` PRs (workflow file edits) that would otherwise get pointlessly batched with npm PRs. + +4. **Compute the batchable set.** Start with all ready PRs that overlap with at least one other. (If the overlap is non-transitive — e.g., {A,B} share `package.json` and {C,D} share only `.github/workflows/ci.yml` — pick the larger connected component. In practice the whole ready set is one component because of the lockfile.) + +## Part 2 · Construct the batch + +5. **Branch off fresh `origin/main`.** Never trust local state: + + ``` + git fetch origin --quiet + git checkout main && git reset --hard origin/main + BATCH_BRANCH="chore/batch-deps-$(date +%Y%m%d)-$(git rev-parse --short HEAD)" + git checkout -b "$BATCH_BRANCH" + ``` + +6. **Replay each PR's `package.json` edits onto the batch branch.** For each PR in the batchable set: + + a. Fetch the PR's branch: `gh pr checkout <#> --detach` into a scratch worktree, OR read the PR head's `package.json` directly via `gh api repos/:owner/:repo/contents/package.json?ref= --jq .content | base64 -d`. + + b. Compute that PR's `package.json` diff vs `origin/main`. The edits should land in one of these mechanical buckets: + - **Added/changed `pnpm.overrides.`** — copy the new value into the batch branch's `package.json`. + - **Added/changed `dependencies.` or `devDependencies.` version** — copy the new version into the batch branch's `package.json`. + - **Added a new top-level key in `pnpm.overrides`** — add it to the batch. + + c. **Same-package version conflicts.** If two PRs bump the same `` to different versions, take the **higher** one (semver-wise, using `pnpm dlx semver-compare` or equivalent reasoning). Note the choice in the PR body's `Resolved version conflicts` section. + + d. **Non-mechanical edits.** If a PR's `package.json` diff touches something other than the buckets above (e.g., scripts changes, new top-level key, peer-dep manipulation), **bail this PR out of the batch** — do not include it, and note it in step 9's exclusion list. The maintainer can merge it serially. + +7. **Regenerate the lockfile:** `pnpm install`. If install fails, that's a real conflict between the bumps (e.g., peer-dep mismatch). Skip ahead to step 9's **Batch broken** branch. + +## Part 3 · Verify and ship + +8. **Verify once.** From the repo root: + - `pnpm lint` + - `pnpm build` + - `pnpm check:links` + + This is the only new verification — each constituent PR was already verified individually in Phase 2. This run catches **interaction effects**: two bumps that pass in isolation but fail when co-installed. + +9. **Branch on the result:** + + **Path A — verification clean.** Open the batch PR: + + ``` + git add package.json pnpm-lock.yaml && git commit -m "chore(deps): batch ready dependency updates" -m "" + git push -u origin "$BATCH_BRANCH" + gh pr create --base main --title "chore(deps): batch ready dependency updates" --body "" + gh pr merge --auto --squash + ``` + + Then **close each constituent** with the rules-shape replacement comment: + + ``` + gh pr edit <#> --add-label "ralphie:replaced-by-newer-pr" + gh pr comment <#> --body "" + gh pr close <#> + ``` + + Return to `${BASE_BRANCH}`. Exit. + + **Path B — verification failed or `pnpm install` failed.** Do NOT open the batch PR. Discard the branch locally: + + ``` + git checkout "${BASE_BRANCH}" + git branch -D "$BATCH_BRANCH" + ``` + + For each PR in the attempted batch, **comment** (do not label, do not close) explaining batching failed and why. Cite the specific lint/build/install error. Leave the originals' `ralphie:ready-to-merge` labels intact — the maintainer can drain them serially: + + ``` + gh pr comment <#> --body "Ralphie attempted to batch this with #X, #Y, #Z, but the combined verification failed: . Leaving this PR open for serial merge; the maintainer can rebase as each lands." + ``` + + Exit. + + **Path C — `< 2` batchable PRs after exclusions in step 6d.** If the non-mechanical-edit exclusions left only one PR, treat as no batch: print `Batch skipped: only 1 PR remained after non-mechanical exclusions.` Exit silently. + +## Part 4 · Wrap-up + +10. ${DRY_RUN_NOTE} + +11. Print a one-line summary: `Batched N PR(s) into # [verification: clean | failed]` OR `Batch skipped: `. + +**[1]** Phase 3 NEVER opens a batch unless verification on the combined diff is clean. The whole point is catching interaction effects; if there are any, the batch loses its value and constituents stay individually mergeable. +**[2]** Phase 3 NEVER overrides the human-as-gate principle. The batch PR is opened with `--auto --squash` so it merges when CI passes, but the maintainer can still cancel auto-merge or close the batch PR. Each constituent's closure is reversible (reopen the PR if needed). +**[3]** Hard constraints from @dependency-rules.md still apply: never push to `main`, never push to a Dependabot branch (we push to `chore/batch-*` which we own), never modify `.github/`, CI workflows, `dependabot.yml`, or licensing files. +**[4]** Branch off fresh `origin/main` — never local main. The cwd may be stale even after a successful pull; the wave that prompted this whole feature was caused by branching off out-of-date local state. +**[5]** If a constituent PR's HEAD has been force-updated since it got the `ralphie:ready-to-merge` label (compare `headRefOid` to what's in the label-applied comment if available), re-verify it individually instead of batching. The label promised verification of the PR's old SHA, not its new one. +**[6]** AI-assistance disclosure in the batch PR body is required by @CONTRIBUTING.md. +**[7]** End on `${BASE_BRANCH}` with a clean working tree. diff --git a/.dev/dependency-rules.md b/.dev/dependency-rules.md index 8456928..97d8720 100644 --- a/.dev/dependency-rules.md +++ b/.dev/dependency-rules.md @@ -1,6 +1,12 @@ # Dependency Rules -Source of truth for how the deps loop processes open Dependabot PRs and Dependabot security alerts. Both `PROMPT_dependency_triage.md` and `PROMPT_dependency_upgrade.md` load this file. Edit here, not in the prompts. +Source of truth for how the deps loop processes open Dependabot PRs and Dependabot security alerts. `PROMPT_dependency_triage.md`, `PROMPT_dependency_upgrade.md`, and `PROMPT_dependency_batch.md` all load this file. Edit here, not in the prompts. + +The loop is three phases: + +- **Phase 1 — Triage.** Sweep alerts (A1–A4), gate Dependabot PRs on protected paths (Rule 1). +- **Phase 2 — Upgrade.** Per-PR verify + investigate. Outcome is a `ralphie:*` label or a replacement PR. +- **Phase 3 — Batch.** Combine ≥2 `ralphie:ready-to-merge` PRs that share files into a single batch PR. Verify the combined diff once. Avoids the rebase cascade that happens when N overlapping PRs land serially. ## PR queue @@ -48,6 +54,102 @@ A PR that passes Rule 1 is **eligible** — it stays unlabeled and Phase 2 picks Ralphie never merges. The merge button is the maintainer's. Always. +## Phase 3 — Batch + +Phase 3 runs after Phase 2 drains. Its job: when ≥2 PRs are `ralphie:ready-to-merge` and touch overlapping files (typically `package.json` + `pnpm-lock.yaml`), combine them into one PR. This prevents the rebase cascade where merging the first PR makes all the others stale. + +### Eligibility gates + +Phase 3 only proceeds when **all** of the following hold: + +- **At least 2 PRs** carry `ralphie:ready-to-merge` (excluding any on a `chore/batch-*` head ref — those are batches themselves and would create infinite recursion). +- **At least one pair shares a touched file path.** If the ready set is e.g. one npm bump and one github-actions bump, their file sets are disjoint and they can merge in any order without conflicting — batching adds no value, so skip. + +If either gate fails, Phase 3 exits silently with a one-line reason. No error. + +### What gets batched + +For each PR in the batchable set, Phase 3 replays *only* the PR's `package.json` edits onto a fresh `chore/batch-deps-YYYYMMDD-` branch off `origin/main`. The lockfile is regenerated from the combined `package.json`, never copied. Recognized edit shapes: + +- **`pnpm.overrides.` add or version bump** — copy the value. +- **`dependencies.` or `devDependencies.` version bump** — copy the version. +- **Same-package version conflict** (two PRs bump the same package to different versions) — take the higher version. Note in the batch PR body. +- **Non-mechanical edits** (scripts, new top-level keys, peer-dep maneuvers) — exclude that PR from the batch. Note in the batch PR body's `Excluded` section. The maintainer can merge it serially. + +### Verification + +After regenerating the lockfile, run the same three commands Phase 2 uses: `pnpm lint`, `pnpm build`, `pnpm check:links`. This is the only verification step that actually matters in Phase 3 — each constituent was already verified individually. The combined verify catches **interaction effects** (two bumps that pass alone but break when co-installed). + +### Outcomes + +| # | Condition | Action | +| --- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| B1 | < 2 ready PRs after exclusions, or all file sets disjoint | Exit silently with one-line reason. Nothing changed. | +| B2 | Combined diff verifies clean | Open batch PR with `--auto --squash`. Each constituent: apply `ralphie:replaced-by-newer-pr`, post replacement comment pointing at batch, `gh pr close`. Originals stay reopenable in case rollback is needed. | +| B3 | `pnpm install` or any verification step failed | Discard the batch branch locally. **Do not** label or close constituents — they remain individually mergeable. Post a comment on each explaining batching failed with the cited error. The maintainer drains serially using GitHub's merge queue or by clicking through, possibly with `@dependabot rebase` for Dependabot ones along the way. | + +### Hard constraints for Phase 3 + +- Branch off **fresh `origin/main`** (always `git fetch` first). Never trust local main — the wave that prompted building this feature was caused by branching off stale local state. +- Never push to `main`, never push to a Dependabot branch, never modify `.github/`, CI workflows, `dependabot.yml`, or licensing files (same as Phase 1+2). +- The batch PR auto-merges on green CI, but the maintainer can always cancel auto-merge or close the PR. Each constituent's closure is reversible — `gh pr reopen <#>` brings it back if the batch goes sideways. + +### Batch PR body shape + +``` +## Summary + +Batches ready-to-merge dependency PRs into one merge to avoid a rebase cascade. Each constituent was individually verified clean in Phase 2; this PR re-verifies the combined diff for interaction effects. + +## Constituents + +- #A — (replaces) +- #B — (replaces) +- #C — (replaces) + +## Resolved version conflicts + +- ``: #A bumped to ``, #B bumped to ``. Taking the higher: ``. + +(Omit section if none.) + +## Excluded from batch + +- #D — : non-mechanical edit (scripts/peer-dep/etc.). Merge serially. + +(Omit section if none.) + +## Test plan + +- [x] `pnpm install` succeeds; lockfile regenerated cleanly from combined `package.json` +- [x] `pnpm lint` — clean +- [x] `pnpm build` — clean +- [x] `pnpm check:links` — clean +- [ ] CI green + + +``` + +### Comment shape (posted on each batched constituent) + +``` +Ralphie replaced this with # as part of a wave-merge batch. + +Why: PRs were ready-to-merge and overlapping on `package.json` + `pnpm-lock.yaml`. Batching avoids the rebase cascade after the first PR lands. + +Replacement: # +``` + +### Comment shape (when batch verification failed and PR remains open) + +``` +Ralphie attempted to batch this with #, #, #, but the combined verification failed: + + + +Leaving this PR open for serial merge. The maintainer can drain individually; Dependabot will rebase on `@dependabot rebase` for any PR that goes stale after the first merge. +``` + ## Alert sweep outcomes Phase 1 sweeps alerts before the PR queue. After dedup by GHSA/CVE ID, each remaining alert gets exactly one outcome: diff --git a/.dev/dependency.sh b/.dev/dependency.sh index f18988b..3a3aaf9 100755 --- a/.dev/dependency.sh +++ b/.dev/dependency.sh @@ -1,11 +1,13 @@ #!/bin/bash set -o pipefail # Usage: ./dependency.sh [options] -# ./dependency.sh # triage + upgrade loop, default --max-deps 3 +# ./dependency.sh # triage + upgrade + batch loop, default --max-deps 3 # ./dependency.sh --max-deps 5 # cap Phase 2 attempts at 5 # ./dependency.sh --triage-only # Phase 1 only -# ./dependency.sh --upgrade-only # skip Phase 1, run upgrade loop on the queue -# ./dependency.sh --pr 124 # skip triage; one upgrade session against PR #124 +# ./dependency.sh --upgrade-only # skip Phase 1, run upgrade loop then batch +# ./dependency.sh --batch-only # skip Phase 1+2, only batch the current ready set +# ./dependency.sh --no-batch # skip Phase 3 (batching) +# ./dependency.sh --pr 124 # skip triage; one upgrade session against PR #124 (no batch) # ./dependency.sh --pr 124 --override # bypass self-skip rules (size, verification) for that PR # ./dependency.sh --dry-run # Phase 1 preview only, no writes # ./dependency.sh --pr 124 --dry-run # preview a single upgrade session, no writes @@ -21,6 +23,8 @@ RESET='\033[0m' MAX_DEPS=3 TRIAGE_ONLY=0 UPGRADE_ONLY=0 +BATCH_ONLY=0 +NO_BATCH=0 SINGLE_PR="" DRY_RUN="" OVERRIDE="" @@ -39,6 +43,14 @@ while [ $# -gt 0 ]; do UPGRADE_ONLY=1 shift ;; + --batch-only) + BATCH_ONLY=1 + shift + ;; + --no-batch) + NO_BATCH=1 + shift + ;; --pr) SINGLE_PR="$2" shift 2 @@ -74,6 +86,14 @@ if [ -n "$OVERRIDE" ] && [ -z "$SINGLE_PR" ]; then echo "Error: --override is only valid with --pr <#>. The Phase 2 auto-loop must always respect skip gates." >&2 exit 1 fi +if [ $BATCH_ONLY -eq 1 ] && { [ $TRIAGE_ONLY -eq 1 ] || [ $UPGRADE_ONLY -eq 1 ] || [ $NO_BATCH -eq 1 ]; }; then + echo "Error: --batch-only is mutually exclusive with --triage-only / --upgrade-only / --no-batch." >&2 + exit 1 +fi +if [ $BATCH_ONLY -eq 1 ] && [ -n "$SINGLE_PR" ]; then + echo "Error: --batch-only and --pr <#> are mutually exclusive." >&2 + exit 1 +fi SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" @@ -166,13 +186,19 @@ run_session() { return ${PIPESTATUS[1]} } -# ── Single-PR mode: skip triage, run one upgrade session ── +# ── Single-PR mode: skip triage, run one upgrade session, no batch ── if [ -n "$SINGLE_PR" ]; then export PR_NUMBER="$SINGLE_PR" run_session "PROMPT_dependency_upgrade.md" "Upgrade #$SINGLE_PR" exit $? fi +# ── Batch-only mode: skip Phase 1+2, run Phase 3 directly ── +if [ $BATCH_ONLY -eq 1 ]; then + run_session "PROMPT_dependency_batch.md" "Phase 3 · Batch ready-to-merge PRs" + exit $? +fi + # ── Phase 1: Triage (skipped with --upgrade-only) ── if [ $UPGRADE_ONLY -eq 0 ]; then run_session "PROMPT_dependency_triage.md" "Phase 1 · Triage" @@ -221,8 +247,8 @@ while [ $DEPS_DONE -lt $MAX_DEPS ]; do rm -f "$QUEUE_ERR" if [ -z "$NEXT" ]; then - echo -e "\n ${GREEN}✓${RESET} Queue empty. $DEPS_DONE PR(s) attempted. Exiting." - exit 0 + echo -e "\n ${GREEN}✓${RESET} Queue empty. $DEPS_DONE PR(s) attempted." + break fi DEPS_DONE=$((DEPS_DONE + 1)) @@ -248,4 +274,23 @@ while [ $DEPS_DONE -lt $MAX_DEPS ]; do sleep 1 done -echo -e "\n ${YELLOW}Reached --max-deps=$MAX_DEPS. Stopping.${RESET}" +if [ $DEPS_DONE -ge $MAX_DEPS ]; then + echo -e "\n ${YELLOW}Reached --max-deps=$MAX_DEPS.${RESET}" +fi + +# ── Phase 3: Batch ready-to-merge PRs into a single PR (skipped with --no-batch) ── +if [ $NO_BATCH -eq 1 ]; then + echo -e "\n ${DIM}Skipping Phase 3 (--no-batch).${RESET}" + exit 0 +fi + +# Defensive: return to BASE_BRANCH before Phase 3 (same reason as the per-iteration +# checkout above — the last Phase 2 session may have left the tree on a PR branch). +git -C "$REPO_ROOT" checkout --quiet "$BASE_BRANCH" 2>/dev/null || true + +run_session "PROMPT_dependency_batch.md" "Phase 3 · Batch ready-to-merge PRs" +BATCH_EXIT=$? +if [ $BATCH_EXIT -ne 0 ]; then + echo -e " ${RED}Batch session exited with status $BATCH_EXIT${RESET}" + exit $BATCH_EXIT +fi