forgedock 1.1.2 → 1.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,577 @@
1
+ ---
2
+ description: Automated CI failure resolution loop — diagnoses failures on a PR, applies targeted fixes, pushes, and loops until green or max attempts reached.
3
+ argument-hint: <PR number or URL> [--max-attempts N] [--repo owner/repo]
4
+ ---
5
+ <!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
6
+ <!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
7
+
8
+ # /fix-ci — Automated CI Failure Resolution
9
+
10
+ **Input**: $ARGUMENTS
11
+
12
+ **Invoked by**: `/deploy-pr` (CI gate step) or directly by a user when a PR's CI is failing.
13
+
14
+ **Output**: Structured result — `{ pr, status, attempts, fixes_applied }` — printed to stdout and available to the calling skill.
15
+
16
+ **Agent model policy**: `model: "sonnet"` (standard tier). Fallback: `model: "opus"` if rate-limited. User can override with `--model <name>`. Feature gate: pass `effort` in Task/Skill spawns only on Claude Code >= 2.1.154.
17
+ **NEVER use plan mode (EnterPlanMode).**
18
+ **NEVER force-push or amend commits** — always create new commits.
19
+
20
+ <!-- FORGE:SPEC_LOADED — fix-ci.md loaded and active. Agent is bound by this spec. -->
21
+
22
+ ---
23
+
24
+ ## Config Preamble
25
+
26
+ Read `forge.yaml` before running any phase:
27
+
28
+ ```bash
29
+ CONFIG_FILE="${FORGE_CONFIG:-forge.yaml}"
30
+ if [ ! -f "$CONFIG_FILE" ]; then
31
+ echo "ERROR: forge.yaml not found. Run: npx forgedock init"
32
+ exit 1
33
+ fi
34
+ GH_REPO=$(yq '.project.owner + "/" + .project.repo' "$CONFIG_FILE")
35
+ GH_FLAG="-R ${GH_REPO}"
36
+ REPO_PATH=$(yq '.paths.root' "$CONFIG_FILE")
37
+ STAGING_BRANCH=$(yq '.branches.staging // "staging"' "$CONFIG_FILE")
38
+ ```
39
+
40
+ ## Argument Parsing
41
+
42
+ Parse `$ARGUMENTS`:
43
+
44
+ | Argument | Variable | Default |
45
+ |----------|----------|---------|
46
+ | PR number or URL | `PR_NUMBER` | (required) |
47
+ | `--max-attempts N` | `MAX_ATTEMPTS` | `5` |
48
+ | `--repo owner/repo` | `GH_REPO` | from forge.yaml |
49
+
50
+ ```bash
51
+ # Extract PR number (accept raw number or full GitHub URL)
52
+ PR_INPUT=$(echo "$ARGUMENTS" | awk '{print $1}')
53
+ if echo "$PR_INPUT" | grep -qE '^https?://'; then
54
+ PR_NUMBER=$(echo "$PR_INPUT" | grep -oE '[0-9]+$')
55
+ else
56
+ PR_NUMBER=$(echo "$PR_INPUT" | tr -d '#')
57
+ fi
58
+
59
+ MAX_ATTEMPTS=5
60
+ if echo "$ARGUMENTS" | grep -q -- "--max-attempts"; then
61
+ MAX_ATTEMPTS=$(echo "$ARGUMENTS" | grep -oE -- '--max-attempts [0-9]+' | awk '{print $2}')
62
+ fi
63
+
64
+ if [ -z "$PR_NUMBER" ]; then
65
+ echo "ERROR: PR number or URL is required."
66
+ echo "Usage: /fix-ci <PR number or URL> [--max-attempts N]"
67
+ exit 1
68
+ fi
69
+
70
+ echo "fix-ci: PR #${PR_NUMBER}, max attempts: ${MAX_ATTEMPTS}"
71
+ ```
72
+
73
+ ---
74
+
75
+ ## Phase 0: Assess the PR
76
+
77
+ ### 0A: Load PR state
78
+
79
+ ```bash
80
+ PR_DATA=$(gh pr view "$PR_NUMBER" $GH_FLAG \
81
+ --json number,title,headRefName,baseRefName,state,isDraft,url,headRepository 2>/dev/null)
82
+
83
+ if [ -z "$PR_DATA" ]; then
84
+ echo "ERROR: PR #${PR_NUMBER} not found or not accessible."
85
+ exit 1
86
+ fi
87
+
88
+ PR_HEAD=$(echo "$PR_DATA" | jq -r '.headRefName')
89
+ PR_BASE=$(echo "$PR_DATA" | jq -r '.baseRefName')
90
+ PR_STATE=$(echo "$PR_DATA" | jq -r '.state')
91
+ PR_URL=$(echo "$PR_DATA" | jq -r '.url')
92
+
93
+ echo "PR #${PR_NUMBER}: ${PR_HEAD} → ${PR_BASE} (${PR_STATE})"
94
+
95
+ if [ "$PR_STATE" = "MERGED" ]; then
96
+ echo "PR #${PR_NUMBER} is already merged — nothing to fix."
97
+ echo '{"pr": '"$PR_NUMBER"', "status": "already_merged", "attempts": 0, "fixes_applied": []}'
98
+ exit 0
99
+ fi
100
+
101
+ if [ "$PR_STATE" = "CLOSED" ]; then
102
+ echo "PR #${PR_NUMBER} is closed — cannot fix CI on a closed PR."
103
+ exit 1
104
+ fi
105
+ ```
106
+
107
+ ### 0B: Determine fix target branch
108
+
109
+ The fix target is the branch the PR's **head** points to. CI fixes are committed directly to the PR's head branch — not to the base branch.
110
+
111
+ ```bash
112
+ # Smart target detection: where do fixes land?
113
+ # - staging → main PRs: fixes go to staging (the head branch)
114
+ # - milestone/* → staging: fixes go to the milestone branch (the head branch)
115
+ # - feature/* PRs: fixes go to the feature branch (the head branch)
116
+ # In all cases: FIX_TARGET = PR_HEAD (the PR's source branch)
117
+ FIX_TARGET="$PR_HEAD"
118
+ echo "Fix target branch: ${FIX_TARGET}"
119
+
120
+ # HARD RULE: never target main directly
121
+ if [ "$FIX_TARGET" = "main" ] || [ "$FIX_TARGET" = "master" ]; then
122
+ echo "ERROR: fix-ci refuses to commit fixes directly to '${FIX_TARGET}'."
123
+ echo "This PR's head branch is a protected branch — something is wrong with the PR setup."
124
+ exit 1
125
+ fi
126
+ ```
127
+
128
+ ### 0C: Check initial CI status
129
+
130
+ ```bash
131
+ LATEST_RUN=$(gh run list $GH_FLAG --branch "$PR_HEAD" --limit 1 \
132
+ --json databaseId,status,conclusion,name,createdAt 2>/dev/null | jq '.[0] // empty')
133
+
134
+ if [ -z "$LATEST_RUN" ]; then
135
+ echo "No CI runs found for branch ${PR_HEAD} — CI may not have triggered yet."
136
+ echo "Waiting up to 60s for CI to start..."
137
+ sleep 15
138
+ LATEST_RUN=$(gh run list $GH_FLAG --branch "$PR_HEAD" --limit 1 \
139
+ --json databaseId,status,conclusion,name,createdAt 2>/dev/null | jq '.[0] // empty')
140
+ fi
141
+
142
+ RUN_STATUS=$(echo "$LATEST_RUN" | jq -r '.status // "unknown"')
143
+ RUN_CONCLUSION=$(echo "$LATEST_RUN" | jq -r '.conclusion // "unknown"')
144
+ RUN_ID=$(echo "$LATEST_RUN" | jq -r '.databaseId // ""')
145
+
146
+ echo "Latest CI run ${RUN_ID}: status=${RUN_STATUS}, conclusion=${RUN_CONCLUSION}"
147
+
148
+ if [ "$RUN_CONCLUSION" = "success" ]; then
149
+ echo "CI is already green on PR #${PR_NUMBER} — no fixes needed."
150
+ echo '{"pr": '"$PR_NUMBER"', "status": "success", "attempts": 0, "fixes_applied": []}'
151
+ exit 0
152
+ fi
153
+ ```
154
+
155
+ ---
156
+
157
+ ## Phase 1: CI Fix Loop
158
+
159
+ Initialize tracking state:
160
+
161
+ ```bash
162
+ ATTEMPT=0
163
+ FIXES_APPLIED="[]"
164
+ LOOP_STATUS="pending"
165
+
166
+ cd "$REPO_PATH"
167
+ git fetch origin
168
+ ```
169
+
170
+ Execute up to `MAX_ATTEMPTS` iterations:
171
+
172
+ ```
173
+ while ATTEMPT < MAX_ATTEMPTS:
174
+ ATTEMPT += 1
175
+ echo "=== fix-ci: Attempt ${ATTEMPT}/${MAX_ATTEMPTS} ==="
176
+
177
+ → Phase 1A: Wait for CI to finish
178
+ → Phase 1B: Check CI result — if green, exit loop with success
179
+ → Phase 1C: Diagnose failure
180
+ → Phase 1D: Apply fix
181
+ → Phase 1E: Format, commit, push
182
+ → Loop back (CI re-triggers on new commit)
183
+
184
+ if loop exits without success AND ATTEMPT == MAX_ATTEMPTS:
185
+ → Phase 2: Report max-attempts failure
186
+ ```
187
+
188
+ ### Phase 1A: Wait for CI
189
+
190
+ Poll the latest run on the PR's head branch. Poll every 20 seconds, timeout at 10 minutes.
191
+
192
+ ```bash
193
+ POLL_TIMEOUT=600 # 10 minutes
194
+ POLL_START=$(date +%s)
195
+ POLL_INTERVAL=20
196
+
197
+ echo "Waiting for CI run to complete (timeout: ${POLL_TIMEOUT}s)..."
198
+
199
+ while true; do
200
+ CURRENT_RUN=$(gh run list $GH_FLAG --branch "$PR_HEAD" --limit 1 \
201
+ --json databaseId,status,conclusion 2>/dev/null | jq '.[0] // empty')
202
+
203
+ CURRENT_STATUS=$(echo "$CURRENT_RUN" | jq -r '.status // "unknown"')
204
+ CURRENT_CONCLUSION=$(echo "$CURRENT_RUN" | jq -r '.conclusion // "unknown"')
205
+ CURRENT_RUN_ID=$(echo "$CURRENT_RUN" | jq -r '.databaseId // ""')
206
+
207
+ if [ "$CURRENT_STATUS" = "completed" ]; then
208
+ echo "CI run ${CURRENT_RUN_ID} completed: ${CURRENT_CONCLUSION}"
209
+ break
210
+ fi
211
+
212
+ NOW=$(date +%s)
213
+ ELAPSED=$((NOW - POLL_START))
214
+ if [ "$ELAPSED" -ge "$POLL_TIMEOUT" ]; then
215
+ echo "Timeout waiting for CI after ${POLL_TIMEOUT}s — run ${CURRENT_RUN_ID} is still ${CURRENT_STATUS}"
216
+ LOOP_STATUS="timeout"
217
+ break
218
+ fi
219
+
220
+ echo " CI run ${CURRENT_RUN_ID} is ${CURRENT_STATUS} (${ELAPSED}s elapsed) — polling again in ${POLL_INTERVAL}s..."
221
+ sleep "$POLL_INTERVAL"
222
+ done
223
+
224
+ RUN_CONCLUSION="$CURRENT_CONCLUSION"
225
+ RUN_ID="$CURRENT_RUN_ID"
226
+ ```
227
+
228
+ ### Phase 1B: Check CI result
229
+
230
+ ```bash
231
+ if [ "$RUN_CONCLUSION" = "success" ]; then
232
+ echo "CI is green after ${ATTEMPT} attempt(s)."
233
+ LOOP_STATUS="success"
234
+ break # exit the fix loop
235
+ fi
236
+
237
+ if [ "$LOOP_STATUS" = "timeout" ]; then
238
+ break # exit the fix loop — reported in Phase 2
239
+ fi
240
+
241
+ echo "CI failed (run ${RUN_ID}, conclusion: ${RUN_CONCLUSION}) — diagnosing..."
242
+ ```
243
+
244
+ ### Phase 1C: Diagnose the failure
245
+
246
+ Download failed job logs and pattern-match against known failure classes.
247
+
248
+ ```bash
249
+ # Download logs from the failed run
250
+ RUN_LOG=$(gh run view "$RUN_ID" $GH_FLAG --log-failed 2>/dev/null | head -500)
251
+
252
+ if [ -z "$RUN_LOG" ]; then
253
+ echo "WARNING: Could not retrieve logs for run ${RUN_ID}"
254
+ RUN_LOG="(logs unavailable)"
255
+ fi
256
+
257
+ echo "--- Failed run log (first 500 lines) ---"
258
+ echo "$RUN_LOG" | head -100
259
+ echo "---"
260
+ ```
261
+
262
+ **Pattern-match against known failure classes** (in priority order — first match wins):
263
+
264
+ ```bash
265
+ FAILURE_CLASS="unknown"
266
+ FAILURE_DETAIL=""
267
+
268
+ # 1. Format errors (prettier, black, ruff, eslint --fix-dry-run)
269
+ if echo "$RUN_LOG" | grep -qiE "(formatting|format check|prettier|black|ruff.*would reformat|eslint.*fixable)"; then
270
+ FAILURE_CLASS="format"
271
+ FAILURE_DETAIL=$(echo "$RUN_LOG" | grep -iE "(formatting|would reformat|fixable)" | head -5)
272
+
273
+ # 2. Import/module errors (missing imports, circular imports, unresolved modules)
274
+ elif echo "$RUN_LOG" | grep -qiE "(ImportError|ModuleNotFoundError|Cannot find module|import.*error|no module named)"; then
275
+ FAILURE_CLASS="import"
276
+ FAILURE_DETAIL=$(echo "$RUN_LOG" | grep -iE "(ImportError|ModuleNotFoundError|Cannot find module|no module named)" | head -5)
277
+
278
+ # 3. Syntax errors (Python, JS/TS, YAML)
279
+ elif echo "$RUN_LOG" | grep -qiE "(SyntaxError|syntax error|Unexpected token|unexpected end of|ParseError)"; then
280
+ FAILURE_CLASS="syntax"
281
+ FAILURE_DETAIL=$(echo "$RUN_LOG" | grep -iE "(SyntaxError|syntax error|Unexpected token|ParseError)" | head -5)
282
+
283
+ # 4. TypeScript/type errors
284
+ elif echo "$RUN_LOG" | grep -qiE "(TS[0-9]+:|type error|TypeScript error|tsc.*error|type '.*' is not assignable)"; then
285
+ FAILURE_CLASS="types"
286
+ FAILURE_DETAIL=$(echo "$RUN_LOG" | grep -iE "(TS[0-9]+:|type error|tsc.*error)" | head -5)
287
+
288
+ # 5. Test failures (pytest, jest, mocha)
289
+ elif echo "$RUN_LOG" | grep -qiE "(FAILED|test.*failed|AssertionError|expect.*received|FAIL .*\.test\.)"; then
290
+ FAILURE_CLASS="tests"
291
+ FAILURE_DETAIL=$(echo "$RUN_LOG" | grep -iE "(FAILED|AssertionError|expect.*received)" | head -5)
292
+
293
+ # 6. Build errors (webpack, vite, esbuild, cargo, maven)
294
+ elif echo "$RUN_LOG" | grep -qiE "(Build failed|build error|compilation failed|error\[E[0-9]+\]|BUILD FAILURE)"; then
295
+ FAILURE_CLASS="build"
296
+ FAILURE_DETAIL=$(echo "$RUN_LOG" | grep -iE "(Build failed|compilation failed|BUILD FAILURE)" | head -5)
297
+
298
+ # 7. Linting errors (eslint, pylint, flake8)
299
+ elif echo "$RUN_LOG" | grep -qiE "(eslint.*error|pylint|flake8|linting.*error|lint.*fail)"; then
300
+ FAILURE_CLASS="lint"
301
+ FAILURE_DETAIL=$(echo "$RUN_LOG" | grep -iE "(eslint|pylint|flake8)" | head -5)
302
+
303
+ # 8. YAML/config validation errors
304
+ elif echo "$RUN_LOG" | grep -qiE "(yaml.*error|YAML parse|invalid yaml|config.*invalid|schema.*error)"; then
305
+ FAILURE_CLASS="config"
306
+ FAILURE_DETAIL=$(echo "$RUN_LOG" | grep -iE "(yaml.*error|config.*invalid|schema.*error)" | head -5)
307
+ fi
308
+
309
+ echo "Failure class: ${FAILURE_CLASS}"
310
+ echo "Failure detail: ${FAILURE_DETAIL}"
311
+ ```
312
+
313
+ ### Phase 1D: Apply targeted fix
314
+
315
+ Work on the PR's head branch directly. Check out the branch in the repo root (not a worktree — we push to the existing PR head):
316
+
317
+ ```bash
318
+ git checkout "$FIX_TARGET" 2>/dev/null || git checkout -b "$FIX_TARGET" "origin/${FIX_TARGET}" 2>/dev/null
319
+ git pull origin "$FIX_TARGET" --rebase 2>/dev/null || true
320
+ ```
321
+
322
+ **Apply fix based on failure class**:
323
+
324
+ ```
325
+ case FAILURE_CLASS:
326
+
327
+ "format":
328
+ Read forge.yaml → verification.commands for configured formatters.
329
+ Run all configured formatters:
330
+ PYTHON_FORMAT=$(yq '.verification.commands.python.format // ""' forge.yaml 2>/dev/null)
331
+ TS_FORMAT=$(yq '.verification.commands.typescript.format // ""' forge.yaml 2>/dev/null)
332
+ [ -n "$PYTHON_FORMAT" ] && eval "$PYTHON_FORMAT" 2>&1 || true
333
+ [ -n "$TS_FORMAT" ] && eval "$TS_FORMAT" 2>&1 || true
334
+ If no formatters configured, try auto-detection:
335
+ - Python files changed → try: black . 2>/dev/null || ruff format . 2>/dev/null || true
336
+ - JS/TS files changed → try: npx prettier --write . 2>/dev/null || true
337
+ FIX_DESCRIPTION="auto-format: run configured formatters"
338
+
339
+ "import":
340
+ Read FAILURE_DETAIL to identify the missing module name.
341
+ Search the codebase for the module to determine if it is:
342
+ a) A typo in the import → fix the import path
343
+ b) A missing dependency in package.json / requirements.txt → add it
344
+ c) A file that was moved or deleted → update the import path
345
+ Apply the targeted fix with Read + Edit tools.
346
+ FIX_DESCRIPTION="fix import: ${FAILURE_DETAIL}"
347
+
348
+ "syntax":
349
+ Read FAILURE_DETAIL to identify the file and line number.
350
+ Read the affected file with the Read tool.
351
+ Apply targeted fix for the syntax error (missing comma, bracket, quote, etc.).
352
+ FIX_DESCRIPTION="fix syntax error: ${FAILURE_DETAIL}"
353
+
354
+ "types":
355
+ Read FAILURE_DETAIL to extract TS error code and location (file:line).
356
+ Read the affected file.
357
+ Apply targeted type fix: add missing type annotation, correct mismatched type,
358
+ add type assertion, or widen/narrow the type as appropriate.
359
+ FIX_DESCRIPTION="fix type error: ${FAILURE_DETAIL}"
360
+
361
+ "tests":
362
+ Read FAILURE_DETAIL to find failing test name and assertion.
363
+ Read the test file and the source file it tests.
364
+ Determine if the failure is:
365
+ a) A test expectation that needs updating (API changed) → update the expectation
366
+ b) A regression in source code → fix the source code
367
+ NEVER delete or skip a failing test without understanding the root cause.
368
+ FIX_DESCRIPTION="fix failing test: ${FAILURE_DETAIL}"
369
+
370
+ "build":
371
+ Read full build log for the specific error message and file.
372
+ Read the affected file.
373
+ Fix the build error: resolve missing dependency, fix webpack config,
374
+ resolve circular import, fix incompatible API usage.
375
+ FIX_DESCRIPTION="fix build error: ${FAILURE_DETAIL}"
376
+
377
+ "lint":
378
+ Read FAILURE_DETAIL to identify specific lint rule and file.
379
+ Read the affected file.
380
+ Apply targeted lint fix respecting the project's lint configuration.
381
+ Do NOT use disable comments unless the lint rule is a false positive.
382
+ FIX_DESCRIPTION="fix lint error: ${FAILURE_DETAIL}"
383
+
384
+ "config":
385
+ Read FAILURE_DETAIL to identify the config file and the parse error.
386
+ Read the config file.
387
+ Fix the YAML/JSON/TOML syntax error or schema violation.
388
+ FIX_DESCRIPTION="fix config error: ${FAILURE_DETAIL}"
389
+
390
+ "unknown":
391
+ Read the full log (first 200 lines) and attempt to identify the error manually.
392
+ If the error is identifiable: apply a targeted fix and describe it.
393
+ If the error is not fixable automatically:
394
+ LOOP_STATUS="undiagnosable"
395
+ break # exit the fix loop — Phase 2 will report
396
+ FIX_DESCRIPTION="fix unclassified CI error (see log)"
397
+ ```
398
+
399
+ Record the fix applied:
400
+ ```bash
401
+ FIX_ENTRY=$(jq -nc \
402
+ --arg attempt "$ATTEMPT" \
403
+ --arg class "$FAILURE_CLASS" \
404
+ --arg desc "$FIX_DESCRIPTION" \
405
+ --arg run_id "$RUN_ID" \
406
+ '{"attempt": ($attempt|tonumber), "failure_class": $class, "description": $desc, "run_id": $run_id}')
407
+ FIXES_APPLIED=$(echo "$FIXES_APPLIED" | jq ". + [$FIX_ENTRY]")
408
+ ```
409
+
410
+ ### Phase 1E: Format, commit, push
411
+
412
+ **Format** (always run before committing, regardless of failure class):
413
+
414
+ ```bash
415
+ # Run formatters unconditionally before commit
416
+ PYTHON_FORMAT=$(yq '.verification.commands.python.format // ""' forge.yaml 2>/dev/null || echo '')
417
+ TS_FORMAT=$(yq '.verification.commands.typescript.format // ""' forge.yaml 2>/dev/null || echo '')
418
+
419
+ [ -n "$PYTHON_FORMAT" ] && eval "$PYTHON_FORMAT" 2>&1 | tail -5 || true
420
+ [ -n "$TS_FORMAT" ] && eval "$TS_FORMAT" 2>&1 | tail -5 || true
421
+ ```
422
+
423
+ **Commit** (new commit only — no amend, no force-push):
424
+
425
+ ```bash
426
+ git add -u
427
+ CHANGED=$(git diff --cached --name-only)
428
+
429
+ if [ -z "$CHANGED" ]; then
430
+ echo "No changes after fix attempt ${ATTEMPT} — fix did not produce a diff."
431
+ echo "This may indicate the fix was already applied or the failure class is misdiagnosed."
432
+ LOOP_STATUS="no_diff"
433
+ break
434
+ fi
435
+
436
+ git commit -s -m "fix(ci): attempt ${ATTEMPT}/${MAX_ATTEMPTS} — ${FIX_DESCRIPTION} (PR #${PR_NUMBER})"
437
+ echo "Committed fix for attempt ${ATTEMPT}"
438
+ ```
439
+
440
+ **Push** (normal push only — no force):
441
+
442
+ ```bash
443
+ git push origin "$FIX_TARGET" 2>&1
444
+ PUSH_EXIT=$?
445
+
446
+ if [ "$PUSH_EXIT" -ne 0 ]; then
447
+ echo "Push failed (exit ${PUSH_EXIT}) — attempting pull-rebase then re-push..."
448
+ git pull origin "$FIX_TARGET" --rebase 2>&1 && \
449
+ git push origin "$FIX_TARGET" 2>&1
450
+ PUSH_EXIT=$?
451
+ fi
452
+
453
+ if [ "$PUSH_EXIT" -ne 0 ]; then
454
+ echo "ERROR: Push failed after retry. Manual intervention required."
455
+ LOOP_STATUS="push_failed"
456
+ break
457
+ fi
458
+
459
+ echo "Pushed fix commit for attempt ${ATTEMPT} — CI will re-trigger on ${FIX_TARGET}"
460
+ ```
461
+
462
+ **Wait briefly for CI to pick up the new commit** before looping:
463
+
464
+ ```bash
465
+ sleep 10
466
+ ```
467
+
468
+ → Loop back to Phase 1A with `ATTEMPT += 1`.
469
+
470
+ ---
471
+
472
+ ## Phase 2: Result Report
473
+
474
+ After the loop exits, determine the final status and report.
475
+
476
+ ### 2A: Determine final status
477
+
478
+ ```bash
479
+ case "$LOOP_STATUS" in
480
+ "success")
481
+ FINAL_STATUS="success"
482
+ FINAL_MESSAGE="CI is green after ${ATTEMPT} fix attempt(s)."
483
+ ;;
484
+ "timeout")
485
+ FINAL_STATUS="timeout"
486
+ FINAL_MESSAGE="CI did not complete within the polling timeout (${POLL_TIMEOUT}s) on attempt ${ATTEMPT}."
487
+ ;;
488
+ "no_diff")
489
+ FINAL_STATUS="blocked"
490
+ FINAL_MESSAGE="Fix attempt ${ATTEMPT} produced no diff — the failure class '${FAILURE_CLASS}' may require manual intervention."
491
+ ;;
492
+ "push_failed")
493
+ FINAL_STATUS="blocked"
494
+ FINAL_MESSAGE="Push failed on attempt ${ATTEMPT} — manual intervention required."
495
+ ;;
496
+ "undiagnosable")
497
+ FINAL_STATUS="blocked"
498
+ FINAL_MESSAGE="CI failure on attempt ${ATTEMPT} could not be automatically diagnosed. Full log required."
499
+ ;;
500
+ "pending"|*)
501
+ # Loop exhausted MAX_ATTEMPTS without success
502
+ FINAL_STATUS="max_attempts"
503
+ FINAL_MESSAGE="CI still failing after ${MAX_ATTEMPTS} fix attempt(s). Manual intervention required."
504
+ ;;
505
+ esac
506
+ ```
507
+
508
+ ### 2B: Post result to PR
509
+
510
+ Post a comment on the PR summarizing what was attempted:
511
+
512
+ ```bash
513
+ REPORT_TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
514
+ FIXES_SUMMARY=$(echo "$FIXES_APPLIED" | jq -r '.[] | "- Attempt \(.attempt): \(.failure_class) — \(.description) (run \(.run_id))"')
515
+
516
+ gh pr comment "$PR_NUMBER" $GH_FLAG --body "<!-- FORGE:FIX_CI -->
517
+ ## /fix-ci Result
518
+
519
+ **Status**: \`${FINAL_STATUS}\`
520
+ **Attempts**: ${ATTEMPT}/${MAX_ATTEMPTS}
521
+ **Branch**: \`${FIX_TARGET}\`
522
+ **Timestamp**: ${REPORT_TIMESTAMP}
523
+
524
+ ### Summary
525
+ ${FINAL_MESSAGE}
526
+
527
+ ### Fixes Applied
528
+ ${FIXES_SUMMARY:-_No fixes applied_}
529
+
530
+ $([ "$FINAL_STATUS" != "success" ] && echo "### Next Steps
531
+ The CI failure was not resolved automatically. Recommended actions:
532
+ 1. Review the latest CI run logs: \`gh run view --log-failed -R ${GH_REPO}\`
533
+ 2. Check the failure class: \`${FAILURE_CLASS}\`
534
+ 3. Apply a manual fix and push to \`${FIX_TARGET}\`
535
+ 4. Re-run \`/fix-ci ${PR_NUMBER}\` after the manual fix")"
536
+ ```
537
+
538
+ ### 2C: Return structured result
539
+
540
+ Print the machine-readable result to stdout for the calling skill (`/deploy-pr`, `/autopilot`):
541
+
542
+ ```bash
543
+ RESULT=$(jq -nc \
544
+ --argjson pr "$PR_NUMBER" \
545
+ --arg status "$FINAL_STATUS" \
546
+ --argjson attempts "$ATTEMPT" \
547
+ --argjson fixes_applied "$FIXES_APPLIED" \
548
+ '{pr: $pr, status: $status, attempts: $attempts, fixes_applied: $fixes_applied}')
549
+
550
+ echo ""
551
+ echo "=== fix-ci result ==="
552
+ echo "$RESULT"
553
+ echo "====================="
554
+
555
+ # Exit with success (0) only if CI is green; non-zero for all other terminal states
556
+ if [ "$FINAL_STATUS" = "success" ]; then
557
+ exit 0
558
+ else
559
+ exit 1
560
+ fi
561
+ ```
562
+
563
+ ---
564
+
565
+ ## Error Handling
566
+
567
+ | Condition | Behavior |
568
+ |-----------|----------|
569
+ | PR not found | Error with message, exit 1 |
570
+ | PR already merged | Report `already_merged`, exit 0 |
571
+ | PR is closed | Error with message, exit 1 |
572
+ | Fix target is `main`/`master` | Hard error — refuses to commit to protected branch |
573
+ | CI timeout | Reports `timeout` status, exits 1 |
574
+ | Push fails after retry | Reports `blocked` status, exits 1 |
575
+ | Unknown failure class | Attempts manual diagnosis; if undiagnosable, reports `blocked` |
576
+ | Max attempts reached | Reports `max_attempts`, exits 1 |
577
+ | No diff after fix | Reports `blocked` with hint, exits 1 |