@windyroad/itil 0.24.0 → 0.24.1-preview.270

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,509 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # @problem P118 — docs/problems/README.md drifts from filesystem truth
4
+ # across sessions despite P094 (refresh-on-create) and P062 (refresh-on-
5
+ # transition) both being Closed. This script is the cross-session
6
+ # robustness layer ON TOP of those per-operation contracts.
7
+ #
8
+ # Contract: `reconcile-readme.sh [<problems-dir>]` is a diagnose-only
9
+ # mechanical drift detector. It reads `<problems-dir>/<NNN>-*.<status>.md`
10
+ # files (default `docs/problems`), parses the WSJF Rankings + Verification
11
+ # Queue + Closed tables in `<problems-dir>/README.md`, and reports each
12
+ # disagreement between README claim and filesystem ground truth.
13
+ #
14
+ # Exit codes:
15
+ # 0 = clean (README matches filesystem for every parsed row)
16
+ # 1 = drift detected (structured diff to stdout, one row per drift)
17
+ # 2 = parse error (README missing or malformed beyond recovery)
18
+ #
19
+ # Diff output budget per ADR-038 progressive disclosure: each diff row
20
+ # ≤ 150 bytes; output is consumed by Claude in agent context, so it
21
+ # must stay terse and machine-readable, not narrative.
22
+ #
23
+ # The script is read-only — it does NOT mutate the README. Narrative
24
+ # content (the long "Last reviewed" prose paragraph, the Closed-section
25
+ # closure-via free text) is preserved by the agent-applied-edits pattern
26
+ # in the `/wr-itil:reconcile-readme` skill which wraps this script.
27
+ #
28
+ # @jtbd JTBD-006 (Progress the Backlog While I'm Away — orchestrators
29
+ # read the README to pick the highest-WSJF actionable ticket; drift
30
+ # burns iterations on already-transitioned tickets)
31
+ # @jtbd JTBD-001 (Enforce Governance Without Slowing Down — read-only
32
+ # diagnostic, no interactive friction on the happy path)
33
+ #
34
+ # Cross-reference:
35
+ # P118: docs/problems/118-readme-drifts-from-filesystem-truth-despite-refresh-contracts-closed.open.md
36
+ # ADR-014 amended (Reconciliation as preflight robustness layer)
37
+ # ADR-022 — Verification Pending lifecycle status conventions
38
+ # ADR-038 — Progressive disclosure (per-row byte budget on diff output)
39
+ # ADR-005 — Plugin testing strategy (script-level bats governance)
40
+
41
+ setup() {
42
+ SCRIPTS_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/.." && pwd)"
43
+ SCRIPT="$SCRIPTS_DIR/reconcile-readme.sh"
44
+ FIXTURE_DIR="$(mktemp -d)"
45
+ }
46
+
47
+ teardown() {
48
+ rm -rf "$FIXTURE_DIR"
49
+ }
50
+
51
+ # ── Existence + executable ──────────────────────────────────────────────────
52
+
53
+ @test "reconcile-readme: script exists" {
54
+ [ -f "$SCRIPT" ]
55
+ }
56
+
57
+ @test "reconcile-readme: script is executable" {
58
+ [ -x "$SCRIPT" ]
59
+ }
60
+
61
+ # ── Exit code 0: clean state ─────────────────────────────────────────────────
62
+
63
+ @test "reconcile-readme: exit 0 when WSJF Rankings matches filesystem .open.md set" {
64
+ cat > "$FIXTURE_DIR/100-foo.open.md" <<EOF
65
+ # Problem 100: Foo
66
+ **Status**: Open
67
+ **WSJF**: 5.0
68
+ EOF
69
+ cat > "$FIXTURE_DIR/README.md" <<'EOF'
70
+ # Problem Backlog
71
+
72
+ ## WSJF Rankings
73
+
74
+ | WSJF | ID | Title | Severity | Status | Effort |
75
+ |------|-----|-------|----------|--------|--------|
76
+ | 5.0 | P100 | Foo | 12 High | Open | M |
77
+
78
+ ## Verification Queue
79
+
80
+ | ID | Title | Released | Likely verified? |
81
+ |----|-------|----------|------------------|
82
+
83
+ ## Closed
84
+
85
+ | ID | Title | Closed via |
86
+ |----|-------|-----------|
87
+ EOF
88
+ run "$SCRIPT" "$FIXTURE_DIR"
89
+ [ "$status" -eq 0 ]
90
+ }
91
+
92
+ # ── Exit code 1: drift cases ─────────────────────────────────────────────────
93
+
94
+ @test "reconcile-readme: exit 1 when README ranks ticket Open but file is .closed.md (P074 case)" {
95
+ # The exact symptom this ticket addresses: a prior session closed the
96
+ # ticket without staging the README refresh, leaving the WSJF Rankings
97
+ # row stale in subsequent sessions.
98
+ cat > "$FIXTURE_DIR/074-foo.closed.md" <<EOF
99
+ # Problem 074: Foo
100
+ **Status**: Closed
101
+ EOF
102
+ cat > "$FIXTURE_DIR/README.md" <<'EOF'
103
+ # Problem Backlog
104
+
105
+ ## WSJF Rankings
106
+
107
+ | WSJF | ID | Title | Severity | Status | Effort |
108
+ |------|-----|-------|----------|--------|--------|
109
+ | 6.0 | P074 | Foo | 12 High | Open | M |
110
+
111
+ ## Verification Queue
112
+
113
+ | ID | Title | Released | Likely verified? |
114
+ |----|-------|----------|------------------|
115
+
116
+ ## Closed
117
+
118
+ | ID | Title | Closed via |
119
+ |----|-------|-----------|
120
+ EOF
121
+ run "$SCRIPT" "$FIXTURE_DIR"
122
+ [ "$status" -eq 1 ]
123
+ # Output mentions P074 as the drift entry.
124
+ echo "$output" | grep -q "P074"
125
+ }
126
+
127
+ @test "reconcile-readme: exit 1 when ticket on disk as .open.md is missing from WSJF Rankings" {
128
+ # The other half of the drift class: a ticket created in a prior
129
+ # session that never refreshed README — the file exists but the
130
+ # row was never inserted.
131
+ cat > "$FIXTURE_DIR/079-bar.open.md" <<EOF
132
+ # Problem 079: Bar
133
+ **Status**: Open
134
+ **WSJF**: 3.0
135
+ EOF
136
+ cat > "$FIXTURE_DIR/README.md" <<'EOF'
137
+ # Problem Backlog
138
+
139
+ ## WSJF Rankings
140
+
141
+ | WSJF | ID | Title | Severity | Status | Effort |
142
+ |------|-----|-------|----------|--------|--------|
143
+
144
+ ## Verification Queue
145
+
146
+ | ID | Title | Released | Likely verified? |
147
+ |----|-------|----------|------------------|
148
+
149
+ ## Closed
150
+
151
+ | ID | Title | Closed via |
152
+ |----|-------|-----------|
153
+ EOF
154
+ run "$SCRIPT" "$FIXTURE_DIR"
155
+ [ "$status" -eq 1 ]
156
+ echo "$output" | grep -q "P079"
157
+ }
158
+
159
+ @test "reconcile-readme: exit 1 when README ranks ticket Open but file is .verifying.md (P110 case)" {
160
+ cat > "$FIXTURE_DIR/110-baz.verifying.md" <<EOF
161
+ # Problem 110: Baz
162
+ **Status**: Verification Pending
163
+ EOF
164
+ cat > "$FIXTURE_DIR/README.md" <<'EOF'
165
+ # Problem Backlog
166
+
167
+ ## WSJF Rankings
168
+
169
+ | WSJF | ID | Title | Severity | Status | Effort |
170
+ |------|-----|-------|----------|--------|--------|
171
+ | 4.0 | P110 | Baz | 8 Med | Open | M |
172
+
173
+ ## Verification Queue
174
+
175
+ | ID | Title | Released | Likely verified? |
176
+ |----|-------|----------|------------------|
177
+
178
+ ## Closed
179
+
180
+ | ID | Title | Closed via |
181
+ |----|-------|-----------|
182
+ EOF
183
+ run "$SCRIPT" "$FIXTURE_DIR"
184
+ [ "$status" -eq 1 ]
185
+ echo "$output" | grep -q "P110"
186
+ }
187
+
188
+ @test "reconcile-readme: exit 1 when Verification Queue lists ticket but file is .closed.md (stale VQ)" {
189
+ cat > "$FIXTURE_DIR/056-qux.closed.md" <<EOF
190
+ # Problem 056: Qux
191
+ **Status**: Closed
192
+ EOF
193
+ cat > "$FIXTURE_DIR/README.md" <<'EOF'
194
+ # Problem Backlog
195
+
196
+ ## WSJF Rankings
197
+
198
+ | WSJF | ID | Title | Severity | Status | Effort |
199
+ |------|-----|-------|----------|--------|--------|
200
+
201
+ ## Verification Queue
202
+
203
+ | ID | Title | Released | Likely verified? |
204
+ |----|-------|----------|------------------|
205
+ | P056 | Qux | 2026-01-01 | yes |
206
+
207
+ ## Closed
208
+
209
+ | ID | Title | Closed via |
210
+ |----|-------|-----------|
211
+ EOF
212
+ run "$SCRIPT" "$FIXTURE_DIR"
213
+ [ "$status" -eq 1 ]
214
+ echo "$output" | grep -q "P056"
215
+ }
216
+
217
+ # ── Diff output is structured + within byte budget (ADR-038) ────────────────
218
+
219
+ @test "reconcile-readme: drift output emits one structured line per drift entry" {
220
+ # Two distinct drift entries; output should contain at least two
221
+ # rows (one per ID).
222
+ cat > "$FIXTURE_DIR/074-foo.closed.md" <<EOF
223
+ **Status**: Closed
224
+ EOF
225
+ cat > "$FIXTURE_DIR/079-bar.open.md" <<EOF
226
+ **WSJF**: 3.0
227
+ EOF
228
+ cat > "$FIXTURE_DIR/README.md" <<'EOF'
229
+ ## WSJF Rankings
230
+
231
+ | WSJF | ID | Title | Severity | Status | Effort |
232
+ |------|-----|-------|----------|--------|--------|
233
+ | 6.0 | P074 | Foo | 12 High | Open | M |
234
+
235
+ ## Verification Queue
236
+
237
+ | ID | Title | Released | Likely verified? |
238
+ |----|-------|----------|------------------|
239
+
240
+ ## Closed
241
+
242
+ | ID | Title | Closed via |
243
+ |----|-------|-----------|
244
+ EOF
245
+ run "$SCRIPT" "$FIXTURE_DIR"
246
+ [ "$status" -eq 1 ]
247
+ # Both drift IDs surface in output.
248
+ echo "$output" | grep -q "P074"
249
+ echo "$output" | grep -q "P079"
250
+ }
251
+
252
+ @test "reconcile-readme: each diff row stays under 150 bytes (ADR-038 progressive-disclosure budget)" {
253
+ cat > "$FIXTURE_DIR/074-foo.closed.md" <<EOF
254
+ **Status**: Closed
255
+ EOF
256
+ cat > "$FIXTURE_DIR/README.md" <<'EOF'
257
+ ## WSJF Rankings
258
+
259
+ | WSJF | ID | Title | Severity | Status | Effort |
260
+ |------|-----|-------|----------|--------|--------|
261
+ | 6.0 | P074 | Foo | 12 High | Open | M |
262
+
263
+ ## Verification Queue
264
+
265
+ | ID | Title | Released | Likely verified? |
266
+ |----|-------|----------|------------------|
267
+
268
+ ## Closed
269
+
270
+ | ID | Title | Closed via |
271
+ |----|-------|-----------|
272
+ EOF
273
+ run "$SCRIPT" "$FIXTURE_DIR"
274
+ [ "$status" -eq 1 ]
275
+ # Filter to data rows only (drift entries start with a marker char).
276
+ while IFS= read -r line; do
277
+ # Skip empty lines + the header line.
278
+ [ -z "$line" ] && continue
279
+ case "$line" in
280
+ DRIFT*|MISSING*|STALE*|MISMATCH*)
281
+ len=${#line}
282
+ [ "$len" -le 150 ] || {
283
+ echo "Diff row over 150 bytes ($len): $line" >&2
284
+ return 1
285
+ }
286
+ ;;
287
+ esac
288
+ done <<< "$output"
289
+ }
290
+
291
+ # ── Exit code 2: parse error ────────────────────────────────────────────────
292
+
293
+ @test "reconcile-readme: exit 2 when README is missing" {
294
+ cat > "$FIXTURE_DIR/100-foo.open.md" <<EOF
295
+ **WSJF**: 5.0
296
+ EOF
297
+ # No README.md created.
298
+ run "$SCRIPT" "$FIXTURE_DIR"
299
+ [ "$status" -eq 2 ]
300
+ }
301
+
302
+ @test "reconcile-readme: exit 2 when README has no WSJF Rankings header (parse error)" {
303
+ cat > "$FIXTURE_DIR/100-foo.open.md" <<EOF
304
+ **WSJF**: 5.0
305
+ EOF
306
+ cat > "$FIXTURE_DIR/README.md" <<'EOF'
307
+ # Problem Backlog
308
+
309
+ This README has no WSJF Rankings section header.
310
+ EOF
311
+ run "$SCRIPT" "$FIXTURE_DIR"
312
+ [ "$status" -eq 2 ]
313
+ }
314
+
315
+ # ── Verification Pending tickets must NOT appear in WSJF Rankings (ADR-022) ─
316
+
317
+ @test "reconcile-readme: .verifying.md tickets in WSJF Rankings are flagged as drift" {
318
+ # ADR-022 — Verification Pending tickets are excluded from WSJF Rankings
319
+ # (they belong in the Verification Queue section). A .verifying.md row
320
+ # in the dev-work table is drift.
321
+ cat > "$FIXTURE_DIR/105-verify.verifying.md" <<EOF
322
+ **Status**: Verification Pending
323
+ EOF
324
+ cat > "$FIXTURE_DIR/README.md" <<'EOF'
325
+ ## WSJF Rankings
326
+
327
+ | WSJF | ID | Title | Severity | Status | Effort |
328
+ |------|-----|-------|----------|--------|--------|
329
+ | 8.0 | P105 | Verify | 12 High | Open | M |
330
+
331
+ ## Verification Queue
332
+
333
+ | ID | Title | Released | Likely verified? |
334
+ |----|-------|----------|------------------|
335
+
336
+ ## Closed
337
+
338
+ | ID | Title | Closed via |
339
+ |----|-------|-----------|
340
+ EOF
341
+ run "$SCRIPT" "$FIXTURE_DIR"
342
+ [ "$status" -eq 1 ]
343
+ echo "$output" | grep -q "P105"
344
+ }
345
+
346
+ # ── Default problems-dir resolution ─────────────────────────────────────────
347
+
348
+ @test "reconcile-readme: defaults to ./docs/problems when no arg passed" {
349
+ # cd into a fixture root that has docs/problems/README.md to confirm
350
+ # the default-arg branch executes.
351
+ mkdir -p "$FIXTURE_DIR/docs/problems"
352
+ cat > "$FIXTURE_DIR/docs/problems/README.md" <<'EOF'
353
+ ## WSJF Rankings
354
+
355
+ | WSJF | ID | Title | Severity | Status | Effort |
356
+ |------|-----|-------|----------|--------|--------|
357
+
358
+ ## Verification Queue
359
+
360
+ | ID | Title | Released | Likely verified? |
361
+ |----|-------|----------|------------------|
362
+
363
+ ## Closed
364
+
365
+ | ID | Title | Closed via |
366
+ |----|-------|-----------|
367
+ EOF
368
+ cd "$FIXTURE_DIR"
369
+ run "$SCRIPT"
370
+ [ "$status" -eq 0 ]
371
+ }
372
+
373
+ # ── Parked tickets are excluded (ADR-022 multiplier 0 + own section) ────────
374
+
375
+ @test "reconcile-readme: .parked.md tickets are not flagged as missing from WSJF Rankings" {
376
+ # Parked tickets live in their own section; they are NOT expected in
377
+ # WSJF Rankings. A .parked.md file with no WSJF Rankings row is
378
+ # correct, not drift.
379
+ cat > "$FIXTURE_DIR/005-parked.parked.md" <<EOF
380
+ **Status**: Parked
381
+ EOF
382
+ cat > "$FIXTURE_DIR/README.md" <<'EOF'
383
+ ## WSJF Rankings
384
+
385
+ | WSJF | ID | Title | Severity | Status | Effort |
386
+ |------|-----|-------|----------|--------|--------|
387
+
388
+ ## Verification Queue
389
+
390
+ | ID | Title | Released | Likely verified? |
391
+ |----|-------|----------|------------------|
392
+
393
+ ## Closed
394
+
395
+ | ID | Title | Closed via |
396
+ |----|-------|-----------|
397
+
398
+ ## Parked
399
+
400
+ | ID | Title | Reason | Parked since |
401
+ |----|-------|--------|-------------|
402
+ | P005 | Parked | Upstream | 2026-04-16 |
403
+ EOF
404
+ run "$SCRIPT" "$FIXTURE_DIR"
405
+ [ "$status" -eq 0 ]
406
+ }
407
+
408
+ # ── Closed tickets never tracked (P001-P028 don't all appear) ───────────────
409
+
410
+ @test "reconcile-readme: .closed.md tickets absent from Closed section are not drift (history is partial)" {
411
+ # The Closed section is curated narrative — it lists "recently closed
412
+ # this session" with closure-via prose. It is NOT exhaustive over
413
+ # every .closed.md file. A .closed.md file absent from the Closed
414
+ # section is allowed; the only Closed-section drift is when a row
415
+ # in that section names an ID that is NOT .closed.md on disk.
416
+ cat > "$FIXTURE_DIR/001-old.closed.md" <<EOF
417
+ **Status**: Closed
418
+ EOF
419
+ cat > "$FIXTURE_DIR/README.md" <<'EOF'
420
+ ## WSJF Rankings
421
+
422
+ | WSJF | ID | Title | Severity | Status | Effort |
423
+ |------|-----|-------|----------|--------|--------|
424
+
425
+ ## Verification Queue
426
+
427
+ | ID | Title | Released | Likely verified? |
428
+ |----|-------|----------|------------------|
429
+
430
+ ## Closed
431
+
432
+ | ID | Title | Closed via |
433
+ |----|-------|-----------|
434
+ EOF
435
+ run "$SCRIPT" "$FIXTURE_DIR"
436
+ [ "$status" -eq 0 ]
437
+ }
438
+
439
+ # ── P133: defensive rename of `status` local → `ticket_status` ──────────────
440
+
441
+ @test "reconcile-readme: drift detection still works when caller-environment exports status=anything (P133 regression)" {
442
+ # P133 — `status` is a read-only built-in under zsh (alias for `$?`). The
443
+ # script's `#!/usr/bin/env bash` shebang means it never runs under zsh
444
+ # directly, but a caller may export `status=…` into the script's environment
445
+ # and the script must not depend on the bash-builtin name for its own state.
446
+ # After the P133 rename (`status` → `ticket_status`), the script's drift
447
+ # detection is independent of any caller-set `status` env var. This test
448
+ # asserts the behaviour: caller exports `status=junk`, script still emits
449
+ # correct drift output (does not pick up the caller's value).
450
+ cat > "$FIXTURE_DIR/074-foo.closed.md" <<EOF
451
+ **Status**: Closed
452
+ EOF
453
+ cat > "$FIXTURE_DIR/README.md" <<'EOF'
454
+ ## WSJF Rankings
455
+
456
+ | WSJF | ID | Title | Severity | Status | Effort |
457
+ |------|-----|-------|----------|--------|--------|
458
+ | 6.0 | P074 | Foo | 12 High | Open | M |
459
+
460
+ ## Verification Queue
461
+
462
+ | ID | Title | Released | Likely verified? |
463
+ |----|-------|----------|------------------|
464
+
465
+ ## Closed
466
+
467
+ | ID | Title | Closed via |
468
+ |----|-------|-----------|
469
+ EOF
470
+ # `env status=junk` sets the env var on the script invocation; bats's
471
+ # `run` still captures the script exit code into the test-scope `$status`.
472
+ run env status=junk "$SCRIPT" "$FIXTURE_DIR"
473
+ [ "$status" -eq 1 ]
474
+ echo "$output" | grep -q "P074"
475
+ # Drift line must report actual filesystem status (`closed`), not the
476
+ # caller's bogus `junk` value — script reads from FS_STATUS, not env.
477
+ echo "$output" | grep -q "actual=closed"
478
+ ! echo "$output" | grep -q "actual=junk"
479
+ }
480
+
481
+ # ── README Closed-section row pointing to non-existent or wrong-status file ──
482
+
483
+ @test "reconcile-readme: exit 1 when Closed section names ID that is .open.md on disk" {
484
+ cat > "$FIXTURE_DIR/099-still-open.open.md" <<EOF
485
+ **Status**: Open
486
+ **WSJF**: 3.0
487
+ EOF
488
+ cat > "$FIXTURE_DIR/README.md" <<'EOF'
489
+ ## WSJF Rankings
490
+
491
+ | WSJF | ID | Title | Severity | Status | Effort |
492
+ |------|-----|-------|----------|--------|--------|
493
+ | 3.0 | P099 | Still Open | 15 High | Open | L |
494
+
495
+ ## Verification Queue
496
+
497
+ | ID | Title | Released | Likely verified? |
498
+ |----|-------|----------|------------------|
499
+
500
+ ## Closed
501
+
502
+ | ID | Title | Closed via |
503
+ |----|-------|-----------|
504
+ | P099 | Still Open | (incorrectly listed) |
505
+ EOF
506
+ run "$SCRIPT" "$FIXTURE_DIR"
507
+ [ "$status" -eq 1 ]
508
+ echo "$output" | grep -q "P099"
509
+ }
@@ -0,0 +1,180 @@
1
+ #!/usr/bin/env bats
2
+
3
+ # P143: scripts/release-watch.sh must absorb changesets/action workflow
4
+ # latency by polling `gh pr list` for up to 120s before exiting "no open
5
+ # release PR found". The release PR is created/updated asynchronously by
6
+ # the changesets/action GitHub workflow ~30-120s after `git push`; the
7
+ # script's first call routinely raced this window and exited 1.
8
+ #
9
+ # Behavioural test (ADR-037 + P081 — behavioural over structural grep).
10
+ # Extracts `find_release_pr` from the script via awk, sources it, and
11
+ # exercises it against a PATH-shadowed `gh` mock + stubbed `sleep`. The
12
+ # mock consumes a comma-delimited iteration sequence (e.g. "empty,empty,ok")
13
+ # so each iteration's `gh pr list` payload is deterministic.
14
+ #
15
+ # Mirrors the extraction + PATH-shadow pattern in
16
+ # scripts/repo-local-skills/install-updates/test/install-updates-step-7-retry-rollback.bats.
17
+
18
+ setup() {
19
+ REPO_ROOT="$(cd "$(dirname "$BATS_TEST_FILENAME")/../../../.." && pwd)"
20
+ SCRIPT="$REPO_ROOT/scripts/release-watch.sh"
21
+
22
+ FN_FILE="$BATS_TEST_TMPDIR/find-release-pr.sh"
23
+ awk '
24
+ /^find_release_pr\(\) \{/ { in_fn=1 }
25
+ in_fn { print }
26
+ in_fn && /^\}/ { exit }
27
+ ' "$SCRIPT" > "$FN_FILE"
28
+ }
29
+
30
+ # Stand up a PATH-shadowing `gh` mock that consumes a comma-delimited
31
+ # iteration sequence ("empty" / "ok"). Each `gh pr list` call advances the
32
+ # pointer; "empty" returns `[]`, "ok" returns a one-PR JSON array.
33
+ make_gh_mock() {
34
+ local pattern="$1"
35
+ local bindir="$BATS_TEST_TMPDIR/bin"
36
+ mkdir -p "$bindir"
37
+ printf '%s' "$pattern" > "$BATS_TEST_TMPDIR/gh-pattern"
38
+ printf '0' > "$BATS_TEST_TMPDIR/gh-counter"
39
+ : > "$BATS_TEST_TMPDIR/gh-log"
40
+ cat > "$bindir/gh" <<'MOCK'
41
+ #!/usr/bin/env bash
42
+ echo "$*" >> "$BATS_TEST_TMPDIR/gh-log"
43
+ case "$1 $2" in
44
+ "pr list")
45
+ pattern=$(cat "$BATS_TEST_TMPDIR/gh-pattern")
46
+ count=$(cat "$BATS_TEST_TMPDIR/gh-counter")
47
+ count=$((count + 1))
48
+ printf '%s' "$count" > "$BATS_TEST_TMPDIR/gh-counter"
49
+ next=$(printf '%s' "$pattern" | cut -d, -f"$count")
50
+ if [ "$next" = "ok" ]; then
51
+ echo '[{"number":99,"url":"https://github.com/example/repo/pull/99"}]'
52
+ else
53
+ echo '[]'
54
+ fi
55
+ exit 0
56
+ ;;
57
+ *) exit 0 ;;
58
+ esac
59
+ MOCK
60
+ chmod +x "$bindir/gh"
61
+ PATH="$bindir:$PATH"
62
+ export PATH
63
+ }
64
+
65
+ # Suppress real sleeps during tests — 120s wall-clock is unacceptable.
66
+ # Records each call so we can count iterations.
67
+ stub_sleep() {
68
+ : > "$BATS_TEST_TMPDIR/sleep-log"
69
+ eval 'sleep() { echo "$1" >> "$BATS_TEST_TMPDIR/sleep-log"; }'
70
+ export -f sleep
71
+ }
72
+
73
+ count_gh_pr_list_calls() {
74
+ [ -f "$BATS_TEST_TMPDIR/gh-log" ] || { echo 0; return; }
75
+ awk '/^pr list/ { n++ } END { print n+0 }' "$BATS_TEST_TMPDIR/gh-log"
76
+ }
77
+
78
+ count_sleep_calls() {
79
+ [ -f "$BATS_TEST_TMPDIR/sleep-log" ] || { echo 0; return; }
80
+ awk 'END { print NR+0 }' "$BATS_TEST_TMPDIR/sleep-log"
81
+ }
82
+
83
+ @test "find_release_pr extracted from release-watch.sh is non-empty" {
84
+ [ -s "$FN_FILE" ] || { echo "find_release_pr missing from $SCRIPT"; return 1; }
85
+ }
86
+
87
+ @test "P143: PR exists on first iteration — fast path, one gh call, no sleep" {
88
+ [ -s "$FN_FILE" ] || { echo "find_release_pr missing from $SCRIPT"; return 1; }
89
+ # shellcheck disable=SC1090
90
+ source "$FN_FILE"
91
+ make_gh_mock "ok"
92
+ stub_sleep
93
+
94
+ run find_release_pr
95
+ [ "$status" -eq 0 ]
96
+ # stdout is "<number>\t<url>"
97
+ [[ "$output" == *"99"* ]]
98
+ [[ "$output" == *"https://github.com/example/repo/pull/99"* ]]
99
+
100
+ [ "$(count_gh_pr_list_calls)" -eq 1 ]
101
+ [ "$(count_sleep_calls)" -eq 0 ]
102
+ }
103
+
104
+ @test "P143: PR appears on iteration 3 — three gh calls, two sleeps, returns the PR" {
105
+ [ -s "$FN_FILE" ] || { echo "find_release_pr missing from $SCRIPT"; return 1; }
106
+ # shellcheck disable=SC1090
107
+ source "$FN_FILE"
108
+ make_gh_mock "empty,empty,ok"
109
+ stub_sleep
110
+
111
+ run find_release_pr
112
+ [ "$status" -eq 0 ]
113
+ [[ "$output" == *"99"* ]]
114
+
115
+ [ "$(count_gh_pr_list_calls)" -eq 3 ]
116
+ [ "$(count_sleep_calls)" -eq 2 ]
117
+ }
118
+
119
+ @test "P143: empty for full 12 iterations — 12 gh calls, exit 1" {
120
+ [ -s "$FN_FILE" ] || { echo "find_release_pr missing from $SCRIPT"; return 1; }
121
+ # shellcheck disable=SC1090
122
+ source "$FN_FILE"
123
+ # 12 empties, no 13th column needed — function should give up after 12.
124
+ make_gh_mock "empty,empty,empty,empty,empty,empty,empty,empty,empty,empty,empty,empty"
125
+ stub_sleep
126
+
127
+ run find_release_pr
128
+ [ "$status" -ne 0 ]
129
+
130
+ [ "$(count_gh_pr_list_calls)" -eq 12 ]
131
+ # 11 sleeps between 12 iterations (no trailing sleep after the final
132
+ # empty result — that would burn 10s for nothing).
133
+ [ "$(count_sleep_calls)" -eq 11 ]
134
+ }
135
+
136
+ @test "P143: RELEASE_WATCH_VERBOSE=1 prints poll progress to stderr" {
137
+ [ -s "$FN_FILE" ] || { echo "find_release_pr missing from $SCRIPT"; return 1; }
138
+ # shellcheck disable=SC1090
139
+ source "$FN_FILE"
140
+ make_gh_mock "empty,empty,ok"
141
+ stub_sleep
142
+
143
+ RELEASE_WATCH_VERBOSE=1 run find_release_pr
144
+ [ "$status" -eq 0 ]
145
+ # `bats run` merges stdout + stderr in $output by default.
146
+ [[ "$output" == *"Polling"* ]] || [[ "$output" == *"attempt"* ]]
147
+ }
148
+
149
+ @test "P143: default (verbose unset) does NOT print poll progress" {
150
+ [ -s "$FN_FILE" ] || { echo "find_release_pr missing from $SCRIPT"; return 1; }
151
+ # shellcheck disable=SC1090
152
+ source "$FN_FILE"
153
+ make_gh_mock "empty,empty,ok"
154
+ stub_sleep
155
+
156
+ unset RELEASE_WATCH_VERBOSE
157
+ run find_release_pr
158
+ [ "$status" -eq 0 ]
159
+ # Output is the final tab-separated PR line ONLY — no progress lines.
160
+ [[ "$output" != *"Polling"* ]]
161
+ [[ "$output" != *"attempt"* ]]
162
+ }
163
+
164
+ @test "P143: returns tab-separated number and URL on success (parseable)" {
165
+ [ -s "$FN_FILE" ] || { echo "find_release_pr missing from $SCRIPT"; return 1; }
166
+ # shellcheck disable=SC1090
167
+ source "$FN_FILE"
168
+ make_gh_mock "ok"
169
+ stub_sleep
170
+
171
+ run find_release_pr
172
+ [ "$status" -eq 0 ]
173
+ # Caller parses with `cut -f1` / `cut -f2` — the contract is one line,
174
+ # tab-separated.
175
+ local first_field second_field
176
+ first_field=$(printf '%s\n' "$output" | head -1 | cut -f1)
177
+ second_field=$(printf '%s\n' "$output" | head -1 | cut -f2)
178
+ [ "$first_field" = "99" ]
179
+ [ "$second_field" = "https://github.com/example/repo/pull/99" ]
180
+ }