@windyroad/itil 0.24.0-preview.268 → 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.
- package/.claude-plugin/plugin.json +1 -1
- package/package.json +2 -1
- package/scripts/check-problems-readme-budget.sh +73 -0
- package/scripts/classify-readme-drift.sh +115 -0
- package/scripts/reconcile-readme.sh +208 -0
- package/scripts/test/check-problems-readme-budget.bats +192 -0
- package/scripts/test/classify-readme-drift.bats +262 -0
- package/scripts/test/reconcile-readme.bats +509 -0
- package/scripts/test/release-watch-poll-loop.bats +180 -0
- package/skills/work-problems/SKILL.md +41 -1
|
@@ -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
|
+
}
|