diff --git a/.changelogs/v3.35.1.md b/.changelogs/v3.35.1.md new file mode 100644 index 0000000..8d9b7db --- /dev/null +++ b/.changelogs/v3.35.1.md @@ -0,0 +1,25 @@ +# Release v3.35.1 + +Released: 2026-08-27 + +## Highlights + +- **`/do:next` works again on GitLab.** Its issue-claiming flow shelled out to `glab api ... --jq`, but plain `glab api` — unlike the `glab issue` / `glab mr` subcommands — has no `--jq` flag and exits with `Unknown flag: --jq`. Every `glab api` call now pipes to the standalone `jq` binary instead, so claiming and updating a GitLab issue no longer fails at the first API call. +- **`--self` can no longer be silently disarmed on GitLab.** The login is now resolved in two checked steps rather than one pipeline. A pipeline reports only `jq`'s exit status, and `jq -r .username` exits 0 on empty input — so a failed `glab api user` used to leave the login empty, turning the `--self` author filter into a no-op and the claim marker into a no-op assign that still looked like success. Both sites now fail closed. +- **`jq` is checked before the first `glab api` call — in issue mode only.** Piping to the standalone binary makes jq a hard dependency of the GitLab *issue* path; the probe sits at the top of that phase with an install hint, so a GitLab repo working a PLAN.md backlog (which never calls plain `glab api`) is not newly required to install jq. +- **The native blocked-by lookup no longer fails open on GitLab.** A pipeline reports only jq's exit status, and jq succeeds on empty input, so a Links-API outage was indistinguishable from "this issue has no native blockers" — and the picker could claim a dependent ahead of its blocker. A failed lookup is now UNRESOLVED (fall back to the body convention, and say so), never unblocked. + +## Fixed + +- `commands/do/next.md`: replaced `glab api --jq ` with `glab api ... | jq ` across all four call sites. Caught live while dogfooding against a real GitLab issue. +- `commands/do/next.md`: the `--self` list filter and the Phase 2 claim marker resolve the GitLab login via `glab api user` → `jq -er .username` with both exit statuses checked **and** the result guarded on non-empty. `jq -e` fails only on `null`/`false`, so a `{"username":""}` response slipped through it — and an empty login means `--author ""`, which glab reads as no author filter at all. +- `commands/do/next.md`: the GitLab issue-mode phase probes `command -v jq` before its first plain `glab api` call — and the swarm preconditions carry the same probe, since swarm replaces Phases 1–7 and would otherwise skip it while still running the native blocked-by check. +- `commands/do/next.md`: the GitLab native blocked-by lookup captures `glab api`'s response and status before filtering it, so an API failure degrades to the body convention instead of reading as "not blocked". + +## Added + +- `test/glab-jq-contract.test.js`: contracts pinning that no plain `glab api` call carries `--jq`, that the login resolution stays two-step with a non-empty guard at both sites, that the blocked-by lookup keeps glab's status separate from jq's, and that the `jq` probe covers both the issue-mode and swarm paths without leaking into the shared pre-flight. + +## Full Changelog + +**Full Diff**: https://github.com/atomantic/slashdo/compare/v3.35.0...v3.35.1 diff --git a/commands/do/next.md b/commands/do/next.md index e2b5c75..b5d8521 100644 --- a/commands/do/next.md +++ b/commands/do/next.md @@ -51,6 +51,19 @@ Collect targets into an ordered list `TARGETS` in three steps, **in this order** **Preconditions — check first; abort cleanly if any fails (do not partially claim):** - **Issues mode only.** Swarm's claim/lease is the tracker's issue-assignee marker (GitHub or GitLab), and partitioning by dependency needs the tracker. **Resolve `ISSUE_MODE` here first, including Phase 1's auto-redirect** — because swarm replaces Phases 1–7, that redirect won't fire on its own: if `--issues`/a saved default didn't already set it, apply the same structural check Phase 1 does — a repo with **no PLAN.md, or only the issue-mode stub**, *is* issue-tracked, so set `ISSUE_MODE=true` (state the switch). **An explicit numeric target settles this too** — issue numbers are inherently tracker references, so **any** numeric target (one or several) sets `ISSUE_MODE=true` (state the switch) even in a repo with a real PLAN.md backlog — **unless the user explicitly typed `--no-issues`**, which wins per this file's usual typed-flag-beats-inference rule and routes straight to the abort below. One target matters as much as several here: a lone `#12` hands off to the single-issue Phases 1–7, which must run in *issues* mode or Phase 1 would go looking for a PLAN.md slug named `12`. Abort when it still resolves to PLAN.md mode — a real PLAN.md backlog with either (no `--issues` and no numeric targets) or an explicit `--no-issues`: ``--swarm works in issues mode only — pass --issues (or run in an issue-tracked repo). PLAN.md-mode swarm is a future enhancement.`` + + **Then probe for `jq` on GitLab, right here.** Swarm replaces Phases 1–7, so the + identical probe at the top of "Phase 1 — issues mode" never runs on this path — yet + A1e/A2e's native blocked-by check calls plain `glab api ... | jq` just like the picker + does. Without this, an explicit GitLab swarm on a host with `glab` but no `jq` skips the + documented install check and cannot validate dependencies. Run it once `ISSUE_MODE` is + settled above (never before — the probe is issue-mode-only, for the same PLAN.md reason): + ```bash + if [ "$CLI_TOOL" = glab ]; then + command -v jq >/dev/null 2>&1 || { + echo "/do:next's GitLab issue mode pipes 'glab api' output through jq, which is not installed. Install it (e.g. 'brew install jq' or 'apt-get install jq') and re-run."; exit 1; } + fi + ``` - **GitHub or GitLab, with the matching CLI authenticated** — the same Phase 1 pre-flight (it ships through `/do:pr`, which supports both). - **A subagent-capable harness.** Swarm fans out parallel agents via the harness's subagent mechanism (Claude Code's `Agent`/Task tool, or the equivalent). **If the environment cannot spawn parallel subagents, fall back to sequential** — run Phase B's per-issue task (Phases 2–6, **no merge**) for each partitioned issue one after another in this same session, then proceed to Phase C so the merge stays owned by the serialized queue, not each iteration (still useful: it drains `SWARM_N` items in one invocation, just not concurrently). State that you're doing so. - **Targets are optional — and may be an explicit list.** **Check target *shape* first, before mode resolution or any claim:** every target must be an **issue number** (bare or `#`-prefixed), because a PLAN.md slug can never be a swarm member — abort on one, and let this abort win over the issues-mode abort above so the message names the real problem: ``--swarm works on issue numbers only — "" looks like a PLAN.md item. Drop --swarm to claim it, or pass issue numbers.`` Then route by count: **no target** → Phase A auto-picks the batch; **two or more** → that list IS the batch (Phase A's explicit-list path; the same `#` cherry-pick semantics, `SWARM_N` at a time); **exactly one** → this isn't a swarm: run the single-issue **Phases 1–7** for it (in issues mode, per the bullet above) and say so. @@ -207,6 +220,11 @@ Runs **once per invocation**, after the last wave, over every result the batch p > echo "/do:next detected a GitLab repo ($ORIGIN_HOST) but glab is not authenticated to it. Run 'glab auth login'."; exit 1; } > # No GH_HOST-style workaround needed here: unlike `gh api`, `glab api` and > # `glab issue`/`glab mr` already resolve the host from the repo's origin remote. +> # NOTE: the jq probe is deliberately NOT here. Piping `glab api` to the standalone +> # jq binary makes jq a dependency of the ISSUE-MODE GitLab path only — PLAN.md mode +> # never calls plain `glab api`, so probing in this shared pre-flight would abort a +> # GitLab + PLAN.md repo that has always worked without jq. The probe lives at the +> # top of "Phase 1 — issues mode" instead. > fi > ``` > Print: `VCS host: {VCS_HOST} (via {CLI_TOOL})`. Carry `CLI_TOOL`/`VCS_HOST` (and `GH_HOST` on GitHub) through every later phase — [lib/plan-issue-mode.md](../../lib/plan-issue-mode.md)'s own setup step reuses `CLI_TOOL` rather than re-detecting it. @@ -252,6 +270,20 @@ Run the shared issue-mode setup — it reuses the `CLI_TOOL` (`gh`/`glab`) the P > **Issue mode works on GitHub or GitLab.** `/do:next`'s claim mechanics (Phase 2) use the tracker's **assignee** field as the cross-machine marker on either host — GitHub via `gh issue edit --add-assignee`/`--remove-assignee`, GitLab via `glab issue update --assignee "+"`/`--assignee "-"` (the `+`/`-` prefix adds/removes one assignee without clobbering any others already on the issue, which matters for the race read-back below). Every `gh issue`/`gh api` call in this phase has a `glab issue`/`glab api` equivalent alongside it, selected by `$CLI_TOOL`. One structural gap to know about: GitHub exposes a native, project-scoped **sub-issues** API for epic/child resolution (step 3) that GitLab does not — GitLab's closest analog (group-level Epics) is a different, tier-gated feature, so on GitLab the **convention fallback** (body task-lists + `Part of #N` back-references, per [lib/epic-children.md](../../lib/epic-children.md)) is the primary path rather than a fallback of last resort. It's fully host-agnostic once every `gh` call in it is paired with its `glab` form, which it already is. +**GitLab only — probe for `jq` before the first plain `glab api` call.** `glab api` has no +built-in `--jq` flag (only the `glab issue`/`glab mr` subcommands do), so this phase and +Phase 2 pipe it to the **standalone** jq binary — which makes jq a hard dependency of the +issue-mode GitLab path. Probe it here, not in the shared Pre-flight: PLAN.md mode never +calls plain `glab api`, so a pre-flight probe would abort a GitLab + PLAN.md repo that has +always worked without jq. + +```bash +if [ "$CLI_TOOL" = glab ]; then + command -v jq >/dev/null 2>&1 || { + echo "/do:next's GitLab issue mode pipes 'glab api' output through jq, which is not installed. Install it (e.g. 'brew install jq' or 'apt-get install jq') and re-run."; exit 1; } +fi +``` + Then: 1. **List candidates** — open issues, **by priority then oldest-first**, **across all labels by default** (`gh issue list`/`glab issue list` never return pull/merge requests, so those are excluded automatically). **By default there is no author filter and no required label** — auto-pick claims any open issue regardless of who filed it or what label it carries — the guards against claiming the wrong thing are the parking-label skip (step 3), the declared-dependency skip (step 4), and the in-flight/assigned checks, *not* a gating label. Three opt-in narrowings apply when active: a label filter (`LABEL_FILTER` set via `--issues-label` or a saved `issues-label` default) restricts the set to one curated label; a **dispatch-hint filter** (`MODEL_FILTER` / `EFFORT_FILTER` set via `--model` / `--effort`) restricts it to issues whose `model:`/`effort:` labels match; and — **when `SELF_MODE` is on (`--self` / saved `self` default)** — an **author filter restricts the set to issues YOU filed** (`--author "@me"`), so issues opened by anyone else are excluded at the source. The author filter is a **security boundary**, not advisory ordering like priority: it removes other people's issues from consideration entirely. @@ -292,8 +324,21 @@ Then: # GitHub-CLI token `@me` — pass the authenticated login so --self actually # filters (the explicit-#num path below already compares against this same # `glab api user` value). + # Resolve the login in TWO steps, never one `glab api user | jq -r .username` + # pipeline: the pipeline's exit status is jq's, and `jq -r .username` exits 0 on + # empty input, so a failed `glab api user` would leave ME empty. Then GUARD ON + # NON-EMPTY separately: `jq -e` only fails on `null`/`false`, and an empty-string + # username ({"username":""}) is truthy to jq, so it exits 0 with no login. Either + # way an empty ME means `--author ""`, which glab reads as NO author filter — the + # --self security gate would silently enumerate and claim other people's issues. + # All three checks must pass before the filter is added. if [ "$SELF_MODE" = "true" ]; then - ME="$(glab api user --jq .username)" + ME_JSON="$(glab api user)" || { + echo "Could not read the authenticated GitLab user — --self cannot be enforced. Aborting."; exit 1; } + ME="$(printf '%s' "$ME_JSON" | jq -er .username)" || { + echo "Could not read the authenticated GitLab user — --self cannot be enforced. Aborting."; exit 1; } + [ -n "$ME" ] || { + echo "GitLab returned an empty username — --self cannot be enforced. Aborting."; exit 1; } LIST_ARGS+=(--author "$ME") fi glab issue list "${LIST_ARGS[@]}" --per-page 100 \ @@ -343,10 +388,10 @@ Then: - `epic-empty` (no children resolvable either way) → not really an umbrella; treat as an ordinary issue. 4. **Resolve declared dependencies before picking (blocked-by).** A candidate may declare a hard dependency in its **body**: a line matching `Depends on #` or `Blocked by #` (case-insensitive; one such line may list several, e.g. `Depends on #12, #15`). Collect every `#` referenced on those lines. A candidate is **blocked** when ANY referenced issue is still open — check the freshest state (GitHub: `gh issue view --json state -q .state`; GitLab: `glab issue view --output json --jq .state`) and test for "closed" rather than an exact "open" match, since the two hosts spell it differently (`OPEN`/`CLOSED` vs `opened`/`closed`); a referenced number that is closed, or doesn't exist, does not block. Resolve this **lazily** as you walk the queue (only for the candidate you're about to pick), so a long backlog doesn't fan out a `gh`/`glab` call per issue up front. - `blocked` (≥1 referenced issue still open) → **skip** in auto-pick; note `#N blocked by #M (open)`. The skip is **self-clearing** — when #M closes, #N becomes eligible on the next run with no manual relabel. - - Also honor each host's **native** blocked-by relationship when the API surfaces it — GitHub's GraphQL `blockedBy` connection, or GitLab's Issue Links API filtered to `link_type: "is_blocked_by"` (`glab api projects/:id/issues//links --jq '.[] | select(.link_type == "is_blocked_by")'`, id/iid resolved the same way the rest of this phase resolves them); the body convention is the portable default and the two are OR'd (blocked by *either* source ⇒ skip). + - Also honor each host's **native** blocked-by relationship when the API surfaces it — GitHub's GraphQL `blockedBy` connection, or GitLab's Issue Links API filtered to `link_type: "is_blocked_by"` (GitLab: capture the response first, then filter it — `LINKS_JSON="$(glab api projects/:id/issues//links)" || ` then `printf '%s' "$LINKS_JSON" | jq '.[] | select(.link_type == "is_blocked_by")'`; id/iid resolved the same way the rest of this phase resolves them). Two reasons for the two steps: plain `glab api` has no built-in `--jq` flag (unlike `glab issue view`/`glab issue list`), so it must pipe to the standalone `jq` binary — and a pipeline reports only **jq's** exit status, which succeeds on empty input. Collapsed into one pipeline, a links-API outage is indistinguishable from *"this issue has no native blockers"* and the picker would **fail open**, claiming a dependent ahead of its blocker. **A failed lookup is UNRESOLVED, not unblocked:** fall back to the body convention alone for that candidate and say so (`#N: native blocked-by lookup failed — using the body convention only`), never silently treat it as clear; the body convention is the portable default and the two are OR'd (blocked by *either* source ⇒ skip). - **Cycle / unresolvable chain** (A depends on B, B depends on A) → both stay skipped; note the cycle so a human can break it. Never loop trying to resolve one. 5. **Pick the target issue:** - - **With argument** — the issue number (strip `#`); **set `ISSUE_NUM` to that stripped number now** (pulling step 6's assignment earlier so the checks below can reference `$ISSUE_NUM` — on a fresh run it isn't set yet). Verify open and NOT in flight. **`--self` first, as a hard gate:** when `SELF_MODE` is on, confirm the issue's author is the running account before anything else — GitHub: `gh issue view "$ISSUE_NUM" --json author -q .author.login` must equal `gh api --hostname "$GH_HOST" user -q .login`; GitLab: `glab issue view "$ISSUE_NUM" --output json --jq .author.username` must equal `glab api user --jq .username`; if it does not, **refuse and stop** with `Issue # was filed by , not you — /do:next --self only works on issues you filed. Drop --self to claim it.` This is the **one skip an explicit number does NOT override** — `--self` is a security boundary, not a curation preference, so a deliberate cherry-pick cannot cross it (unlike a parking label or label filter). If it's an epic, resolve its state (step 3) first and act on that state — claim an `epic-wrapup`, close an `epic-done`, or warn that children are still open on an `epic-open` (the explicit request still overrides — state that you're doing so). Otherwise a named number is an **explicit override**: it claims even an issue auto-pick would skip — a parking-labelled one, one with an **open declared blocker** (step 4), one outside the curated label when `LABEL_FILTER` is active, or one outside the dispatch-hint filter when `MODEL_FILTER`/`EFFORT_FILTER` is active (but **never** an issue another user filed while `--self` is on). State plainly when you're overriding a skip (e.g. "claiming `future`-labelled #123 by explicit request", "claiming #123 despite open blocker #120 by explicit request", or "claiming `model:heavy` #123 despite --model light by explicit request"). If any other check fails (closed, in flight), print why and stop. + - **With argument** — the issue number (strip `#`); **set `ISSUE_NUM` to that stripped number now** (pulling step 6's assignment earlier so the checks below can reference `$ISSUE_NUM` — on a fresh run it isn't set yet). Verify open and NOT in flight. **`--self` first, as a hard gate:** when `SELF_MODE` is on, confirm the issue's author is the running account before anything else — GitHub: `gh issue view "$ISSUE_NUM" --json author -q .author.login` must equal `gh api --hostname "$GH_HOST" user -q .login`; GitLab: `glab issue view "$ISSUE_NUM" --output json --jq .author.username` must equal the authenticated GitLab login, read as `glab api user` piped to `jq -er .username` (plain `glab api` has no built-in `--jq` flag, unlike the `glab issue`/`glab mr` subcommands — pipe to the standalone `jq` binary instead, capturing the two exit statuses separately so a failed `glab api` cannot pass as an empty login: a pipeline reports only jq's status, and jq exits 0 on empty input — and guarding the result on non-empty, since `jq -e` fails only on `null`/`false` and an empty-string username sails through it); if it does not, **refuse and stop** with `Issue # was filed by , not you — /do:next --self only works on issues you filed. Drop --self to claim it.` This is the **one skip an explicit number does NOT override** — `--self` is a security boundary, not a curation preference, so a deliberate cherry-pick cannot cross it (unlike a parking label or label filter). If it's an epic, resolve its state (step 3) first and act on that state — claim an `epic-wrapup`, close an `epic-done`, or warn that children are still open on an `epic-open` (the explicit request still overrides — state that you're doing so). Otherwise a named number is an **explicit override**: it claims even an issue auto-pick would skip — a parking-labelled one, one with an **open declared blocker** (step 4), one outside the curated label when `LABEL_FILTER` is active, or one outside the dispatch-hint filter when `MODEL_FILTER`/`EFFORT_FILTER` is active (but **never** an issue another user filed while `--self` is on). State plainly when you're overriding a skip (e.g. "claiming `future`-labelled #123 by explicit request", "claiming #123 despite open blocker #120 by explicit request", or "claiming `model:heavy` #123 despite --model light by explicit request"). If any other check fails (closed, in flight), print why and stop. - **Without argument** — pick the FIRST candidate in the priority/oldest walk (step 1) that is NOT in flight, NOT already assigned, NOT carrying a parking label (`blocked`, `needs-input`, `wontfix`, `discussion`, `future`, or any repo-specific parking label — skip and note it), NOT blocked by an open declared dependency (step 4 — skip and note it), and NOT an `epic-open`/`epic-done` epic per step 3 (an `epic-wrapup` epic **is** eligible). Because auto-pick is label-agnostic by default, the parking-label skip, the dependency skip, and the epic resolution are the primary guards against claiming parked, blocked, or umbrella work. An explicit `#num` can still claim a skipped issue; auto-pick never surfaces one. 6. **Set `ISSUE_NUM=` and `SLUG="issue-${ISSUE_NUM}"`** — later phases use `SLUG` for worktree/branch/commit/PR and `ISSUE_NUM` for `gh issue`/`glab issue` calls. - **Surface the claimed issue's dispatch hint, if it carries one** (`model:` / `effort:`): `#42 hints model:heavy + effort:high`. In the **single-issue** flow this is a *report, not a dispatch* — a session cannot switch its own model or effort mid-run on any host, so the work proceeds in whatever session you're already in. Say so when there's a real mismatch worth acting on (`this session is on and #42 hints model:heavy — consider restarting on a stronger model, or continue as-is`), naming the mechanism **this** CLI uses to switch models if it has one, and then continue; never stall waiting for permission over an advisory label. Swarm is where the hint is actually *applied*, because that flow spawns a fresh agent per issue (Phase B). @@ -408,11 +453,21 @@ if [ "$CLI_TOOL" = gh ]; then ME="$(gh api --hostname "$GH_HOST" user -q .login)" gh issue edit "$ISSUE_NUM" --add-assignee @me else - ME="$(glab api user --jq .username)" + # Plain `glab api` has no built-in --jq flag; pipe to the standalone jq binary + # (probed at the top of Phase 1 issues mode). Resolve the login in TWO steps, not one + # pipeline: a pipeline reports only jq's exit status, and `jq -r .username` exits 0 on + # empty input, so a failed `glab api user` would leave ME empty and `--assignee "+"` + # would claim nothing while still looking like a successful claim. Chaining with `&&` + # (plus `jq -e` and the emptiness guard) fails closed into the abort handler below, + # which retracts the remote claim instead of proceeding without a marker. The + # `[ -n "$ME" ]` is NOT redundant with `jq -e`: -e only fails on null/false, so an + # empty-string username exits 0 and would assign `+` — nobody — while looking like + # a successful claim. + # # `+` ADDS one assignee without touching whatever's already on the issue. A bare # `--assignee "$ME"` REPLACES the whole assignee list, which would silently # overwrite a sibling who claimed first and defeat the read-back check below. - glab issue update "$ISSUE_NUM" --assignee "+$ME" + ME_JSON="$(glab api user)" && ME="$(printf '%s' "$ME_JSON" | jq -er .username)" && [ -n "$ME" ] && glab issue update "$ISSUE_NUM" --assignee "+$ME" fi || { echo "Could not claim issue #$ISSUE_NUM (missing write access?) — aborting." # Phase 2 already created and (best-effort) pushed next/issue-. Retract the diff --git a/package.json b/package.json index a5cc913..5f8fc9f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "slash-do", - "version": "3.35.0", + "version": "3.35.1", "description": "Curated slash commands for AI coding assistants — Claude Code, OpenCode, Antigravity CLI, Codex, and Grok Build", "author": "Adam Eivy ", "license": "MIT", diff --git a/test/glab-jq-contract.test.js b/test/glab-jq-contract.test.js new file mode 100644 index 0000000..7519f23 --- /dev/null +++ b/test/glab-jq-contract.test.js @@ -0,0 +1,80 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const root = path.join(__dirname, '..'); +const next = fs.readFileSync(path.join(root, 'commands', 'do', 'next.md'), 'utf8'); + +// `glab api` — unlike the `glab issue` / `glab mr` subcommands — has no built-in +// `--jq` flag and exits with "Unknown flag: --jq", so every `glab api` call pipes to +// the standalone jq binary instead. These contracts pin the three ways that piping +// fails silently rather than loudly. +describe('glab api / jq contracts', () => { + it('never passes --jq to a plain `glab api` call', () => { + // The flag does not exist there; the call dies before returning any JSON. + const offenders = next + .split('\n') + .filter((line) => /glab api[^|`\n]*--jq/.test(line)); + assert.deepEqual(offenders, []); + }); + + it('resolves the GitLab login in two steps, not one jq pipeline', () => { + // A pipeline's exit status is jq's, and `jq -r .username` exits 0 on EMPTY input + // (verified: exit 0, no output). So `ME="$(glab api user | jq -r .username)"` + // leaves ME empty when glab fails — `--author ""` drops the --self filter, and + // `--assignee "+"` claims nothing while looking like a successful claim. + assert.doesNotMatch(next, /ME="\$\(glab api user \| jq/); + // Both call sites capture glab's status separately and use `jq -e`, which exits + // non-zero (4) when no valid result was produced. + const twoStep = next.match(/ME_JSON="\$\(glab api user\)"/g) || []; + assert.equal(twoStep.length, 2, 'both the --self list filter and the claim marker'); + assert.equal((next.match(/jq -er \.username/g) || []).length, 3, 'two snippets + the prose contract'); + }); + + it('guards the resolved login on non-empty at BOTH sites', () => { + // `jq -e` fails only on `null`/`false`. An empty-string username — `{"username":""}` + // — is truthy to jq, so `jq -er .username` exits 0 with no login (verified). An empty + // ME then means `--author ""`, which glab reads as NO author filter: --self would + // enumerate and claim other people's issues, exactly the boundary it exists to hold. + // The claim site's `+$ME` would likewise assign nobody while looking successful. + assert.match(next, /\[ -n "\$ME" \] \|\| \{\n\s*echo "GitLab returned an empty username/); + assert.match(next, /&& \[ -n "\$ME" \] && glab issue update/); + }); + + it('keeps the native blocked-by lookup from failing open', () => { + // A pipeline reports jq's status, and jq succeeds on empty input — so a links-API + // outage would read as "no native blockers" and the picker would claim a dependent + // ahead of its blocker. The lookup captures glab's status first and treats a failure + // as UNRESOLVED (fall back to the body convention), never as unblocked. + assert.doesNotMatch(next, /glab api projects\/:id\/issues\/\/links \| jq/); + assert.match(next, /LINKS_JSON="\$\(glab api projects\/:id\/issues\/\/links\)"/); + assert.match(next, /A failed lookup is UNRESOLVED, not unblocked/); + }); + + it('probes for jq on the swarm path too', () => { + // Swarm replaces Phases 1-7, so Phase 1's probe never runs there — but A1e/A2e's + // native blocked-by check calls plain `glab api ... | jq` all the same. + const swarm = next.slice(0, next.indexOf('## Phase 1: Pick')); + assert.match(swarm, /if \[ "\$CLI_TOOL" = glab \]; then\n\s*command -v jq >\/dev\/null 2>&1 \|\| \{/); + // Still not before ISSUE_MODE is settled — the probe is issue-mode-only. + assert.ok( + swarm.indexOf('command -v jq') > swarm.indexOf('--swarm works in issues mode only'), + 'the swarm probe runs after the issues-mode gate' + ); + }); + + it('probes for jq in issue mode, not the shared pre-flight', () => { + // jq is a dependency of the ISSUE-MODE GitLab path only — PLAN.md mode never calls + // plain `glab api` (its `glab issue`/`glab mr` calls carry their own --jq). Probing + // in the shared pre-flight would abort a GitLab + PLAN.md repo that never needed jq. + // Scope to the Pre-flight host-detection block itself: the swarm section above it + // carries its own copy of the probe, gated on its own resolved ISSUE_MODE. + const preflight = next.slice(next.indexOf('## Phase 1: Pick'), next.indexOf('### Phase 1 — issues mode')); + assert.doesNotMatch(preflight, /command -v jq/, 'no jq probe before the mode split'); + assert.match(next, /if \[ "\$CLI_TOOL" = glab \]; then\n\s*command -v jq >\/dev\/null 2>&1 \|\| \{/); + assert.match(next, /GitLab issue mode pipes 'glab api' output through jq, which is not installed/); + }); +});