forgedock 1.1.7 → 1.1.9

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.
Files changed (97) hide show
  1. package/README.md +10 -33
  2. package/bin/cinema.mjs +23 -5
  3. package/bin/engine/invariants.mjs +361 -0
  4. package/bin/engine-cli.mjs +137 -11
  5. package/bin/env-detect.mjs +249 -0
  6. package/bin/forge-utils.mjs +364 -4
  7. package/bin/forgedock.mjs +1064 -135
  8. package/bin/hooks/pre-tool-use.mjs +182 -0
  9. package/bin/hooks/session-start.mjs +68 -3
  10. package/bin/init-detect.mjs +101 -12
  11. package/bin/journey.mjs +917 -16
  12. package/bin/labels.json +10 -0
  13. package/bin/recall.mjs +557 -0
  14. package/bin/registry.mjs +91 -3
  15. package/bin/runner.mjs +38 -2
  16. package/bin/tui.mjs +49 -3
  17. package/commands/adopt.md +1 -1
  18. package/commands/analytics.md +8 -8
  19. package/commands/audit.md +1 -1
  20. package/commands/autopilot.md +958 -77
  21. package/commands/ci-audit.md +1 -1
  22. package/commands/cleanup.md +139 -55
  23. package/commands/compat-audit.md +1 -1
  24. package/commands/deploy-info.md +1 -1
  25. package/commands/deploy-pr.md +1 -1
  26. package/commands/diagnose.md +1 -1
  27. package/commands/explain.md +1 -1
  28. package/commands/fix-ci.md +1 -1
  29. package/commands/forgedock-init.md +1 -1
  30. package/commands/incident-response.md +3 -3
  31. package/commands/issue.md +1 -1
  32. package/commands/optimize.md +586 -1
  33. package/commands/orchestrate/config.md +13 -3
  34. package/commands/orchestrate/phase-1-resolve.md +36 -26
  35. package/commands/orchestrate/phase-2-triage.md +1 -1
  36. package/commands/orchestrate/phase-2.5-synthesis.md +1 -1
  37. package/commands/orchestrate/phase-3-dependency.md +587 -48
  38. package/commands/orchestrate/phase-4-execution.md +1031 -57
  39. package/commands/orchestrate/phase-5-cleanup.md +1 -1
  40. package/commands/orchestrate/phase-6-report.md +248 -11
  41. package/commands/orchestrate/safety.md +2 -2
  42. package/commands/pipeline-health/phase-2-metrics.md +305 -1
  43. package/commands/pipeline-health/phase-4-proposals.md +127 -0
  44. package/commands/qa-sweep.md +1 -1
  45. package/commands/quality-gate.md +418 -1
  46. package/commands/recover-orphans.md +5 -5
  47. package/commands/replay.md +1 -1
  48. package/commands/review-pr-agents/api.md +2 -2
  49. package/commands/review-pr-agents/auth.md +2 -2
  50. package/commands/review-pr-agents/billing.md +2 -2
  51. package/commands/review-pr-agents/concurrency.md +2 -2
  52. package/commands/review-pr-agents/database.md +2 -2
  53. package/commands/review-pr-agents/frontend.md +2 -2
  54. package/commands/review-pr-agents/infra.md +2 -2
  55. package/commands/review-pr-agents/protocols.md +1 -1
  56. package/commands/review-pr-agents/scraper.md +2 -2
  57. package/commands/review-pr-agents/security.md +2 -2
  58. package/commands/review-pr-staging.md +41 -3
  59. package/commands/review-pr.md +462 -19
  60. package/commands/rollback.md +1 -1
  61. package/commands/scope.md +1 -1
  62. package/commands/security-audit.md +1 -1
  63. package/commands/spec-doctor.md +429 -0
  64. package/commands/test-gate.md +1 -1
  65. package/commands/validate.md +53 -1
  66. package/commands/work-on/build/architect.md +78 -1
  67. package/commands/work-on/build/context.md +360 -22
  68. package/commands/work-on/build/implement.md +3 -1
  69. package/commands/work-on/build/validate.md +29 -1
  70. package/commands/work-on/build.md +75 -6
  71. package/commands/work-on/close.md +624 -10
  72. package/commands/work-on/decompose.md +1 -1
  73. package/commands/work-on/investigate.md +235 -5
  74. package/commands/work-on/remediate.md +331 -0
  75. package/commands/work-on/review.md +1 -1
  76. package/commands/work-on-monolithic.md +1 -1
  77. package/commands/work-on.md +205 -66
  78. package/docs/CI.md +2 -2
  79. package/docs/CODEX.md +1 -1
  80. package/docs/CONFIG.md +186 -0
  81. package/docs/FORGE-PROTOCOL.md +101 -0
  82. package/docs/site/command-reference.md +31 -1
  83. package/docs/site/getting-started.md +9 -34
  84. package/docs/site/index.md +1 -1
  85. package/docs/spec/forge-protocol-v1.md +97 -5
  86. package/docs/spec/label-state-machine.md +6 -1
  87. package/package.json +3 -2
  88. package/packages/protocol/src/cli.js +374 -84
  89. package/packages/protocol/src/types.js +75 -1
  90. package/scripts/build-knowledge-index.mjs +1761 -0
  91. package/scripts/calibration.mjs +996 -0
  92. package/scripts/check-command-docs-drift.sh +4 -2
  93. package/scripts/danger-zones.mjs +789 -0
  94. package/scripts/doctor-pipeline-state.sh +88 -2
  95. package/scripts/select-fix-targets.sh +234 -0
  96. package/scripts/transition-label.sh +245 -6
  97. package/scripts/refresh-bot-token.sh +0 -97
@@ -29,9 +29,11 @@
29
29
  # are silently disabled and warrants investigating the underlying `gh` failure.
30
30
  #
31
31
  # Exit codes:
32
- # 0 = no findings (pipeline healthy)
32
+ # 0 = no findings (pipeline healthy — all checks ran and passed)
33
33
  # 1 = findings present
34
34
  # 2 = error (missing deps, bad args)
35
+ # 3 = inconclusive — checks were skipped due to gh API failures (total=0 but checks_skipped>0);
36
+ # pipeline health cannot be confirmed; treat as UNKNOWN, not healthy
35
37
  #
36
38
  # Depends on: gh (GitHub CLI), jq, date
37
39
  #
@@ -43,6 +45,8 @@
43
45
  # I5 Issues in workflow:in-review with no open PR
44
46
  # I6 PRs open without a FORGE:CONTRACT or FORGE:BUILDER annotation
45
47
  # I7 Orphaned worktree branches (local branches fix/* with no open PR)
48
+ # I8 FORGE-referenced gists with public visibility (KNOWLEDGE_GIST, MILESTONE_INDEX,
49
+ # PRIOR_GIST annotations in recent issue comments)
46
50
  #
47
51
  # /pipeline-health should consume this script's JSON output rather than
48
52
  # re-discovering state from scratch.
@@ -401,6 +405,79 @@ while IFS= read -r row; do
401
405
  fi
402
406
  done < <(echo "$OPEN_PRS" | jq -c '.[]')
403
407
 
408
+ # ---------------------------------------------------------------------------
409
+ # I8: FORGE-referenced gists with public visibility
410
+ #
411
+ # Scan the last 100 issues (open + closed) for comments containing
412
+ # FORGE:KNOWLEDGE_GIST, FORGE:MILESTONE_INDEX, or FORGE:PRIOR_GIST annotations.
413
+ # Extract the gist ID from each URL and query gh api gists/{id} --jq .public.
414
+ # A "true" result means a pipeline memory gist is world-readable — CRITICAL finding.
415
+ #
416
+ # Fail-open on gh API errors: a network blip or rate-limit must NOT be reported
417
+ # as a confirmed public gist. Uses add_skip (same pattern as I4/I5) so persistent
418
+ # API degradation surfaces in --json degraded[] without inflating CRITICAL count.
419
+ # ---------------------------------------------------------------------------
420
+ RECENT_ISSUES=$(gh issue list $GH_FLAG \
421
+ --state all \
422
+ --limit 100 \
423
+ --json number \
424
+ --jq '.[].number' 2>"$GH_STDERR_TMP" || true)
425
+
426
+ I8_FETCH_ERR=$(cat "$GH_STDERR_TMP")
427
+ if [ -z "$RECENT_ISSUES" ] && [ -n "$I8_FETCH_ERR" ]; then
428
+ echo "WARNING: gh issue list failed for I8 gist-visibility scan — check inconclusive, skipping: $I8_FETCH_ERR" >&2
429
+ add_skip "I8" "all" "gh issue list failed: $(bounded_reason "$I8_FETCH_ERR")"
430
+ fi
431
+
432
+ for issue_num in $RECENT_ISSUES; do
433
+ # Fetch comments and extract FORGE gist annotation URLs
434
+ if ! ISSUE_COMMENTS=$(gh issue view "$issue_num" $GH_FLAG \
435
+ --json comments \
436
+ --jq '[.comments[].body] | join("\n")' 2>"$GH_STDERR_TMP"); then
437
+ echo "WARNING: gh issue view failed for #$issue_num (I8) — skipping: $(cat "$GH_STDERR_TMP")" >&2
438
+ add_skip "I8" "$issue_num" "gh issue view failed: $(bounded_reason "$(cat "$GH_STDERR_TMP")")"
439
+ continue
440
+ fi
441
+
442
+ # Extract gist URLs from all three FORGE annotation types.
443
+ # The character class [^\s >]+ matches any non-whitespace, non-space, non-> char,
444
+ # which covers standard usernames, bot accounts (e.g. rapiercraft-forge[bot]),
445
+ # and gist hashes — all valid in a gist URL before the closing ' -->'.
446
+ GIST_URLS=$(echo "$ISSUE_COMMENTS" \
447
+ | grep -oE '<!-- FORGE:(KNOWLEDGE_GIST|PRIOR_GIST): https://gist\.github\.com/[^ >]+ -->' \
448
+ | grep -oE 'https://gist\.github\.com/[^ >]+' || true)
449
+ # FORGE:MILESTONE_INDEX may appear in milestone description, not comments, but
450
+ # some pipelines also post it as a comment — include it in the scan.
451
+ MILESTONE_GIST_URLS=$(echo "$ISSUE_COMMENTS" \
452
+ | grep -oE '<!-- FORGE:MILESTONE_INDEX: https://gist\.github\.com/[^ >]+ -->' \
453
+ | grep -oE 'https://gist\.github\.com/[^ >]+' || true)
454
+ ALL_GIST_URLS=$(printf '%s\n%s\n' "$GIST_URLS" "$MILESTONE_GIST_URLS" | grep -v '^$' | sort -u || true)
455
+
456
+ for gist_url in $ALL_GIST_URLS; do
457
+ # Extract gist ID: last path component after the username segment
458
+ gist_id=$(echo "$gist_url" | sed 's|.*/||')
459
+ if [ -z "$gist_id" ]; then
460
+ continue
461
+ fi
462
+
463
+ # Query gist visibility — fail-open on API error (rate limit, auth, network)
464
+ if ! IS_PUBLIC=$(gh api "gists/${gist_id}" --jq '.public' 2>"$GH_STDERR_TMP"); then
465
+ echo "WARNING: gh api gists/${gist_id} failed (I8) — skipping: $(cat "$GH_STDERR_TMP")" >&2
466
+ add_skip "I8" "$issue_num" "gh api gists/${gist_id} failed: $(bounded_reason "$(cat "$GH_STDERR_TMP")")"
467
+ continue
468
+ fi
469
+
470
+ if [ "$IS_PUBLIC" = "true" ]; then
471
+ add_finding \
472
+ "public_forge_gist" "critical" \
473
+ "$issue_num" "gist_visibility" "0" \
474
+ "FORGE:KNOWLEDGE_GIST/FORGE:MILESTONE_INDEX/FORGE:PRIOR_GIST" \
475
+ "gh api gists/${gist_id} --jq '.public'" \
476
+ "Issue #${issue_num} references a public FORGE gist: ${gist_url} — gist ID: ${gist_id}. Pipeline memory gists (FORGE:KNOWLEDGE_GIST, FORGE:MILESTONE_INDEX, FORGE:PRIOR_GIST) MUST be secret. Public gists expose root causes, file paths, and security findings to the open internet. Invariant I8 broken. Migration: gh gist view ${gist_id} > /tmp/gist_migrate.md && gh gist delete ${gist_id} --yes && gh gist create /tmp/gist_migrate.md --desc '<original description>' (no --public). Then update the annotation URL on issue #${issue_num}."
477
+ fi
478
+ done
479
+ done
480
+
404
481
  # ---------------------------------------------------------------------------
405
482
  # Summary
406
483
  # ---------------------------------------------------------------------------
@@ -429,6 +506,12 @@ if $JSON_MODE; then
429
506
  --argjson findings "$FINDINGS_JSON" \
430
507
  --argjson summary "$SUMMARY_JSON" \
431
508
  '{findings:$findings,summary:$summary}'
509
+ # WIRE:PROVEN — reachable when gh API fails during a doctor run with no prior findings:
510
+ # all I1/I2/I3/I6 checks fail and add_skip() is called; CHECKS_SKIPPED > 0; TOTAL stays 0.
511
+ # Verified by the scenario in #1590: total gh outage → all list checks skipped → previously exit 0.
512
+ if [ "$TOTAL" -eq 0 ] && [ "$CHECKS_SKIPPED" -gt 0 ]; then
513
+ exit 3 # INCONCLUSIVE — checks skipped due to gh failures; cannot confirm healthy
514
+ fi
432
515
  [ "$TOTAL" -eq 0 ] && exit 0 || exit 1
433
516
  fi
434
517
 
@@ -443,11 +526,14 @@ if [ "$CHECKS_SKIPPED" -gt 0 ]; then
443
526
  fi
444
527
 
445
528
  if [ "$TOTAL" -eq 0 ]; then
529
+ # WIRE:PROVEN — same trigger as JSON mode branch above: TOTAL=0 and CHECKS_SKIPPED>0.
530
+ # This branch executes when the outer 'if [ "$TOTAL" -eq 0 ]' block is entered
531
+ # and CHECKS_SKIPPED > 0, i.e., at least one gh API call failed during the run.
446
532
  if [ "$CHECKS_SKIPPED" -gt 0 ]; then
447
533
  echo " No stalls detected in checks that ran, but $CHECKS_SKIPPED check(s) were skipped"
448
534
  echo " due to gh API failures — pipeline health is INCONCLUSIVE, not confirmed healthy."
449
535
  echo ""
450
- exit 0
536
+ exit 3 # INCONCLUSIVE — cannot confirm healthy when checks were skipped
451
537
  fi
452
538
  echo " No stalls detected. Pipeline is healthy."
453
539
  echo ""
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env bash
2
+ # select-fix-targets.sh — Deterministic priority-ranked issue selection for autopilot
3
+ #
4
+ # Usage:
5
+ # select-fix-targets.sh [-R <owner/repo>] [--limit <n>] [--fixture-test]
6
+ #
7
+ # -R <owner/repo> : GitHub repository (optional, defaults to current repo)
8
+ # --limit <n> : Maximum number of issues to output (optional, default: 10)
9
+ # --fixture-test : Run against synthetic fixture data instead of GitHub API.
10
+ # Exits 0 if output matches expected ranking, 1 otherwise.
11
+ # Use this for wire-through proof per #1731.
12
+ #
13
+ # Output:
14
+ # One issue number per line, in priority order (highest priority first).
15
+ # Suitable for: for ISSUE_NUM in $(select-fix-targets.sh); do ... done
16
+ #
17
+ # Ranking algorithm (deterministic — no LLM required):
18
+ # 1. P0 issues first (priority:P0 label)
19
+ # 2. P1 issues second (priority:P1 label)
20
+ # 3. P2 issues third (priority:P2 label)
21
+ # 4. Unlabeled issues (no priority:P* label) are EXCLUDED — never selected autonomously
22
+ #
23
+ # Tie-breaker within each priority band: oldest createdAt wins.
24
+ # This ensures fresh self-filed findings (from /autopilot recon) never outrank
25
+ # older human-filed issues at the same priority level. See: #1752.
26
+ #
27
+ # Exclusions (issues never selected, regardless of priority):
28
+ # - Issues with needs-human label
29
+ # - Issues with a milestone (feature-lane issues — autopilot handles these in Phase 3)
30
+ # - Issues with terminal workflow labels (workflow:merged, workflow:invalid, workflow:decomposed)
31
+ # - Issues with active workflow labels (workflow:building, workflow:in-review) — already in flight
32
+ #
33
+ # Exit codes: 0 = success (or fixture test passed), 1 = error (or fixture test failed)
34
+ #
35
+ # Added: forge#1752
36
+
37
+ set -euo pipefail
38
+
39
+ GH_REPO_ARGS=()
40
+ LIMIT=10
41
+ FIXTURE_TEST=false
42
+
43
+ # Parse arguments
44
+ while [ $# -gt 0 ]; do
45
+ case "$1" in
46
+ -R)
47
+ if [ $# -lt 2 ]; then
48
+ echo "ERROR: -R requires a value <owner/repo>" >&2
49
+ echo "Usage: select-fix-targets.sh [-R <owner/repo>] [--limit <n>] [--fixture-test]" >&2
50
+ exit 1
51
+ fi
52
+ if ! [[ "$2" =~ ^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$ ]]; then
53
+ echo "ERROR: -R value must be owner/repo format, got: $2" >&2
54
+ exit 1
55
+ fi
56
+ GH_REPO_ARGS=(-R "$2")
57
+ shift 2
58
+ ;;
59
+ --limit)
60
+ if [ $# -lt 2 ] || ! [[ "$2" =~ ^[0-9]+$ ]]; then
61
+ echo "ERROR: --limit requires a positive integer" >&2
62
+ exit 1
63
+ fi
64
+ LIMIT="$2"
65
+ shift 2
66
+ ;;
67
+ --fixture-test)
68
+ FIXTURE_TEST=true
69
+ shift
70
+ ;;
71
+ *)
72
+ echo "ERROR: unknown argument: $1" >&2
73
+ echo "Usage: select-fix-targets.sh [-R <owner/repo>] [--limit <n>] [--fixture-test]" >&2
74
+ exit 1
75
+ ;;
76
+ esac
77
+ done
78
+
79
+ # ---------------------------------------------------------------------------
80
+ # Fixture test mode (wire-through proof per #1731)
81
+ # ---------------------------------------------------------------------------
82
+ # Runs the ranking logic against synthetic JSON that exercises every edge case:
83
+ # - P0 issue (should be first)
84
+ # - P1 issue (should be second)
85
+ # - Unlabeled issue (should be excluded)
86
+ # - Fresh P2 self-filed finding (should be last among selected, after P0 and P1)
87
+ # - Older P2 issue (should appear before fresh P2 within the P2 band)
88
+ # Expected output order: 100 (P0), 101 (P1, older), 103 (P2, older), 104 (P2, newer)
89
+ # Issues 102 (unlabeled) and 105 (needs-human) must be absent from output.
90
+ #
91
+ # This test does NOT make GitHub API calls — it validates the jq pipeline directly.
92
+ # ---------------------------------------------------------------------------
93
+ if [ "$FIXTURE_TEST" = "true" ]; then
94
+ FIXTURE_JSON='[
95
+ {
96
+ "number": 100,
97
+ "createdAt": "2026-01-01T00:00:00Z",
98
+ "milestone": null,
99
+ "labels": [{"name": "priority:P0"}, {"name": "bug"}]
100
+ },
101
+ {
102
+ "number": 101,
103
+ "createdAt": "2026-01-02T00:00:00Z",
104
+ "milestone": null,
105
+ "labels": [{"name": "priority:P1"}]
106
+ },
107
+ {
108
+ "number": 102,
109
+ "createdAt": "2026-01-03T00:00:00Z",
110
+ "milestone": null,
111
+ "labels": [{"name": "bug"}]
112
+ },
113
+ {
114
+ "number": 103,
115
+ "createdAt": "2026-03-01T00:00:00Z",
116
+ "milestone": null,
117
+ "labels": [{"name": "priority:P2"}]
118
+ },
119
+ {
120
+ "number": 104,
121
+ "createdAt": "2026-07-08T01:30:00Z",
122
+ "milestone": null,
123
+ "labels": [{"name": "priority:P2"}, {"name": "bug"}]
124
+ },
125
+ {
126
+ "number": 105,
127
+ "createdAt": "2026-01-01T00:00:00Z",
128
+ "milestone": null,
129
+ "labels": [{"name": "priority:P0"}, {"name": "needs-human"}]
130
+ },
131
+ {
132
+ "number": 106,
133
+ "createdAt": "2026-01-01T00:00:00Z",
134
+ "milestone": {"title": "v2.0"},
135
+ "labels": [{"name": "priority:P0"}]
136
+ }
137
+ ]'
138
+
139
+ # Expected order: 100, 101, 103, 104
140
+ # Excluded: 102 (no priority label), 105 (needs-human), 106 (has milestone)
141
+ EXPECTED="100
142
+ 101
143
+ 103
144
+ 104"
145
+
146
+ ACTUAL=$(echo "$FIXTURE_JSON" | jq -r '
147
+ # Step 1: Filter out exclusions
148
+ [.[] | select(
149
+ .milestone == null and
150
+ (.labels | map(.name) | any(. == "needs-human" or
151
+ . == "workflow:merged" or . == "workflow:invalid" or
152
+ . == "workflow:decomposed" or . == "workflow:building" or
153
+ . == "workflow:in-review") | not) and
154
+ # Only include issues with an explicit priority:P* label (unlabeled = excluded)
155
+ (.labels | map(.name) | any(startswith("priority:P")))
156
+ )] |
157
+ # Step 2: Assign numeric priority rank (lower = higher priority)
158
+ # P0 → 0, P1 → 1, P2 → 2, any other priority:P* → 9 (future-proof)
159
+ map(. + {
160
+ "_rank": (
161
+ .labels | map(.name) |
162
+ if any(. == "priority:P0") then 0
163
+ elif any(. == "priority:P1") then 1
164
+ elif any(. == "priority:P2") then 2
165
+ else 9
166
+ end
167
+ )
168
+ }) |
169
+ # Step 3: Sort by rank ASC, then by createdAt ASC (oldest wins within same band)
170
+ sort_by([._rank, .createdAt]) |
171
+ # Step 4: Output issue numbers
172
+ .[].number
173
+ ')
174
+
175
+ if [ "$ACTUAL" = "$EXPECTED" ]; then
176
+ echo "FIXTURE TEST PASSED — ranking: $(echo "$ACTUAL" | tr '\n' ' ')"
177
+ echo "Excluded correctly: 102 (unlabeled), 105 (needs-human), 106 (has milestone)"
178
+ exit 0
179
+ else
180
+ echo "FIXTURE TEST FAILED" >&2
181
+ echo "Expected order: $(echo "$EXPECTED" | tr '\n' ' ')" >&2
182
+ echo "Actual order: $(echo "$ACTUAL" | tr '\n' ' ')" >&2
183
+ echo "" >&2
184
+ echo "Diff:" >&2
185
+ diff <(echo "$EXPECTED") <(echo "$ACTUAL") >&2
186
+ exit 1
187
+ fi
188
+ fi
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Live mode: query GitHub and apply ranking
192
+ # ---------------------------------------------------------------------------
193
+
194
+ # Fetch open issues — get all candidates in one API call.
195
+ # JSON fields: number, createdAt, milestone (null or object), labels (array of name objects).
196
+ # Limit to 200 issues to match the rest of autopilot's queries; ranking is applied in jq.
197
+ ISSUES_JSON=$(gh issue list "${GH_REPO_ARGS[@]}" \
198
+ --state open \
199
+ --limit 200 \
200
+ --json number,createdAt,milestone,labels \
201
+ 2>/dev/null || echo '[]')
202
+
203
+ if [ "$ISSUES_JSON" = "[]" ] || [ -z "$ISSUES_JSON" ]; then
204
+ echo "" # No issues — empty output is valid (caller loops over nothing)
205
+ exit 0
206
+ fi
207
+
208
+ echo "$ISSUES_JSON" | jq -r --argjson limit "$LIMIT" '
209
+ # Step 1: Filter out exclusions (same logic as fixture test)
210
+ [.[] | select(
211
+ .milestone == null and
212
+ (.labels | map(.name) | any(. == "needs-human" or
213
+ . == "workflow:merged" or . == "workflow:invalid" or
214
+ . == "workflow:decomposed" or . == "workflow:building" or
215
+ . == "workflow:in-review") | not) and
216
+ # Only include issues with an explicit priority:P* label (unlabeled = excluded)
217
+ (.labels | map(.name) | any(startswith("priority:P")))
218
+ )] |
219
+ # Step 2: Assign numeric priority rank
220
+ map(. + {
221
+ "_rank": (
222
+ .labels | map(.name) |
223
+ if any(. == "priority:P0") then 0
224
+ elif any(. == "priority:P1") then 1
225
+ elif any(. == "priority:P2") then 2
226
+ else 9
227
+ end
228
+ )
229
+ }) |
230
+ # Step 3: Sort by rank ASC, then by createdAt ASC (oldest wins within same priority band)
231
+ sort_by([._rank, .createdAt]) |
232
+ # Step 4: Apply limit and output issue numbers
233
+ .[:$limit] | .[].number
234
+ '
@@ -2,37 +2,138 @@
2
2
  # transition-label.sh — Workflow label state machine for ForgeDock pipeline
3
3
  #
4
4
  # Usage: transition-label.sh <ISSUE_NUMBER> <GH_FLAG...> <TARGET_STATE>
5
+ # OR: transition-label.sh --validate <VERDICT> <ISSUE_NUMBER> [GH_FLAG...]
5
6
  #
6
7
  # ISSUE_NUMBER GitHub issue number (e.g. 674)
7
8
  # GH_FLAG Repository flag passed to gh (e.g. -R RapierCraftStudios/forgedock)
8
9
  # May be multiple tokens — pass before TARGET_STATE
9
10
  # TARGET_STATE One of: investigating, ready-to-build, building, in-review,
10
- # merged, invalid, decomposed
11
+ # merged, invalid, decomposed, awaiting-merge
12
+ #
13
+ # --validate Sub-command mode: translate an investigation verdict into a
14
+ # finding-lifecycle label (needs-validation → validated/false-positive).
15
+ # VERDICT One of: CONFIRMED, NOT-CONFIRMED, INVALID, PARTIAL
16
+ # CONFIRMED → validated; all others → false-positive
11
17
  #
12
18
  # Examples:
13
19
  # transition-label.sh 674 -R RapierCraftStudios/forgedock investigating
14
20
  # transition-label.sh 674 -R owner/repo ready-to-build
21
+ # transition-label.sh --validate CONFIRMED 674 -R owner/repo
22
+ # transition-label.sh --validate NOT-CONFIRMED 674 -R owner/repo
15
23
  #
16
- # Behavior:
24
+ # Behavior (workflow mode):
17
25
  # 1. Validates ISSUE_NUMBER and TARGET_STATE
18
26
  # 2. Verifies the issue exists (exits 1 if not)
19
27
  # 3. Adds workflow:{TARGET_STATE} label
20
28
  # 4. Removes all other workflow:* labels atomically
21
29
  #
30
+ # Behavior (--validate mode):
31
+ # 1. Validates VERDICT and ISSUE_NUMBER
32
+ # 2. Verifies issue exists and has needs-validation label (exits 0 silently if not)
33
+ # 3. Adds validated (CONFIRMED) or false-positive (all other verdicts) label
34
+ # 4. Removes needs-validation label
35
+ # 5. Idempotent: if already labeled validated/false-positive, exits 0 silently
36
+ #
22
37
  # Exit codes: 0 = success, 1 = error (bad args, issue not found, gh failure)
23
38
 
24
39
  set -euo pipefail
25
40
 
26
41
  # ---------------------------------------------------------------------------
27
- # Argument parsing
42
+ # --validate sub-command: finding lifecycle label transition
43
+ # Signature: --validate <VERDICT> <ISSUE_NUMBER> [GH_FLAG...]
44
+ # Separate from the workflow state machine — operates on needs-validation,
45
+ # validated, false-positive labels only; never touches workflow:* labels.
46
+ # ---------------------------------------------------------------------------
47
+ if [ "${1:-}" = "--validate" ]; then
48
+ shift
49
+
50
+ if [ "$#" -lt 2 ]; then
51
+ echo "ERROR: Usage: transition-label.sh --validate <VERDICT> <ISSUE_NUMBER> [GH_FLAG...]" >&2
52
+ echo " VERDICT: CONFIRMED | NOT-CONFIRMED | INVALID | PARTIAL" >&2
53
+ echo " Example: transition-label.sh --validate CONFIRMED 674 -R owner/repo" >&2
54
+ exit 1
55
+ fi
56
+
57
+ VERDICT="$1"
58
+ shift
59
+ ISSUE_NUMBER="$1"
60
+ shift
61
+ GH_ARGS=("$@")
62
+
63
+ # Validate verdict
64
+ case "$VERDICT" in
65
+ CONFIRMED|NOT-CONFIRMED|INVALID|PARTIAL) ;;
66
+ *)
67
+ echo "ERROR: Unknown verdict: '$VERDICT'" >&2
68
+ echo " Valid verdicts: CONFIRMED, NOT-CONFIRMED, INVALID, PARTIAL" >&2
69
+ exit 1
70
+ ;;
71
+ esac
72
+
73
+ # Validate issue number
74
+ if ! [[ "$ISSUE_NUMBER" =~ ^[0-9]+$ ]]; then
75
+ echo "ERROR: ISSUE_NUMBER must be a positive integer, got: '$ISSUE_NUMBER'" >&2
76
+ exit 1
77
+ fi
78
+
79
+ # Verify issue exists
80
+ if ! gh issue view "$ISSUE_NUMBER" "${GH_ARGS[@]}" --json number >/dev/null 2>&1; then
81
+ echo "ERROR: Issue #$ISSUE_NUMBER not found (GH_FLAG: ${GH_ARGS[*]:-<none>})" >&2
82
+ exit 1
83
+ fi
84
+
85
+ # Check if issue carries needs-validation (idempotent gate)
86
+ CURRENT_LABELS=$(gh issue view "$ISSUE_NUMBER" "${GH_ARGS[@]}" --json labels \
87
+ --jq '[.labels[].name] | join(",")' 2>/dev/null || echo "")
88
+
89
+ if ! echo "$CURRENT_LABELS" | grep -q "needs-validation"; then
90
+ echo "OK: Issue #$ISSUE_NUMBER does not have needs-validation — no action taken (idempotent)"
91
+ exit 0
92
+ fi
93
+
94
+ # Check if already resolved (idempotent)
95
+ if echo "$CURRENT_LABELS" | grep -qE "validated|false-positive"; then
96
+ echo "OK: Issue #$ISSUE_NUMBER already has a resolved verdict label — no action taken (idempotent)"
97
+ exit 0
98
+ fi
99
+
100
+ # Map verdict to label
101
+ if [ "$VERDICT" = "CONFIRMED" ]; then
102
+ TARGET_VERDICT_LABEL="validated"
103
+ echo "Verdict CONFIRMED → adding 'validated' to issue #$ISSUE_NUMBER..."
104
+ else
105
+ TARGET_VERDICT_LABEL="false-positive"
106
+ echo "Verdict $VERDICT → adding 'false-positive' to issue #$ISSUE_NUMBER..."
107
+ fi
108
+
109
+ # Add verdict label
110
+ gh issue edit "$ISSUE_NUMBER" "${GH_ARGS[@]}" --add-label "$TARGET_VERDICT_LABEL"
111
+
112
+ # Remove needs-validation
113
+ gh issue edit "$ISSUE_NUMBER" "${GH_ARGS[@]}" --remove-label "needs-validation" 2>/dev/null || true
114
+
115
+ echo "OK: needs-validation → $TARGET_VERDICT_LABEL on issue #$ISSUE_NUMBER"
116
+ exit 0
117
+ fi
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # Argument parsing (workflow state machine mode)
28
121
  # Signature: <ISSUE_NUMBER> [GH_FLAG...] <TARGET_STATE>
29
122
  # We consume ISSUE_NUMBER as $1, TARGET_STATE as the last arg, everything
30
123
  # in between is GH_FLAG (e.g. -R RapierCraftStudios/forgedock).
31
124
  # ---------------------------------------------------------------------------
32
125
 
126
+ # Deferred exit code (forge#1929) — set to 1 by the persistent stale-label
127
+ # removal failure branch below instead of exiting immediately, so that the
128
+ # unconditional needs-human clear step (scoped to awaiting-merge) always
129
+ # gets a chance to run before the script actually exits. Initialized here,
130
+ # before any conditional logic, so `set -u` never sees it unbound.
131
+ TRANSITION_EXIT_CODE=0
132
+
33
133
  if [ "$#" -lt 2 ]; then
34
134
  echo "ERROR: Usage: transition-label.sh <ISSUE_NUMBER> [GH_FLAG...] <TARGET_STATE>" >&2
35
135
  echo " Example: transition-label.sh 674 -R RapierCraftStudios/forgedock investigating" >&2
136
+ echo " OR: transition-label.sh --validate CONFIRMED 674 -R owner/repo" >&2
36
137
  exit 1
37
138
  fi
38
139
 
@@ -65,6 +166,7 @@ VALID_STATES=(
65
166
  "merged"
66
167
  "invalid"
67
168
  "decomposed"
169
+ "awaiting-merge"
68
170
  )
69
171
 
70
172
  # ---------------------------------------------------------------------------
@@ -161,9 +263,146 @@ echo "Adding $EFFECTIVE_LABEL to issue #$ISSUE_NUMBER..."
161
263
  gh issue edit "$ISSUE_NUMBER" "${GH_ARGS[@]}" --add-label "$EFFECTIVE_LABEL"
162
264
 
163
265
  # ---------------------------------------------------------------------------
164
- # Remove all other workflow:* labels (best-effort — labels may not all exist)
266
+ # Remove all other workflow:* labels currently on the issue (best-effort)
267
+ #
268
+ # IMPORTANT: `gh issue edit --remove-label` is atomic across its whole
269
+ # comma-separated argument — if ANY label in the list is not a valid label
270
+ # on the repo (e.g. a newly added VALID_STATES entry like awaiting-merge
271
+ # whose repo-side `gh label create` / bootstrap hasn't run yet), the ENTIRE
272
+ # call fails with "not found" and — under `set -euo pipefail` without the
273
+ # `|| true` this used to silently swallow — no labels are removed at all,
274
+ # including ones that DO exist and SHOULD have been cleared (forge#1810
275
+ # follow-up: this exact bug was caught by dogfooding this script against
276
+ # the live repo before `workflow:awaiting-merge` had been bootstrapped).
277
+ #
278
+ # Fix: only ask to remove labels that are BOTH (a) in REMOVE_LABELS (valid
279
+ # states other than the target) AND (b) actually present on the issue right
280
+ # now. A label that was never applied to this issue can't be "not found" on
281
+ # the repo without failing the whole call, and a label the issue doesn't
282
+ # have doesn't need removing anyway — so intersecting against the issue's
283
+ # current labels sidesteps the all-or-nothing failure mode entirely instead
284
+ # of relying on `|| true` to mask it.
285
+ # ---------------------------------------------------------------------------
286
+ CURRENT_ISSUE_LABELS=$(gh issue view "$ISSUE_NUMBER" "${GH_ARGS[@]}" --json labels \
287
+ --jq '[.labels[].name] | join(",")' 2>/dev/null || echo "")
288
+
289
+ TO_REMOVE=""
290
+ IFS=',' read -ra REMOVE_CANDIDATES <<< "$REMOVE_LABELS"
291
+ for candidate in "${REMOVE_CANDIDATES[@]}"; do
292
+ case ",$CURRENT_ISSUE_LABELS," in
293
+ *",$candidate,"*)
294
+ TO_REMOVE="${TO_REMOVE:+$TO_REMOVE,}$candidate"
295
+ ;;
296
+ esac
297
+ done
298
+
299
+ if [ -n "$TO_REMOVE" ]; then
300
+ echo "Removing stale workflow:* labels present on the issue ($TO_REMOVE)..."
301
+ gh issue edit "$ISSUE_NUMBER" "${GH_ARGS[@]}" --remove-label "$TO_REMOVE" 2>/dev/null || true
302
+
303
+ # -------------------------------------------------------------------------
304
+ # Post-condition verification + single retry (forge#1915)
305
+ #
306
+ # The intersection fix above (forge#1810) already prevents the "unbootstrapped
307
+ # label 404" all-or-nothing failure — every candidate in $TO_REMOVE is known
308
+ # to exist on the issue at the time it was computed. But the removal call
309
+ # itself can still fail for reasons unrelated to label existence (a transient
310
+ # `gh`/GitHub API hiccup, rate limiting, a dropped connection), and that
311
+ # failure was previously swallowed unconditionally by `2>/dev/null || true`
312
+ # with no verification — leaving stale workflow:* labels stacked alongside
313
+ # the newly-added terminal label with no diagnostic trail. Live evidence:
314
+ # issue #1892 closed with both workflow:in-review and workflow:merged applied
315
+ # simultaneously because this exact removal call silently failed once.
316
+ #
317
+ # Fix: re-fetch the issue's labels after the removal call. If any label in
318
+ # $TO_REMOVE is still present, retry the removal once. If it's *still*
319
+ # present after the retry, this is a real, persistent failure — surface it
320
+ # loudly (stderr ERROR + non-zero exit) instead of the unconditional "OK".
321
+ #
322
+ # forge#1929: the non-zero exit is DEFERRED (via $TRANSITION_EXIT_CODE)
323
+ # rather than immediate. This block runs before the needs-human clear step
324
+ # further down, which is scoped to TARGET_STATE=awaiting-merge and must
325
+ # run regardless of this failure — it is unrelated to stale-label removal.
326
+ # An immediate `exit 1` here would silently skip that unrelated cleanup
327
+ # step in the one case (persistent removal failure) it's most important
328
+ # for the caller to see a consistent, fully-applied label state.
329
+ # -------------------------------------------------------------------------
330
+ POST_REMOVE_LABELS=$(gh issue view "$ISSUE_NUMBER" "${GH_ARGS[@]}" --json labels \
331
+ --jq '[.labels[].name] | join(",")' 2>/dev/null || echo "")
332
+
333
+ STILL_PRESENT=""
334
+ IFS=',' read -ra TO_REMOVE_CHECK <<< "$TO_REMOVE"
335
+ for candidate in "${TO_REMOVE_CHECK[@]}"; do
336
+ case ",$POST_REMOVE_LABELS," in
337
+ *",$candidate,"*)
338
+ STILL_PRESENT="${STILL_PRESENT:+$STILL_PRESENT,}$candidate"
339
+ ;;
340
+ esac
341
+ done
342
+
343
+ if [ -n "$STILL_PRESENT" ]; then
344
+ echo "WARNING: label removal did not take effect for ($STILL_PRESENT) — retrying once..." >&2
345
+ gh issue edit "$ISSUE_NUMBER" "${GH_ARGS[@]}" --remove-label "$STILL_PRESENT" 2>/dev/null || true
346
+
347
+ POST_RETRY_LABELS=$(gh issue view "$ISSUE_NUMBER" "${GH_ARGS[@]}" --json labels \
348
+ --jq '[.labels[].name] | join(",")' 2>/dev/null || echo "")
349
+
350
+ STILL_PRESENT_AFTER_RETRY=""
351
+ IFS=',' read -ra RETRY_CHECK <<< "$STILL_PRESENT"
352
+ for candidate in "${RETRY_CHECK[@]}"; do
353
+ case ",$POST_RETRY_LABELS," in
354
+ *",$candidate,"*)
355
+ STILL_PRESENT_AFTER_RETRY="${STILL_PRESENT_AFTER_RETRY:+$STILL_PRESENT_AFTER_RETRY,}$candidate"
356
+ ;;
357
+ esac
358
+ done
359
+
360
+ if [ -n "$STILL_PRESENT_AFTER_RETRY" ]; then
361
+ echo "ERROR: failed to remove stale label(s) ($STILL_PRESENT_AFTER_RETRY) from issue #$ISSUE_NUMBER after retry — label state machine now inconsistent (issue carries both '$EFFECTIVE_LABEL' and the stale label(s) above)." >&2
362
+ TRANSITION_EXIT_CODE=1
363
+ else
364
+ echo "Retry succeeded — stale label(s) removed."
365
+ fi
366
+ fi
367
+ else
368
+ echo "No stale workflow:* labels present on the issue — nothing to remove."
369
+ fi
370
+
165
371
  # ---------------------------------------------------------------------------
166
- echo "Removing stale workflow:* labels ($REMOVE_LABELS)..."
167
- gh issue edit "$ISSUE_NUMBER" "${GH_ARGS[@]}" --remove-label "$REMOVE_LABELS" 2>/dev/null || true
372
+ # Clear needs-human (best-effort) — SCOPED to workflow:awaiting-merge only
373
+ #
374
+ # needs-human is NOT a workflow:* label, so it is never included in
375
+ # VALID_STATES/REMOVE_LABELS above. It is a write-only, sticky, terminal
376
+ # label: once a human flags an issue/PR as needing attention, no routine
377
+ # forward-progress transition (investigating, ready-to-build, building,
378
+ # in-review, merged, invalid, decomposed) should silently clear it.
379
+ #
380
+ # The ONE deliberate exception is workflow:awaiting-merge (forge#1809/#1810):
381
+ # a remediated + re-reviewed PR that has moved off needs-human without yet
382
+ # meeting the auto-land bar. That is the single code path allowed to clear
383
+ # needs-human — every other TARGET_STATE must leave a pre-existing
384
+ # needs-human label untouched.
385
+ #
386
+ # Best-effort: `|| true` so a missing label never fails the script under
387
+ # `set -euo pipefail`.
388
+ # ---------------------------------------------------------------------------
389
+ if [ "$TARGET_STATE" = "awaiting-merge" ]; then
390
+ echo "Clearing needs-human (best-effort, scoped to awaiting-merge)..."
391
+ gh issue edit "$ISSUE_NUMBER" "${GH_ARGS[@]}" --remove-label "needs-human" 2>/dev/null || true
392
+ else
393
+ echo "Skipping needs-human clear — TARGET_STATE is '$TARGET_STATE', not 'awaiting-merge'."
394
+ fi
395
+
396
+ # ---------------------------------------------------------------------------
397
+ # Final exit (forge#1929) — honor any deferred failure from the persistent
398
+ # stale-label removal check above. The needs-human clear step just above
399
+ # always ran first, regardless of that failure, so the caller-visible exit
400
+ # code still surfaces the same "fail loud" signal introduced by forge#1915,
401
+ # just after the unrelated cleanup step has had a chance to run.
402
+ # ---------------------------------------------------------------------------
403
+ if [ "$TRANSITION_EXIT_CODE" -ne 0 ]; then
404
+ echo "FAILED: $EFFECTIVE_LABEL set on issue #$ISSUE_NUMBER, but persistent stale-label removal failure occurred (see ERROR above)." >&2
405
+ exit "$TRANSITION_EXIT_CODE"
406
+ fi
168
407
 
169
408
  echo "OK: $EFFECTIVE_LABEL set on issue #$ISSUE_NUMBER"