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
107 changes: 107 additions & 0 deletions .dev/PROMPT_dependency_batch.md
Original file line number Diff line number Diff line change
@@ -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: <N> 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: <N> 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=<headSha> --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.<pkg>`** — copy the new value into the batch branch's `package.json`.
- **Added/changed `dependencies.<pkg>` or `devDependencies.<pkg>` 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 `<pkg>` 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 <N> ready dependency updates" -m "<one-line summary of constituents>"
git push -u origin "$BATCH_BRANCH"
gh pr create --base main --title "chore(deps): batch <N> ready dependency updates" --body "<see body shape in @dependency-rules.md>"
gh pr merge <new-PR-#> --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 "<replacement comment shape from rules — points at batch PR>"
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: <specific error, fenced>. 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 #<new-PR-#> [verification: clean | failed]` OR `Batch skipped: <reason>`.

**[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.
104 changes: 103 additions & 1 deletion .dev/dependency-rules.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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-<sha>` branch off `origin/main`. The lockfile is regenerated from the combined `package.json`, never copied. Recognized edit shapes:

- **`pnpm.overrides.<pkg>` add or version bump** — copy the value.
- **`dependencies.<pkg>` or `devDependencies.<pkg>` 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 <N> 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 — <one-line summary> (replaces)
- #B — <one-line summary> (replaces)
- #C — <one-line summary> (replaces)

## Resolved version conflicts

- `<pkg>`: #A bumped to `<vA>`, #B bumped to `<vB>`. Taking the higher: `<vB>`.

(Omit section if none.)

## Excluded from batch

- #D — <reason>: 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

<AI-assistance disclosure per CONTRIBUTING.md>
```

### Comment shape (posted on each batched constituent)

```
Ralphie replaced this with #<batch-PR> as part of a wave-merge batch.

Why: <N> PRs were ready-to-merge and overlapping on `package.json` + `pnpm-lock.yaml`. Batching avoids the rebase cascade after the first PR lands.

Replacement: #<batch-PR>
```

### Comment shape (when batch verification failed and PR remains open)

```
Ralphie attempted to batch this with #<X>, #<Y>, #<Z>, but the combined verification failed:

<fenced output of the failing pnpm command>

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:
Expand Down
59 changes: 52 additions & 7 deletions .dev/dependency.sh
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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=""
Expand All @@ -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
Expand Down Expand Up @@ -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)"

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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))
Expand All @@ -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