Merge remote-tracking branch 'origin/main' into garrytan/fix-ceo-no-askuser

This commit is contained in:
Garry Tan
2026-04-23 23:41:27 -07:00
24 changed files with 2728 additions and 46 deletions
+36 -8
View File
@@ -2736,8 +2736,8 @@ fi
Read the `STATE:` line and dispatch:
- **FRESH** → proceed with the bump action below (steps 14).
- **ALREADY_BUMPED** → skip the bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. Continue to the next step.
- **DRIFT_STALE_PKG** → a prior `/ship` bumped `VERSION` but failed to update `package.json`. Run the sync-only repair block below (after step 4). Do NOT re-bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body.
- **ALREADY_BUMPED** → skip the bump by default, BUT check for queue drift first: call `bin/gstack-next-version` with the implied bump level (derived from `CURRENT_VERSION` vs `BASE_VERSION`), compare its `.version` against `CURRENT_VERSION`. If they differ (queue moved since last ship), use **AskUserQuestion**: "VERSION drift detected: you claim v<CURRENT> but next available is v<NEW> (queue moved). A) Rebump to v<NEW> and rewrite CHANGELOG header + PR title (recommended), B) Keep v<CURRENT> — will be rejected by CI version-gate until resolved." If A, treat this as FRESH with `NEW_VERSION=<new>` and run steps 1-4 (which will also trigger Step 13 CHANGELOG header rewrite and Step 19 PR title rewrite). If B, reuse `CURRENT_VERSION` and warn that CI will likely reject. If util is offline, warn and reuse `CURRENT_VERSION`.
- **DRIFT_STALE_PKG** → a prior `/ship` bumped `VERSION` but failed to update `package.json`. Run the sync-only repair block below (after step 4). Do NOT re-bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. (Queue check still runs in ALREADY_BUMPED terms after repair.)
- **DRIFT_UNEXPECTED** → `/ship` has halted (exit 1). Resolve manually; /ship cannot tell which file is authoritative.
1. Read the current `VERSION` file (4-digit format: `MAJOR.MINOR.PATCH.MICRO`)
@@ -2750,9 +2750,33 @@ Read the `STATE:` line and dispatch:
- **MINOR** (2nd digit): **ASK the user** if ANY feature signal is detected, OR 500+ lines changed, OR new modules/packages added
- **MAJOR** (1st digit): **ASK the user** — only for milestones or breaking changes
3. Compute the new version:
- Bumping a digit resets all digits to its right to 0
- Example: `0.19.1.0` + PATCH → `0.19.2.0`
Save the chosen level as `BUMP_LEVEL` (one of `major`, `minor`, `patch`, `micro`). This is the user-intended level. The next step decides *placement* — the level stays the same even if queue-aware allocation has to advance past a claimed slot.
3. **Queue-aware version pick (workspace-aware ship, v1.6.4.0+).** Call `bin/gstack-next-version` to see what's already claimed by open PRs + active sibling Conductor worktrees, then render the queue state to the user:
```bash
QUEUE_JSON=$(bun run bin/gstack-next-version \
--base <base> \
--bump "$BUMP_LEVEL" \
--current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
NEW_VERSION=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
CLAIMED_COUNT=$(echo "$QUEUE_JSON" | jq -r '.claimed | length')
ACTIVE_SIBLING_COUNT=$(echo "$QUEUE_JSON" | jq -r '.active_siblings | length')
OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false')
REASON=$(echo "$QUEUE_JSON" | jq -r '.reason // ""')
```
- If `OFFLINE=true` or the util fails (auth expired, no `gh`/`glab`, network): fall back to local `BUMP_LEVEL` arithmetic (bump `BASE_VERSION` at the chosen level). Print `⚠ workspace-aware ship offline — using local bump only`. Continue.
- If `CLAIMED_COUNT > 0`: render the queue table to the user so they can see landing order at a glance:
```
Queue on <base> (vBASE_VERSION):
#<pr> <branch> → v<version> [⚠ collision with #<other>]
Active sibling workspaces (WIP, not yet PR'd):
<path> → v<version> (committed Nh ago)
Your branch will claim: vNEW_VERSION (<reason>)
```
- If `ACTIVE_SIBLING_COUNT > 0` and any active sibling's VERSION is `>= NEW_VERSION`, use **AskUserQuestion**: "Sibling workspace <path> has v<X> committed <N>h ago but hasn't PR'd yet. Wait for them to ship first, or advance past? A) Advance past (recommended for unrelated work), B) Abort /ship and sync up with sibling first."
- Validate `NEW_VERSION` matches `MAJOR.MINOR.PATCH.MICRO`. If util returns an empty or malformed version, fall back to local bump.
4. **Validate** `NEW_VERSION` and write it to **both** `VERSION` and `package.json`. This block runs only when `STATE: FRESH`.
@@ -3093,7 +3117,11 @@ gh pr view --json url,number,state -q 'if .state == "OPEN" then "PR #\(.number):
glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS" else "NO_MR" end' 2>/dev/null || echo "NO_MR"
```
If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body "..."` (GitHub) or `glab mr update -d "..."` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. Print the existing URL and continue to Step 20.
If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body "..."` (GitHub) or `glab mr update -d "..."` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run.
**Also update the PR title** if the version changed on rerun. PR titles use the workspace-aware format `v<NEW_VERSION> <type>: <summary>` — version ALWAYS first. If the current title's version prefix doesn't match `NEW_VERSION`, run `gh pr edit --title "v$NEW_VERSION <type>: <summary>"` (or the `glab mr update -t ...` equivalent). This keeps the title truthful when Step 12's queue-drift detection rebumps a stale version. If the title has no `v<X.Y.Z.W>` prefix (a custom title kept intentionally), leave the title alone — only rewrite titles that already follow the format.
Print the existing URL and continue to Step 20.
If no PR/MR exists: create a pull request (GitHub) or merge request (GitLab) using the platform detected in Step 0.
@@ -3161,7 +3189,7 @@ you missed it.>
**If GitHub:**
```bash
gh pr create --base <base> --title "<type>: <summary>" --body "$(cat <<'EOF'
gh pr create --base <base> --title "v$NEW_VERSION <type>: <summary>" --body "$(cat <<'EOF'
<PR body from above>
EOF
)"
@@ -3170,7 +3198,7 @@ EOF
**If GitLab:**
```bash
glab mr create -b <base> -t "<type>: <summary>" -d "$(cat <<'EOF'
glab mr create -b <base> -t "v$NEW_VERSION <type>: <summary>" -d "$(cat <<'EOF'
<MR body from above>
EOF
)"
+36 -8
View File
@@ -2351,8 +2351,8 @@ fi
Read the `STATE:` line and dispatch:
- **FRESH** → proceed with the bump action below (steps 14).
- **ALREADY_BUMPED** → skip the bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. Continue to the next step.
- **DRIFT_STALE_PKG** → a prior `/ship` bumped `VERSION` but failed to update `package.json`. Run the sync-only repair block below (after step 4). Do NOT re-bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body.
- **ALREADY_BUMPED** → skip the bump by default, BUT check for queue drift first: call `bin/gstack-next-version` with the implied bump level (derived from `CURRENT_VERSION` vs `BASE_VERSION`), compare its `.version` against `CURRENT_VERSION`. If they differ (queue moved since last ship), use **AskUserQuestion**: "VERSION drift detected: you claim v<CURRENT> but next available is v<NEW> (queue moved). A) Rebump to v<NEW> and rewrite CHANGELOG header + PR title (recommended), B) Keep v<CURRENT> — will be rejected by CI version-gate until resolved." If A, treat this as FRESH with `NEW_VERSION=<new>` and run steps 1-4 (which will also trigger Step 13 CHANGELOG header rewrite and Step 19 PR title rewrite). If B, reuse `CURRENT_VERSION` and warn that CI will likely reject. If util is offline, warn and reuse `CURRENT_VERSION`.
- **DRIFT_STALE_PKG** → a prior `/ship` bumped `VERSION` but failed to update `package.json`. Run the sync-only repair block below (after step 4). Do NOT re-bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. (Queue check still runs in ALREADY_BUMPED terms after repair.)
- **DRIFT_UNEXPECTED** → `/ship` has halted (exit 1). Resolve manually; /ship cannot tell which file is authoritative.
1. Read the current `VERSION` file (4-digit format: `MAJOR.MINOR.PATCH.MICRO`)
@@ -2365,9 +2365,33 @@ Read the `STATE:` line and dispatch:
- **MINOR** (2nd digit): **ASK the user** if ANY feature signal is detected, OR 500+ lines changed, OR new modules/packages added
- **MAJOR** (1st digit): **ASK the user** — only for milestones or breaking changes
3. Compute the new version:
- Bumping a digit resets all digits to its right to 0
- Example: `0.19.1.0` + PATCH → `0.19.2.0`
Save the chosen level as `BUMP_LEVEL` (one of `major`, `minor`, `patch`, `micro`). This is the user-intended level. The next step decides *placement* — the level stays the same even if queue-aware allocation has to advance past a claimed slot.
3. **Queue-aware version pick (workspace-aware ship, v1.6.4.0+).** Call `bin/gstack-next-version` to see what's already claimed by open PRs + active sibling Conductor worktrees, then render the queue state to the user:
```bash
QUEUE_JSON=$(bun run bin/gstack-next-version \
--base <base> \
--bump "$BUMP_LEVEL" \
--current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
NEW_VERSION=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
CLAIMED_COUNT=$(echo "$QUEUE_JSON" | jq -r '.claimed | length')
ACTIVE_SIBLING_COUNT=$(echo "$QUEUE_JSON" | jq -r '.active_siblings | length')
OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false')
REASON=$(echo "$QUEUE_JSON" | jq -r '.reason // ""')
```
- If `OFFLINE=true` or the util fails (auth expired, no `gh`/`glab`, network): fall back to local `BUMP_LEVEL` arithmetic (bump `BASE_VERSION` at the chosen level). Print `⚠ workspace-aware ship offline — using local bump only`. Continue.
- If `CLAIMED_COUNT > 0`: render the queue table to the user so they can see landing order at a glance:
```
Queue on <base> (vBASE_VERSION):
#<pr> <branch> → v<version> [⚠ collision with #<other>]
Active sibling workspaces (WIP, not yet PR'd):
<path> → v<version> (committed Nh ago)
Your branch will claim: vNEW_VERSION (<reason>)
```
- If `ACTIVE_SIBLING_COUNT > 0` and any active sibling's VERSION is `>= NEW_VERSION`, use **AskUserQuestion**: "Sibling workspace <path> has v<X> committed <N>h ago but hasn't PR'd yet. Wait for them to ship first, or advance past? A) Advance past (recommended for unrelated work), B) Abort /ship and sync up with sibling first."
- Validate `NEW_VERSION` matches `MAJOR.MINOR.PATCH.MICRO`. If util returns an empty or malformed version, fall back to local bump.
4. **Validate** `NEW_VERSION` and write it to **both** `VERSION` and `package.json`. This block runs only when `STATE: FRESH`.
@@ -2708,7 +2732,11 @@ gh pr view --json url,number,state -q 'if .state == "OPEN" then "PR #\(.number):
glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS" else "NO_MR" end' 2>/dev/null || echo "NO_MR"
```
If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body "..."` (GitHub) or `glab mr update -d "..."` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. Print the existing URL and continue to Step 20.
If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body "..."` (GitHub) or `glab mr update -d "..."` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run.
**Also update the PR title** if the version changed on rerun. PR titles use the workspace-aware format `v<NEW_VERSION> <type>: <summary>` — version ALWAYS first. If the current title's version prefix doesn't match `NEW_VERSION`, run `gh pr edit --title "v$NEW_VERSION <type>: <summary>"` (or the `glab mr update -t ...` equivalent). This keeps the title truthful when Step 12's queue-drift detection rebumps a stale version. If the title has no `v<X.Y.Z.W>` prefix (a custom title kept intentionally), leave the title alone — only rewrite titles that already follow the format.
Print the existing URL and continue to Step 20.
If no PR/MR exists: create a pull request (GitHub) or merge request (GitLab) using the platform detected in Step 0.
@@ -2776,7 +2804,7 @@ you missed it.>
**If GitHub:**
```bash
gh pr create --base <base> --title "<type>: <summary>" --body "$(cat <<'EOF'
gh pr create --base <base> --title "v$NEW_VERSION <type>: <summary>" --body "$(cat <<'EOF'
<PR body from above>
EOF
)"
@@ -2785,7 +2813,7 @@ EOF
**If GitLab:**
```bash
glab mr create -b <base> -t "<type>: <summary>" -d "$(cat <<'EOF'
glab mr create -b <base> -t "v$NEW_VERSION <type>: <summary>" -d "$(cat <<'EOF'
<MR body from above>
EOF
)"
+36 -8
View File
@@ -2727,8 +2727,8 @@ fi
Read the `STATE:` line and dispatch:
- **FRESH** → proceed with the bump action below (steps 14).
- **ALREADY_BUMPED** → skip the bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. Continue to the next step.
- **DRIFT_STALE_PKG** → a prior `/ship` bumped `VERSION` but failed to update `package.json`. Run the sync-only repair block below (after step 4). Do NOT re-bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body.
- **ALREADY_BUMPED** → skip the bump by default, BUT check for queue drift first: call `bin/gstack-next-version` with the implied bump level (derived from `CURRENT_VERSION` vs `BASE_VERSION`), compare its `.version` against `CURRENT_VERSION`. If they differ (queue moved since last ship), use **AskUserQuestion**: "VERSION drift detected: you claim v<CURRENT> but next available is v<NEW> (queue moved). A) Rebump to v<NEW> and rewrite CHANGELOG header + PR title (recommended), B) Keep v<CURRENT> — will be rejected by CI version-gate until resolved." If A, treat this as FRESH with `NEW_VERSION=<new>` and run steps 1-4 (which will also trigger Step 13 CHANGELOG header rewrite and Step 19 PR title rewrite). If B, reuse `CURRENT_VERSION` and warn that CI will likely reject. If util is offline, warn and reuse `CURRENT_VERSION`.
- **DRIFT_STALE_PKG** → a prior `/ship` bumped `VERSION` but failed to update `package.json`. Run the sync-only repair block below (after step 4). Do NOT re-bump. Reuse `CURRENT_VERSION` for CHANGELOG and PR body. (Queue check still runs in ALREADY_BUMPED terms after repair.)
- **DRIFT_UNEXPECTED** → `/ship` has halted (exit 1). Resolve manually; /ship cannot tell which file is authoritative.
1. Read the current `VERSION` file (4-digit format: `MAJOR.MINOR.PATCH.MICRO`)
@@ -2741,9 +2741,33 @@ Read the `STATE:` line and dispatch:
- **MINOR** (2nd digit): **ASK the user** if ANY feature signal is detected, OR 500+ lines changed, OR new modules/packages added
- **MAJOR** (1st digit): **ASK the user** — only for milestones or breaking changes
3. Compute the new version:
- Bumping a digit resets all digits to its right to 0
- Example: `0.19.1.0` + PATCH → `0.19.2.0`
Save the chosen level as `BUMP_LEVEL` (one of `major`, `minor`, `patch`, `micro`). This is the user-intended level. The next step decides *placement* — the level stays the same even if queue-aware allocation has to advance past a claimed slot.
3. **Queue-aware version pick (workspace-aware ship, v1.6.4.0+).** Call `bin/gstack-next-version` to see what's already claimed by open PRs + active sibling Conductor worktrees, then render the queue state to the user:
```bash
QUEUE_JSON=$(bun run bin/gstack-next-version \
--base <base> \
--bump "$BUMP_LEVEL" \
--current-version "$BASE_VERSION" 2>/dev/null || echo '{"offline":true}')
NEW_VERSION=$(echo "$QUEUE_JSON" | jq -r '.version // empty')
CLAIMED_COUNT=$(echo "$QUEUE_JSON" | jq -r '.claimed | length')
ACTIVE_SIBLING_COUNT=$(echo "$QUEUE_JSON" | jq -r '.active_siblings | length')
OFFLINE=$(echo "$QUEUE_JSON" | jq -r '.offline // false')
REASON=$(echo "$QUEUE_JSON" | jq -r '.reason // ""')
```
- If `OFFLINE=true` or the util fails (auth expired, no `gh`/`glab`, network): fall back to local `BUMP_LEVEL` arithmetic (bump `BASE_VERSION` at the chosen level). Print `⚠ workspace-aware ship offline — using local bump only`. Continue.
- If `CLAIMED_COUNT > 0`: render the queue table to the user so they can see landing order at a glance:
```
Queue on <base> (vBASE_VERSION):
#<pr> <branch> → v<version> [⚠ collision with #<other>]
Active sibling workspaces (WIP, not yet PR'd):
<path> → v<version> (committed Nh ago)
Your branch will claim: vNEW_VERSION (<reason>)
```
- If `ACTIVE_SIBLING_COUNT > 0` and any active sibling's VERSION is `>= NEW_VERSION`, use **AskUserQuestion**: "Sibling workspace <path> has v<X> committed <N>h ago but hasn't PR'd yet. Wait for them to ship first, or advance past? A) Advance past (recommended for unrelated work), B) Abort /ship and sync up with sibling first."
- Validate `NEW_VERSION` matches `MAJOR.MINOR.PATCH.MICRO`. If util returns an empty or malformed version, fall back to local bump.
4. **Validate** `NEW_VERSION` and write it to **both** `VERSION` and `package.json`. This block runs only when `STATE: FRESH`.
@@ -3084,7 +3108,11 @@ gh pr view --json url,number,state -q 'if .state == "OPEN" then "PR #\(.number):
glab mr view -F json 2>/dev/null | jq -r 'if .state == "opened" then "MR_EXISTS" else "NO_MR" end' 2>/dev/null || echo "NO_MR"
```
If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body "..."` (GitHub) or `glab mr update -d "..."` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run. Print the existing URL and continue to Step 20.
If an **open** PR/MR already exists: **update** the PR body using `gh pr edit --body "..."` (GitHub) or `glab mr update -d "..."` (GitLab). Always regenerate the PR body from scratch using this run's fresh results (test output, coverage audit, review findings, adversarial review, TODOS summary, documentation_section from Step 18). Never reuse stale PR body content from a prior run.
**Also update the PR title** if the version changed on rerun. PR titles use the workspace-aware format `v<NEW_VERSION> <type>: <summary>` — version ALWAYS first. If the current title's version prefix doesn't match `NEW_VERSION`, run `gh pr edit --title "v$NEW_VERSION <type>: <summary>"` (or the `glab mr update -t ...` equivalent). This keeps the title truthful when Step 12's queue-drift detection rebumps a stale version. If the title has no `v<X.Y.Z.W>` prefix (a custom title kept intentionally), leave the title alone — only rewrite titles that already follow the format.
Print the existing URL and continue to Step 20.
If no PR/MR exists: create a pull request (GitHub) or merge request (GitLab) using the platform detected in Step 0.
@@ -3152,7 +3180,7 @@ you missed it.>
**If GitHub:**
```bash
gh pr create --base <base> --title "<type>: <summary>" --body "$(cat <<'EOF'
gh pr create --base <base> --title "v$NEW_VERSION <type>: <summary>" --body "$(cat <<'EOF'
<PR body from above>
EOF
)"
@@ -3161,7 +3189,7 @@ EOF
**If GitLab:**
```bash
glab mr create -b <base> -t "<type>: <summary>" -d "$(cat <<'EOF'
glab mr create -b <base> -t "v$NEW_VERSION <type>: <summary>" -d "$(cat <<'EOF'
<MR body from above>
EOF
)"
+182
View File
@@ -0,0 +1,182 @@
// Pure-function tests for bin/gstack-next-version.
// Covers the version arithmetic and slot-picking logic. Subprocess paths
// (gh/glab/git) are covered by the integration test at the bottom (skipped
// when the relevant CLI isn't available).
import { test, expect, describe } from "bun:test";
import {
parseVersion,
fmtVersion,
bumpVersion,
cmpVersion,
pickNextSlot,
markActiveSiblings,
} from "../bin/gstack-next-version";
describe("parseVersion", () => {
test("accepts 4-digit semver", () => {
expect(parseVersion("1.6.3.0")).toEqual([1, 6, 3, 0]);
expect(parseVersion("0.0.0.0")).toEqual([0, 0, 0, 0]);
expect(parseVersion("99.99.99.99")).toEqual([99, 99, 99, 99]);
});
test("trims whitespace", () => {
expect(parseVersion(" 1.2.3.4 \n")).toEqual([1, 2, 3, 4]);
});
test("rejects malformed", () => {
expect(parseVersion("1.2.3")).toBeNull();
expect(parseVersion("1.2.3.4.5")).toBeNull();
expect(parseVersion("v1.2.3.4")).toBeNull();
expect(parseVersion("")).toBeNull();
expect(parseVersion("not-a-version")).toBeNull();
expect(parseVersion("1.2.3.x")).toBeNull();
});
});
describe("bumpVersion", () => {
test("major zeros everything right", () => {
expect(bumpVersion([1, 6, 3, 0], "major")).toEqual([2, 0, 0, 0]);
expect(bumpVersion([1, 6, 3, 7], "major")).toEqual([2, 0, 0, 0]);
});
test("minor zeros patch+micro", () => {
expect(bumpVersion([1, 6, 3, 0], "minor")).toEqual([1, 7, 0, 0]);
expect(bumpVersion([1, 6, 3, 7], "minor")).toEqual([1, 7, 0, 0]);
});
test("patch zeros micro", () => {
expect(bumpVersion([1, 6, 3, 0], "patch")).toEqual([1, 6, 4, 0]);
expect(bumpVersion([1, 6, 3, 7], "patch")).toEqual([1, 6, 4, 0]);
});
test("micro increments slot 4", () => {
expect(bumpVersion([1, 6, 3, 0], "micro")).toEqual([1, 6, 3, 1]);
expect(bumpVersion([1, 6, 3, 7], "micro")).toEqual([1, 6, 3, 8]);
});
});
describe("cmpVersion", () => {
test("detects order", () => {
expect(cmpVersion([1, 6, 3, 0], [1, 6, 3, 0])).toBe(0);
expect(cmpVersion([1, 6, 4, 0], [1, 6, 3, 0])).toBeGreaterThan(0);
expect(cmpVersion([1, 6, 3, 0], [1, 6, 4, 0])).toBeLessThan(0);
expect(cmpVersion([2, 0, 0, 0], [1, 99, 99, 99])).toBeGreaterThan(0);
});
});
describe("pickNextSlot (the heart of queue-aware allocation)", () => {
const base: [number, number, number, number] = [1, 6, 3, 0];
test("happy path — no claims, clean bump", () => {
const r = pickNextSlot(base, [], "minor");
expect(fmtVersion(r.version)).toBe("1.7.0.0");
expect(r.reason).toMatch(/no collision/);
});
test("collision — one PR claims the next slot, bump past", () => {
const r = pickNextSlot(base, [[1, 7, 0, 0]], "minor");
expect(fmtVersion(r.version)).toBe("1.8.0.0");
expect(r.reason).toMatch(/bumped past/);
});
test("multi-collision — two PRs claim sequential slots", () => {
const r = pickNextSlot(base, [[1, 7, 0, 0], [1, 8, 0, 0]], "minor");
expect(fmtVersion(r.version)).toBe("1.9.0.0");
});
test("collision cross-level — queued MINOR bumps past my PATCH", () => {
// Queue has 1.7.0.0 (minor), my bump is patch. I should land at 1.7.1.0
// (patch relative to the highest claim).
const r = pickNextSlot(base, [[1, 7, 0, 0]], "patch");
expect(fmtVersion(r.version)).toBe("1.7.1.0");
});
test("claims below base are ignored", () => {
const r = pickNextSlot(base, [[1, 5, 0, 0], [1, 6, 2, 0]], "patch");
expect(fmtVersion(r.version)).toBe("1.6.4.0");
expect(r.reason).toMatch(/no collision/);
});
test("claims equal to base are treated as no-claim", () => {
// The caller is expected to pre-filter base-equal claims out, but even if
// one slipped through, we don't want to inflate past it.
const r = pickNextSlot(base, [], "micro");
expect(fmtVersion(r.version)).toBe("1.6.3.1");
});
test("major collision — competing majors", () => {
const r = pickNextSlot(base, [[2, 0, 0, 0]], "major");
expect(fmtVersion(r.version)).toBe("3.0.0.0");
});
test("unsorted claims still resolve correctly", () => {
const r = pickNextSlot(base, [[1, 9, 0, 0], [1, 7, 0, 0], [1, 8, 0, 0]], "minor");
expect(fmtVersion(r.version)).toBe("1.10.0.0");
});
});
describe("markActiveSiblings", () => {
const base: [number, number, number, number] = [1, 6, 3, 0];
const now = Math.floor(Date.now() / 1000);
test("flags siblings that are ahead of base AND recent AND have no PR", () => {
const siblings = [
{ path: "/a", branch: "feat/alpha", version: "1.7.0.0", last_commit_ts: now - 60, has_open_pr: false, is_active: false },
];
const r = markActiveSiblings(siblings, base);
expect(r[0].is_active).toBe(true);
});
test("does not flag siblings with open PRs (already in the queue)", () => {
const siblings = [
{ path: "/a", branch: "feat/alpha", version: "1.7.0.0", last_commit_ts: now - 60, has_open_pr: true, is_active: false },
];
expect(markActiveSiblings(siblings, base)[0].is_active).toBe(false);
});
test("does not flag stale siblings (commit > 24h old)", () => {
const siblings = [
{ path: "/a", branch: "feat/alpha", version: "1.7.0.0", last_commit_ts: now - 25 * 3600, has_open_pr: false, is_active: false },
];
expect(markActiveSiblings(siblings, base)[0].is_active).toBe(false);
});
test("does not flag siblings at or below base", () => {
const siblings = [
{ path: "/a", branch: "feat/alpha", version: "1.6.3.0", last_commit_ts: now - 60, has_open_pr: false, is_active: false },
{ path: "/b", branch: "feat/beta", version: "1.5.0.0", last_commit_ts: now - 60, has_open_pr: false, is_active: false },
];
const r = markActiveSiblings(siblings, base);
expect(r[0].is_active).toBe(false);
expect(r[1].is_active).toBe(false);
});
});
// Integration smoke — only runs if gh is available and authenticated. Confirms
// the CLI executes end-to-end against real APIs without crashing.
describe("integration (smoke)", () => {
test("CLI runs against real repo and emits parseable JSON", async () => {
const proc = Bun.spawnSync([
"bun",
"run",
"./bin/gstack-next-version",
"--base",
"main",
"--bump",
"patch",
"--current-version",
"1.6.3.0",
"--workspace-root",
"null", // skip sibling scan in CI
]);
const out = new TextDecoder().decode(proc.stdout);
const parsed = JSON.parse(out);
expect(parsed).toHaveProperty("version");
expect(parseVersion(parsed.version)).not.toBeNull();
expect(parsed).toHaveProperty("bump", "patch");
expect(parsed).toHaveProperty("host");
expect(["github", "gitlab", "unknown"]).toContain(parsed.host);
expect(parsed).toHaveProperty("claimed");
expect(Array.isArray(parsed.claimed)).toBe(true);
expect(parsed).toHaveProperty("siblings");
expect(parsed.siblings).toEqual([]); // --workspace-root null disabled scanning
});
});