forgedock 1.1.6 → 1.1.8

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 (43) hide show
  1. package/README.md +3 -1
  2. package/bin/engine/invariants.mjs +361 -0
  3. package/bin/hooks/pre-tool-use.mjs +182 -0
  4. package/bin/journey.mjs +77 -7
  5. package/bin/labels.json +10 -0
  6. package/bin/recall.mjs +540 -0
  7. package/commands/autopilot.md +959 -78
  8. package/commands/cleanup.md +52 -3
  9. package/commands/optimize.md +585 -0
  10. package/commands/orchestrate/phase-1-resolve.md +35 -25
  11. package/commands/orchestrate/phase-3-dependency.md +465 -34
  12. package/commands/orchestrate/phase-4-execution.md +448 -29
  13. package/commands/orchestrate/phase-6-report.md +198 -9
  14. package/commands/pipeline-health/phase-2-metrics.md +305 -1
  15. package/commands/pipeline-health/phase-4-proposals.md +127 -0
  16. package/commands/quality-gate.md +417 -0
  17. package/commands/review-pr.md +391 -13
  18. package/commands/spec-doctor.md +429 -0
  19. package/commands/validate.md +52 -0
  20. package/commands/work-on/build/architect.md +77 -0
  21. package/commands/work-on/build/context.md +357 -22
  22. package/commands/work-on/build/implement.md +2 -0
  23. package/commands/work-on/build/validate.md +28 -0
  24. package/commands/work-on/build.md +44 -0
  25. package/commands/work-on/close.md +621 -8
  26. package/commands/work-on/investigate.md +221 -0
  27. package/commands/work-on.md +15 -14
  28. package/docs/FORGE-PROTOCOL.md +101 -0
  29. package/docs/design/awesome-list-submissions.md +105 -0
  30. package/docs/design/f5bot-monitoring-setup.md +43 -0
  31. package/docs/design/growth-strategy-2026.md +579 -0
  32. package/docs/spec/forge-protocol-v1.md +97 -5
  33. package/docs/spec/label-state-machine.md +6 -1
  34. package/package.json +2 -2
  35. package/packages/protocol/src/cli.js +374 -84
  36. package/packages/protocol/src/types.js +75 -1
  37. package/scripts/build-knowledge-index.mjs +1761 -0
  38. package/scripts/calibration.mjs +996 -0
  39. package/scripts/danger-zones.mjs +782 -0
  40. package/scripts/doctor-pipeline-state.sh +88 -2
  41. package/scripts/refresh-bot-token.sh +97 -0
  42. package/scripts/select-fix-targets.sh +234 -0
  43. package/scripts/transition-label.sh +157 -6
@@ -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,97 @@
1
+ #!/usr/bin/env bash
2
+ # SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios
3
+ # SPDX-License-Identifier: AGPL-3.0-or-later
4
+ #
5
+ # scripts/refresh-bot-token.sh — refresh the rapiercraft-forgedock[bot] installation token.
6
+ #
7
+ # Generates a JWT from the app's private key, exchanges it for a 1-hour
8
+ # installation token, and re-auths the gh CLI. Idempotent — safe to run
9
+ # on every session start or from cron.
10
+ #
11
+ # Supports two installations:
12
+ # RapierCraftStudios (org) — installation 144998831 (default)
13
+ # RapierCraft (personal) — installation 140233364
14
+ #
15
+ # Usage: scripts/refresh-bot-token.sh [--pem /path/to/key.pem] [--personal]
16
+ #
17
+ # Env: FORGEDOCK_APP_PEM — path to private key (default: secrets/rapiercraft-forgedock.pem)
18
+
19
+ set -euo pipefail
20
+
21
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
22
+ FORGE_HOME="$(cd "$SCRIPT_DIR/.." && pwd)"
23
+
24
+ APP_ID="4051319"
25
+ ORG_INSTALLATION_ID="144998831"
26
+ PERSONAL_INSTALLATION_ID="140233364"
27
+ DEFAULT_PEM="$FORGE_HOME/secrets/rapiercraft-forgedock.pem"
28
+
29
+ # --- Parse args ---
30
+ PEM_PATH="${FORGEDOCK_APP_PEM:-$DEFAULT_PEM}"
31
+ INSTALLATION_ID="$ORG_INSTALLATION_ID"
32
+ while [[ $# -gt 0 ]]; do
33
+ case "$1" in
34
+ --pem) PEM_PATH="$2"; shift 2 ;;
35
+ --personal) INSTALLATION_ID="$PERSONAL_INSTALLATION_ID"; shift ;;
36
+ *) echo "Unknown arg: $1" >&2; exit 1 ;;
37
+ esac
38
+ done
39
+
40
+ if [[ ! -f "$PEM_PATH" ]]; then
41
+ echo "ERROR: Private key not found at $PEM_PATH" >&2
42
+ echo " Set FORGEDOCK_APP_PEM or pass --pem /path/to/key.pem" >&2
43
+ exit 1
44
+ fi
45
+
46
+ # --- Generate JWT and exchange for installation token ---
47
+ INSTALL_TOKEN=$(node -e "
48
+ var crypto = require('crypto');
49
+ var fs = require('fs');
50
+ var https = require('https');
51
+ var pem = fs.readFileSync(process.argv[1], 'utf8');
52
+ var now = Math.floor(Date.now() / 1000);
53
+ var h = Buffer.from(JSON.stringify({alg:'RS256',typ:'JWT'})).toString('base64url');
54
+ var p = Buffer.from(JSON.stringify({iat:now-60,exp:now+(9*60),iss:process.argv[2]})).toString('base64url');
55
+ var s = crypto.createSign('RSA-SHA256');
56
+ s.update(h+'.'+p);
57
+ var jwt = h+'.'+p+'.'+s.sign(pem,'base64url');
58
+ var opts = {
59
+ hostname: 'api.github.com',
60
+ path: '/app/installations/'+process.argv[3]+'/access_tokens',
61
+ method: 'POST',
62
+ headers: {
63
+ 'Authorization': 'Bearer '+jwt,
64
+ 'Accept': 'application/vnd.github+json',
65
+ 'User-Agent': 'forgedock-refresh'
66
+ }
67
+ };
68
+ var req = https.request(opts, function(res) {
69
+ var body = '';
70
+ res.on('data', function(c) { body += c; });
71
+ res.on('end', function() {
72
+ var t = JSON.parse(body).token;
73
+ if (!t) { process.stderr.write('API error: '+body); process.exit(1); }
74
+ process.stdout.write(t);
75
+ });
76
+ });
77
+ req.end();
78
+ " "$PEM_PATH" "$APP_ID" "$INSTALLATION_ID")
79
+
80
+ if [[ -z "$INSTALL_TOKEN" ]]; then
81
+ echo "ERROR: Failed to obtain installation token" >&2
82
+ exit 1
83
+ fi
84
+
85
+ # --- Auth gh CLI ---
86
+ echo "$INSTALL_TOKEN" | gh auth login --with-token 2>/dev/null
87
+
88
+ # --- Clean up stale old bot if present ---
89
+ if gh auth status 2>&1 | grep -q "rapiercraft-forge\[bot\]"; then
90
+ gh auth logout -h github.com -u "rapiercraft-forge[bot]" 2>/dev/null || true
91
+ fi
92
+
93
+ if [[ "$INSTALLATION_ID" == "$ORG_INSTALLATION_ID" ]]; then
94
+ echo "rapiercraft-forgedock[bot] token refreshed — org (RapierCraftStudios) ~1h"
95
+ else
96
+ echo "rapiercraft-forgedock[bot] token refreshed — personal (RapierCraft) ~1h"
97
+ fi
@@ -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,29 +2,122 @@
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).
@@ -33,6 +126,7 @@ set -euo pipefail
33
126
  if [ "$#" -lt 2 ]; then
34
127
  echo "ERROR: Usage: transition-label.sh <ISSUE_NUMBER> [GH_FLAG...] <TARGET_STATE>" >&2
35
128
  echo " Example: transition-label.sh 674 -R RapierCraftStudios/forgedock investigating" >&2
129
+ echo " OR: transition-label.sh --validate CONFIRMED 674 -R owner/repo" >&2
36
130
  exit 1
37
131
  fi
38
132
 
@@ -65,6 +159,7 @@ VALID_STATES=(
65
159
  "merged"
66
160
  "invalid"
67
161
  "decomposed"
162
+ "awaiting-merge"
68
163
  )
69
164
 
70
165
  # ---------------------------------------------------------------------------
@@ -161,9 +256,65 @@ echo "Adding $EFFECTIVE_LABEL to issue #$ISSUE_NUMBER..."
161
256
  gh issue edit "$ISSUE_NUMBER" "${GH_ARGS[@]}" --add-label "$EFFECTIVE_LABEL"
162
257
 
163
258
  # ---------------------------------------------------------------------------
164
- # Remove all other workflow:* labels (best-effort — labels may not all exist)
259
+ # Remove all other workflow:* labels currently on the issue (best-effort)
260
+ #
261
+ # IMPORTANT: `gh issue edit --remove-label` is atomic across its whole
262
+ # comma-separated argument — if ANY label in the list is not a valid label
263
+ # on the repo (e.g. a newly added VALID_STATES entry like awaiting-merge
264
+ # whose repo-side `gh label create` / bootstrap hasn't run yet), the ENTIRE
265
+ # call fails with "not found" and — under `set -euo pipefail` without the
266
+ # `|| true` this used to silently swallow — no labels are removed at all,
267
+ # including ones that DO exist and SHOULD have been cleared (forge#1810
268
+ # follow-up: this exact bug was caught by dogfooding this script against
269
+ # the live repo before `workflow:awaiting-merge` had been bootstrapped).
270
+ #
271
+ # Fix: only ask to remove labels that are BOTH (a) in REMOVE_LABELS (valid
272
+ # states other than the target) AND (b) actually present on the issue right
273
+ # now. A label that was never applied to this issue can't be "not found" on
274
+ # the repo without failing the whole call, and a label the issue doesn't
275
+ # have doesn't need removing anyway — so intersecting against the issue's
276
+ # current labels sidesteps the all-or-nothing failure mode entirely instead
277
+ # of relying on `|| true` to mask it.
278
+ # ---------------------------------------------------------------------------
279
+ CURRENT_ISSUE_LABELS=$(gh issue view "$ISSUE_NUMBER" "${GH_ARGS[@]}" --json labels \
280
+ --jq '[.labels[].name] | join(",")' 2>/dev/null || echo "")
281
+
282
+ TO_REMOVE=""
283
+ IFS=',' read -ra REMOVE_CANDIDATES <<< "$REMOVE_LABELS"
284
+ for candidate in "${REMOVE_CANDIDATES[@]}"; do
285
+ case ",$CURRENT_ISSUE_LABELS," in
286
+ *",$candidate,"*)
287
+ TO_REMOVE="${TO_REMOVE:+$TO_REMOVE,}$candidate"
288
+ ;;
289
+ esac
290
+ done
291
+
292
+ if [ -n "$TO_REMOVE" ]; then
293
+ echo "Removing stale workflow:* labels present on the issue ($TO_REMOVE)..."
294
+ gh issue edit "$ISSUE_NUMBER" "${GH_ARGS[@]}" --remove-label "$TO_REMOVE" 2>/dev/null || true
295
+ else
296
+ echo "No stale workflow:* labels present on the issue — nothing to remove."
297
+ fi
298
+
299
+ # ---------------------------------------------------------------------------
300
+ # Clear needs-human (best-effort)
301
+ #
302
+ # needs-human is NOT a workflow:* label, so it is never included in
303
+ # VALID_STATES/REMOVE_LABELS above. Historically no code path ever cleared it
304
+ # (forge#1809/#1810) — it was a write-only, sticky, terminal label even after
305
+ # the pipeline made forward progress past the condition that set it.
306
+ #
307
+ # This script only runs at explicit forward-progress transition points (the
308
+ # dispatcher STOPs on needs-human before ever reaching a transition-label.sh
309
+ # call in normal operation — see commands/work-on.md's terminal-state checks),
310
+ # so clearing needs-human here is safe: it only fires when something has
311
+ # deliberately decided to move the issue/PR to a new state, most notably
312
+ # workflow:awaiting-merge (a remediated + re-reviewed PR moving off
313
+ # needs-human without yet meeting the auto-land bar — see #1809 Q1).
314
+ # Best-effort: `|| true` so a missing label never fails the script under
315
+ # `set -euo pipefail`.
165
316
  # ---------------------------------------------------------------------------
166
- echo "Removing stale workflow:* labels ($REMOVE_LABELS)..."
167
- gh issue edit "$ISSUE_NUMBER" "${GH_ARGS[@]}" --remove-label "$REMOVE_LABELS" 2>/dev/null || true
317
+ echo "Clearing needs-human (best-effort)..."
318
+ gh issue edit "$ISSUE_NUMBER" "${GH_ARGS[@]}" --remove-label "needs-human" 2>/dev/null || true
168
319
 
169
320
  echo "OK: $EFFECTIVE_LABEL set on issue #$ISSUE_NUMBER"