forgedock 1.1.2 → 1.1.3
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/bin/engine/phases.mjs +8 -6
- package/bin/runner.mjs +3 -2
- package/commands/autopilot.md +510 -425
- package/commands/deploy-pr.md +485 -0
- package/commands/fix-ci.md +577 -0
- package/commands/recover-orphans.md +576 -0
- package/docs/CI.md +14 -0
- package/docs/eval/README.md +7 -4
- package/docs/site/command-reference.md +49 -0
- package/package.json +1 -1
- package/packages/protocol/src/parse.js +27 -2
|
@@ -0,0 +1,485 @@
|
|
|
1
|
+
---
|
|
2
|
+
description: Ship a branch to its deploy target — detect or create PR, fix CI, review, merge. Returns structured result for callers.
|
|
3
|
+
argument-hint: [source_branch | "staging" | "milestone/{slug}"] [--target TARGET] [--issue ISSUE_NUMBER] [--repo REPO] [--max-ci-iterations N] [--max-review-iterations N] [--dry-run]
|
|
4
|
+
allowed-tools: Bash, Read, Grep, Glob, Skill
|
|
5
|
+
---
|
|
6
|
+
<!-- SPDX-FileCopyrightText: Copyright (c) RapierCraft Studios -->
|
|
7
|
+
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
|
|
8
|
+
|
|
9
|
+
# /deploy-pr — PR Ship Orchestrator
|
|
10
|
+
|
|
11
|
+
**Input**: $ARGUMENTS
|
|
12
|
+
|
|
13
|
+
**NEVER use plan mode (EnterPlanMode)** — it breaks execution context.
|
|
14
|
+
**NEVER use the Agent tool** — deploy-pr dispatches sub-skills via `Skill(...)` only. The Agent tool bypasses the allowed-tools constraint and produces opaque output that cannot be structured into the deploy result.
|
|
15
|
+
|
|
16
|
+
**Agent model policy**: `model: "sonnet"` (standard tier). Fallback: `model: "opus"` if rate-limited. User can override with `--model <name>`.
|
|
17
|
+
|
|
18
|
+
<!-- FORGE:SPEC_LOADED — deploy-pr.md loaded and active. Agent is bound by this spec. -->
|
|
19
|
+
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
## What This Command Does
|
|
23
|
+
|
|
24
|
+
`/deploy-pr` is a **sub-skill** designed to be invoked by callers like `/autopilot` that need to ship a branch without consuming their own context window. It handles the full PR shipping lifecycle:
|
|
25
|
+
|
|
26
|
+
1. Detect or create the PR for the source→target pair
|
|
27
|
+
2. Run the CI gate — fix failures via `/fix-ci` (loop up to max iterations)
|
|
28
|
+
3. Run the review gate — via `/review-pr` (fix + re-review loop up to max iterations)
|
|
29
|
+
4. Merge the PR after both gates pass
|
|
30
|
+
5. Return a structured result to the caller
|
|
31
|
+
|
|
32
|
+
**Never force-merges.** If CI or review fail after maximum iterations, the command stops and reports the blocking state without merging.
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Forbidden Tools Self-Check
|
|
37
|
+
|
|
38
|
+
**Before executing any phase**, verify you are NOT using any of these tools:
|
|
39
|
+
|
|
40
|
+
| Tool | Status | Reason |
|
|
41
|
+
|------|--------|--------|
|
|
42
|
+
| `Agent` | **FORBIDDEN** | Bypasses allowed-tools; produces opaque output that cannot feed the structured result |
|
|
43
|
+
| `EnterPlanMode` | **FORBIDDEN** | Breaks execution context; phases must be executed, not planned |
|
|
44
|
+
|
|
45
|
+
---
|
|
46
|
+
|
|
47
|
+
## Phase 0: Config Resolution & Argument Parsing
|
|
48
|
+
|
|
49
|
+
### 0A: Read forge.yaml
|
|
50
|
+
|
|
51
|
+
Resolve all config variables before any logic runs. This prevents unresolved `{PLACEHOLDER}` values in bash blocks — a known failure mode on deploy-info.md (issues #318, #1392).
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
CONFIG_FILE="${FORGE_CONFIG:-forge.yaml}"
|
|
55
|
+
|
|
56
|
+
GH_REPO=$(yq '.project.owner + "/" + .project.repo' "$CONFIG_FILE" 2>/dev/null)
|
|
57
|
+
GH_FLAG="-R $GH_REPO"
|
|
58
|
+
STAGING_BRANCH=$(yq '.branches.staging // "staging"' "$CONFIG_FILE" 2>/dev/null || echo "staging")
|
|
59
|
+
DEFAULT_BRANCH=$(yq '.branches.default // "main"' "$CONFIG_FILE" 2>/dev/null || echo "main")
|
|
60
|
+
REPO_PATH=$(yq '.paths.root' "$CONFIG_FILE" 2>/dev/null || echo "$(git rev-parse --show-toplevel)")
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
### 0B: Parse Arguments
|
|
64
|
+
|
|
65
|
+
```
|
|
66
|
+
$ARGUMENTS format examples:
|
|
67
|
+
staging
|
|
68
|
+
milestone/my-feature
|
|
69
|
+
feat/my-branch --target staging
|
|
70
|
+
staging --issue 1234 --repo owner/repo --max-ci-iterations 2 --max-review-iterations 3
|
|
71
|
+
staging --dry-run
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Parse:
|
|
75
|
+
- `SOURCE` — first positional arg, or `staging` if omitted
|
|
76
|
+
- `--target TARGET` — explicit PR target override (skips routing table)
|
|
77
|
+
- `--issue ISSUE_NUMBER` — parent issue to reference in PR body (optional)
|
|
78
|
+
- `--repo REPO` — override GH_REPO (optional — forge.yaml takes precedence if absent)
|
|
79
|
+
- `--max-ci-iterations N` — max CI fix attempts, default 3
|
|
80
|
+
- `--max-review-iterations N` — max review + fix cycles, default 3
|
|
81
|
+
- `--dry-run` — run all phases but do NOT merge; print what would happen
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
SOURCE="${1:-staging}"
|
|
85
|
+
TARGET="" # resolved in Phase 1 if not passed via --target
|
|
86
|
+
ISSUE_NUMBER=""
|
|
87
|
+
MAX_CI_ITER=3
|
|
88
|
+
MAX_REVIEW_ITER=3
|
|
89
|
+
DRY_RUN=false
|
|
90
|
+
|
|
91
|
+
# Parse optional flags
|
|
92
|
+
shift 2>/dev/null || true
|
|
93
|
+
while [[ $# -gt 0 ]]; do
|
|
94
|
+
case "$1" in
|
|
95
|
+
--target) TARGET="$2"; shift 2 ;;
|
|
96
|
+
--issue) ISSUE_NUMBER="$2"; shift 2 ;;
|
|
97
|
+
--repo) GH_REPO="$2"; GH_FLAG="-R $GH_REPO"; shift 2 ;;
|
|
98
|
+
--max-ci-iterations) MAX_CI_ITER="$2"; shift 2 ;;
|
|
99
|
+
--max-review-iterations) MAX_REVIEW_ITER="$2"; shift 2 ;;
|
|
100
|
+
--dry-run) DRY_RUN=true; shift ;;
|
|
101
|
+
*) shift ;;
|
|
102
|
+
esac
|
|
103
|
+
done
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
## Phase 1: Branch Routing
|
|
109
|
+
|
|
110
|
+
Determine the PR target from the source branch. If `--target` was passed explicitly, skip the routing table and use it directly.
|
|
111
|
+
|
|
112
|
+
### Routing Table
|
|
113
|
+
|
|
114
|
+
| Source Branch Pattern | Target | PR Title Prefix |
|
|
115
|
+
|----------------------|--------|-----------------|
|
|
116
|
+
| `staging` | `main` (DEFAULT_BRANCH) | `Deploy` |
|
|
117
|
+
| `milestone/*` | `staging` (STAGING_BRANCH) | `Ship` |
|
|
118
|
+
| Any other branch | `staging` (STAGING_BRANCH) | `Merge` |
|
|
119
|
+
|
|
120
|
+
```bash
|
|
121
|
+
if [ -n "$TARGET" ]; then
|
|
122
|
+
# Explicit override
|
|
123
|
+
PR_TARGET="$TARGET"
|
|
124
|
+
case "$SOURCE" in
|
|
125
|
+
staging) TITLE_PREFIX="Deploy" ;;
|
|
126
|
+
milestone/*) TITLE_PREFIX="Ship" ;;
|
|
127
|
+
*) TITLE_PREFIX="Merge" ;;
|
|
128
|
+
esac
|
|
129
|
+
else
|
|
130
|
+
case "$SOURCE" in
|
|
131
|
+
staging)
|
|
132
|
+
PR_TARGET="$DEFAULT_BRANCH"
|
|
133
|
+
TITLE_PREFIX="Deploy"
|
|
134
|
+
;;
|
|
135
|
+
milestone/*)
|
|
136
|
+
PR_TARGET="$STAGING_BRANCH"
|
|
137
|
+
TITLE_PREFIX="Ship"
|
|
138
|
+
;;
|
|
139
|
+
*)
|
|
140
|
+
PR_TARGET="$STAGING_BRANCH"
|
|
141
|
+
TITLE_PREFIX="Merge"
|
|
142
|
+
;;
|
|
143
|
+
esac
|
|
144
|
+
fi
|
|
145
|
+
|
|
146
|
+
echo "Source: $SOURCE → Target: $PR_TARGET (prefix: $TITLE_PREFIX)"
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
**Routing guard — staging→main**: When `SOURCE=staging` and `PR_TARGET=main`, the review gate (Phase 4) MUST route to `Skill("review-pr-staging", ...)` rather than the standard `review-pr`. This ensures the staging deploy review (comprehensive, multi-agent) runs instead of the single-PR review.
|
|
150
|
+
|
|
151
|
+
**Routing guard — milestone→staging**: When `SOURCE` matches `milestone/*`, this is a feature lane ship. `/review-pr` handles these normally (they target staging, not main).
|
|
152
|
+
|
|
153
|
+
---
|
|
154
|
+
|
|
155
|
+
## Phase 2: PR Detection / Creation
|
|
156
|
+
|
|
157
|
+
### 2A: Detect Existing PR
|
|
158
|
+
|
|
159
|
+
Always check for an open PR before creating one. Never close-and-recreate — always update in place. <!-- Ref: issue #1328 -->
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
cd "$REPO_PATH"
|
|
163
|
+
git fetch origin "$SOURCE" "$PR_TARGET" 2>/dev/null || true
|
|
164
|
+
|
|
165
|
+
EXISTING_PR=$(gh pr list $GH_FLAG \
|
|
166
|
+
--head "$SOURCE" \
|
|
167
|
+
--base "$PR_TARGET" \
|
|
168
|
+
--state open \
|
|
169
|
+
--json number,url,title,headRefOid \
|
|
170
|
+
--jq '.[0] // empty' 2>/dev/null)
|
|
171
|
+
|
|
172
|
+
if [ -n "$EXISTING_PR" ]; then
|
|
173
|
+
PR_NUMBER=$(echo "$EXISTING_PR" | jq -r '.number')
|
|
174
|
+
PR_URL=$(echo "$EXISTING_PR" | jq -r '.url')
|
|
175
|
+
echo "Found existing open PR #${PR_NUMBER}: ${PR_URL}"
|
|
176
|
+
PR_CREATED=false
|
|
177
|
+
else
|
|
178
|
+
echo "No existing PR found — creating new PR"
|
|
179
|
+
PR_NUMBER=""
|
|
180
|
+
PR_CREATED=true
|
|
181
|
+
fi
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### 2B: Gather Commit Summary (for new PR body)
|
|
185
|
+
|
|
186
|
+
Skip if PR already exists.
|
|
187
|
+
|
|
188
|
+
```bash
|
|
189
|
+
if [ "$PR_CREATED" = "true" ]; then
|
|
190
|
+
# Commits on source that are not yet on target
|
|
191
|
+
COMMIT_LOG=$(git log --oneline "origin/${PR_TARGET}..origin/${SOURCE}" --format="%h %s" 2>/dev/null | head -30)
|
|
192
|
+
COMMIT_COUNT=$(git rev-list --count "origin/${PR_TARGET}..origin/${SOURCE}" 2>/dev/null || echo "0")
|
|
193
|
+
|
|
194
|
+
if [ "$COMMIT_COUNT" -eq 0 ]; then
|
|
195
|
+
echo "DEPLOY_RESULT: status=NOTHING_TO_DEPLOY source=$SOURCE target=$PR_TARGET"
|
|
196
|
+
echo "{ \"pr\": null, \"source\": \"$SOURCE\", \"target\": \"$PR_TARGET\", \"status\": \"nothing_to_deploy\", \"ci_fixes\": 0, \"review_findings\": 0 }"
|
|
197
|
+
exit 0
|
|
198
|
+
fi
|
|
199
|
+
|
|
200
|
+
# Extract issue references from commit messages
|
|
201
|
+
ISSUE_REFS=$(echo "$COMMIT_LOG" | grep -oE '#[0-9]+' | sort -u | head -20 | tr '\n' ' ')
|
|
202
|
+
|
|
203
|
+
# Build issue ref block
|
|
204
|
+
if [ -n "$ISSUE_NUMBER" ]; then
|
|
205
|
+
CLOSES_LINE="Closes #${ISSUE_NUMBER}"
|
|
206
|
+
else
|
|
207
|
+
CLOSES_LINE="<!-- No parent issue specified -->"
|
|
208
|
+
fi
|
|
209
|
+
|
|
210
|
+
PR_BODY="## Summary
|
|
211
|
+
|
|
212
|
+
${TITLE_PREFIX}: \`${SOURCE}\` → \`${PR_TARGET}\`
|
|
213
|
+
|
|
214
|
+
**Commits**: ${COMMIT_COUNT}
|
|
215
|
+
|
|
216
|
+
## Changes
|
|
217
|
+
|
|
218
|
+
\`\`\`
|
|
219
|
+
${COMMIT_LOG}
|
|
220
|
+
\`\`\`
|
|
221
|
+
|
|
222
|
+
## Issue References
|
|
223
|
+
|
|
224
|
+
${ISSUE_REFS:-None}
|
|
225
|
+
|
|
226
|
+
---
|
|
227
|
+
${CLOSES_LINE}
|
|
228
|
+
**Source**: \`${SOURCE}\`
|
|
229
|
+
**Target**: \`${PR_TARGET}\`"
|
|
230
|
+
fi
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
### 2C: Create PR (if needed)
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
if [ "$PR_CREATED" = "true" ] && [ "$DRY_RUN" = "false" ]; then
|
|
237
|
+
# Determine PR title
|
|
238
|
+
PR_TITLE="${TITLE_PREFIX}: ${SOURCE} → ${PR_TARGET} (${COMMIT_COUNT} commits)"
|
|
239
|
+
|
|
240
|
+
# Route to correct review spec — staging→main PRs use review-pr-staging conventions
|
|
241
|
+
if [ "$SOURCE" = "$STAGING_BRANCH" ] && [ "$PR_TARGET" = "$DEFAULT_BRANCH" ]; then
|
|
242
|
+
PR_TITLE="${TITLE_PREFIX}: Deploy staging → main (${COMMIT_COUNT} commits)"
|
|
243
|
+
fi
|
|
244
|
+
|
|
245
|
+
PR_URL=$(gh pr create $GH_FLAG \
|
|
246
|
+
--head "$SOURCE" \
|
|
247
|
+
--base "$PR_TARGET" \
|
|
248
|
+
--title "$PR_TITLE" \
|
|
249
|
+
--body "$PR_BODY" 2>&1)
|
|
250
|
+
|
|
251
|
+
if echo "$PR_URL" | grep -qE '^https://'; then
|
|
252
|
+
PR_NUMBER=$(echo "$PR_URL" | grep -oE '/pull/[0-9]+' | grep -oE '[0-9]+' | tail -1)
|
|
253
|
+
echo "Created PR #${PR_NUMBER}: ${PR_URL}"
|
|
254
|
+
else
|
|
255
|
+
echo "ERROR: PR creation failed:"
|
|
256
|
+
echo "$PR_URL"
|
|
257
|
+
echo "{ \"pr\": null, \"source\": \"$SOURCE\", \"target\": \"$PR_TARGET\", \"status\": \"pr_creation_failed\", \"ci_fixes\": 0, \"review_findings\": 0 }"
|
|
258
|
+
exit 1
|
|
259
|
+
fi
|
|
260
|
+
|
|
261
|
+
elif [ "$DRY_RUN" = "true" ] && [ "$PR_CREATED" = "true" ]; then
|
|
262
|
+
echo "[DRY-RUN] Would create PR: ${TITLE_PREFIX}: ${SOURCE} → ${PR_TARGET} (${COMMIT_COUNT} commits)"
|
|
263
|
+
echo "[DRY-RUN] PR body would include ${COMMIT_COUNT} commits and issue refs: ${ISSUE_REFS}"
|
|
264
|
+
PR_NUMBER="DRY_RUN"
|
|
265
|
+
fi
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
**State after Phase 2**: `PR_NUMBER` is set (either detected or created).
|
|
269
|
+
|
|
270
|
+
---
|
|
271
|
+
|
|
272
|
+
## Phase 3: CI Gate
|
|
273
|
+
|
|
274
|
+
Invoke `/fix-ci` to handle CI failures. Loop until green or max iterations reached.
|
|
275
|
+
|
|
276
|
+
**Graceful degradation**: If `/fix-ci` is not available (not yet installed — issue #1675 is its companion), skip this phase with a warning. The review gate in Phase 4 will still run, and a failing CI will block the merge at the review stage.
|
|
277
|
+
|
|
278
|
+
```bash
|
|
279
|
+
CI_FIXES=0
|
|
280
|
+
CI_GATE_PASSED=false
|
|
281
|
+
CI_ITER=0
|
|
282
|
+
|
|
283
|
+
# Check if fix-ci is available
|
|
284
|
+
FIX_CI_AVAILABLE=$(ls ~/.claude/commands/fix-ci.md 2>/dev/null && echo "true" || echo "false")
|
|
285
|
+
|
|
286
|
+
if [ "$FIX_CI_AVAILABLE" = "false" ]; then
|
|
287
|
+
echo "WARNING: /fix-ci not available — skipping CI gate. Install fix-ci (issue #1675) for automated CI fixing."
|
|
288
|
+
echo "CI gate: SKIPPED (fix-ci unavailable)"
|
|
289
|
+
CI_GATE_PASSED=true # Allow pipeline to continue; review gate will catch CI failures
|
|
290
|
+
else
|
|
291
|
+
# Check current CI status
|
|
292
|
+
CURRENT_CI_STATUS=$(gh pr checks "$PR_NUMBER" $GH_FLAG --json name,status,conclusion \
|
|
293
|
+
--jq '[.[] | select(.conclusion == "failure")] | length' 2>/dev/null || echo "0")
|
|
294
|
+
|
|
295
|
+
if [ "$CURRENT_CI_STATUS" -eq 0 ]; then
|
|
296
|
+
echo "CI gate: PASSED (no failing checks)"
|
|
297
|
+
CI_GATE_PASSED=true
|
|
298
|
+
else
|
|
299
|
+
echo "CI has $CURRENT_CI_STATUS failing checks — invoking /fix-ci (max $MAX_CI_ITER iterations)"
|
|
300
|
+
|
|
301
|
+
while [ "$CI_ITER" -lt "$MAX_CI_ITER" ] && [ "$CI_GATE_PASSED" = "false" ]; do
|
|
302
|
+
CI_ITER=$((CI_ITER + 1))
|
|
303
|
+
echo "=== CI Gate Iteration $CI_ITER / $MAX_CI_ITER ==="
|
|
304
|
+
|
|
305
|
+
if [ "$DRY_RUN" = "false" ]; then
|
|
306
|
+
Skill("fix-ci", args="$PR_NUMBER $GH_FLAG")
|
|
307
|
+
else
|
|
308
|
+
echo "[DRY-RUN] Would invoke: Skill(fix-ci, $PR_NUMBER $GH_FLAG)"
|
|
309
|
+
fi
|
|
310
|
+
|
|
311
|
+
CI_FIXES=$((CI_FIXES + 1))
|
|
312
|
+
|
|
313
|
+
# Re-check CI status after fix attempt
|
|
314
|
+
FAILING_AFTER=$(gh pr checks "$PR_NUMBER" $GH_FLAG --json name,status,conclusion \
|
|
315
|
+
--jq '[.[] | select(.conclusion == "failure")] | length' 2>/dev/null || echo "0")
|
|
316
|
+
|
|
317
|
+
if [ "$FAILING_AFTER" -eq 0 ]; then
|
|
318
|
+
echo "CI gate: PASSED after $CI_ITER iteration(s)"
|
|
319
|
+
CI_GATE_PASSED=true
|
|
320
|
+
else
|
|
321
|
+
echo "CI still has $FAILING_AFTER failing checks after iteration $CI_ITER"
|
|
322
|
+
fi
|
|
323
|
+
done
|
|
324
|
+
|
|
325
|
+
if [ "$CI_GATE_PASSED" = "false" ]; then
|
|
326
|
+
echo "CI gate: FAILED — still failing after $MAX_CI_ITER fix iteration(s). NOT merging."
|
|
327
|
+
echo "{ \"pr\": $PR_NUMBER, \"source\": \"$SOURCE\", \"target\": \"$PR_TARGET\", \"status\": \"ci_failed\", \"ci_fixes\": $CI_FIXES, \"review_findings\": 0 }"
|
|
328
|
+
exit 1
|
|
329
|
+
fi
|
|
330
|
+
fi
|
|
331
|
+
fi
|
|
332
|
+
|
|
333
|
+
# Emit gate state marker for auditability <!-- Ref: issue #1582 — gate passes must be auditable -->
|
|
334
|
+
if [ "$DRY_RUN" = "false" ] && [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "DRY_RUN" ]; then
|
|
335
|
+
gh pr comment "$PR_NUMBER" $GH_FLAG --body "<!-- FORGE:GATE_PASS phase=ci iterations=${CI_ITER} fixes=${CI_FIXES} -->" 2>/dev/null || true
|
|
336
|
+
fi
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
---
|
|
340
|
+
|
|
341
|
+
## Phase 4: Review Gate
|
|
342
|
+
|
|
343
|
+
Invoke `/review-pr` for the PR. Route staging→main PRs to the staging review spec.
|
|
344
|
+
|
|
345
|
+
**On rejection**: Callers (e.g., /autopilot) are expected to push a fix commit to the source branch before this command is re-invoked. The re-review loop here handles only the re-review after a fix is already pushed — it does NOT fix code itself.
|
|
346
|
+
|
|
347
|
+
```bash
|
|
348
|
+
REVIEW_FINDINGS=0
|
|
349
|
+
REVIEW_GATE_PASSED=false
|
|
350
|
+
REVIEW_ITER=0
|
|
351
|
+
|
|
352
|
+
while [ "$REVIEW_ITER" -lt "$MAX_REVIEW_ITER" ] && [ "$REVIEW_GATE_PASSED" = "false" ]; do
|
|
353
|
+
REVIEW_ITER=$((REVIEW_ITER + 1))
|
|
354
|
+
echo "=== Review Gate Iteration $REVIEW_ITER / $MAX_REVIEW_ITER ==="
|
|
355
|
+
|
|
356
|
+
if [ "$DRY_RUN" = "false" ]; then
|
|
357
|
+
# Route: staging→main uses review-pr-staging; all other uses standard review-pr
|
|
358
|
+
if [ "$SOURCE" = "$STAGING_BRANCH" ] && [ "$PR_TARGET" = "$DEFAULT_BRANCH" ]; then
|
|
359
|
+
REVIEW_RESULT=$(Skill("review-pr-staging", args="$PR_NUMBER $GH_FLAG"))
|
|
360
|
+
else
|
|
361
|
+
REVIEW_RESULT=$(Skill("review-pr", args="$PR_NUMBER $GH_FLAG"))
|
|
362
|
+
fi
|
|
363
|
+
else
|
|
364
|
+
echo "[DRY-RUN] Would invoke review-pr for PR #$PR_NUMBER"
|
|
365
|
+
REVIEW_RESULT="DRY_RUN_APPROVED"
|
|
366
|
+
fi
|
|
367
|
+
|
|
368
|
+
# Extract verdict from review result
|
|
369
|
+
# Review-pr posts <!-- FORGE:REVIEW --> comment on the PR — read it
|
|
370
|
+
if [ "$DRY_RUN" = "false" ]; then
|
|
371
|
+
REVIEW_VERDICT=$(gh api "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
|
|
372
|
+
--jq '[.[] | select(.body | contains("FORGE:REVIEW"))] | last | .body' 2>/dev/null \
|
|
373
|
+
| grep -oE 'APPROVED|CHANGES REQUESTED' | head -1 || echo "")
|
|
374
|
+
FINDINGS_THIS_ROUND=$(gh api "repos/${GH_REPO}/issues/${PR_NUMBER}/comments" \
|
|
375
|
+
--jq '[.[] | select(.body | contains("FORGE:REVIEW"))] | last | .body' 2>/dev/null \
|
|
376
|
+
| grep -oE '[0-9]+ finding' | grep -oE '[0-9]+' | head -1 || echo "0")
|
|
377
|
+
REVIEW_FINDINGS=$((REVIEW_FINDINGS + ${FINDINGS_THIS_ROUND:-0}))
|
|
378
|
+
else
|
|
379
|
+
REVIEW_VERDICT="APPROVED"
|
|
380
|
+
fi
|
|
381
|
+
|
|
382
|
+
if [ "$REVIEW_VERDICT" = "APPROVED" ]; then
|
|
383
|
+
echo "Review gate: APPROVED after $REVIEW_ITER iteration(s)"
|
|
384
|
+
REVIEW_GATE_PASSED=true
|
|
385
|
+
elif [ "$REVIEW_VERDICT" = "CHANGES REQUESTED" ]; then
|
|
386
|
+
echo "Review gate: CHANGES REQUESTED (iteration $REVIEW_ITER / $MAX_REVIEW_ITER)"
|
|
387
|
+
if [ "$REVIEW_ITER" -lt "$MAX_REVIEW_ITER" ]; then
|
|
388
|
+
echo "Waiting for caller to push a fix commit, then re-reviewing..."
|
|
389
|
+
echo "NOTE: /deploy-pr does not fix code itself. Push fix commits to $SOURCE and re-invoke, or allow the max-review-iterations loop to exhaust."
|
|
390
|
+
# In an autonomous context (e.g., /autopilot), the caller pushes the fix.
|
|
391
|
+
# In manual context, the operator pushes the fix and re-invokes /deploy-pr.
|
|
392
|
+
# This loop's next iteration will re-read the latest PR state.
|
|
393
|
+
fi
|
|
394
|
+
else
|
|
395
|
+
echo "WARNING: Could not determine review verdict from FORGE:REVIEW comment. Assuming APPROVED."
|
|
396
|
+
REVIEW_GATE_PASSED=true
|
|
397
|
+
fi
|
|
398
|
+
done
|
|
399
|
+
|
|
400
|
+
if [ "$REVIEW_GATE_PASSED" = "false" ]; then
|
|
401
|
+
echo "Review gate: FAILED — changes requested after $MAX_REVIEW_ITER review iteration(s). NOT merging."
|
|
402
|
+
echo "{ \"pr\": $PR_NUMBER, \"source\": \"$SOURCE\", \"target\": \"$PR_TARGET\", \"status\": \"review_failed\", \"ci_fixes\": $CI_FIXES, \"review_findings\": $REVIEW_FINDINGS }"
|
|
403
|
+
exit 1
|
|
404
|
+
fi
|
|
405
|
+
|
|
406
|
+
# Emit review gate state marker for auditability
|
|
407
|
+
if [ "$DRY_RUN" = "false" ] && [ -n "$PR_NUMBER" ] && [ "$PR_NUMBER" != "DRY_RUN" ]; then
|
|
408
|
+
gh pr comment "$PR_NUMBER" $GH_FLAG --body "<!-- FORGE:GATE_PASS phase=review iterations=${REVIEW_ITER} findings=${REVIEW_FINDINGS} -->" 2>/dev/null || true
|
|
409
|
+
fi
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
---
|
|
413
|
+
|
|
414
|
+
## Phase 5: Merge
|
|
415
|
+
|
|
416
|
+
Both CI and review gates have passed. Merge the PR.
|
|
417
|
+
|
|
418
|
+
**Never force-merges.** If merge fails, report and stop.
|
|
419
|
+
|
|
420
|
+
```bash
|
|
421
|
+
if [ "$DRY_RUN" = "true" ]; then
|
|
422
|
+
echo "[DRY-RUN] Would merge PR #$PR_NUMBER (${SOURCE} → ${PR_TARGET})"
|
|
423
|
+
MERGE_STATUS="dry_run"
|
|
424
|
+
else
|
|
425
|
+
echo "Merging PR #$PR_NUMBER..."
|
|
426
|
+
MERGE_OUTPUT=$(gh pr merge "$PR_NUMBER" $GH_FLAG --merge 2>&1)
|
|
427
|
+
MERGE_EXIT=$?
|
|
428
|
+
|
|
429
|
+
if [ "$MERGE_EXIT" -eq 0 ]; then
|
|
430
|
+
echo "Merged PR #$PR_NUMBER successfully"
|
|
431
|
+
MERGE_STATUS="merged"
|
|
432
|
+
else
|
|
433
|
+
echo "ERROR: Merge failed:"
|
|
434
|
+
echo "$MERGE_OUTPUT"
|
|
435
|
+
echo "{ \"pr\": $PR_NUMBER, \"source\": \"$SOURCE\", \"target\": \"$PR_TARGET\", \"status\": \"merge_failed\", \"ci_fixes\": $CI_FIXES, \"review_findings\": $REVIEW_FINDINGS }"
|
|
436
|
+
exit 1
|
|
437
|
+
fi
|
|
438
|
+
fi
|
|
439
|
+
```
|
|
440
|
+
|
|
441
|
+
---
|
|
442
|
+
|
|
443
|
+
## Phase 6: Report Structured Result
|
|
444
|
+
|
|
445
|
+
Return a machine-readable result for callers. This structured block is the primary output consumed by `/autopilot` and other callers.
|
|
446
|
+
|
|
447
|
+
```bash
|
|
448
|
+
echo ""
|
|
449
|
+
echo "=== /deploy-pr Complete ==="
|
|
450
|
+
echo ""
|
|
451
|
+
echo "DEPLOY_RESULT: pr=$PR_NUMBER source=$SOURCE target=$PR_TARGET status=$MERGE_STATUS ci_fixes=$CI_FIXES review_findings=$REVIEW_FINDINGS"
|
|
452
|
+
echo ""
|
|
453
|
+
|
|
454
|
+
# Machine-readable JSON result (consumed by callers)
|
|
455
|
+
cat <<JSON
|
|
456
|
+
{
|
|
457
|
+
"pr": ${PR_NUMBER:-null},
|
|
458
|
+
"source": "${SOURCE}",
|
|
459
|
+
"target": "${PR_TARGET}",
|
|
460
|
+
"status": "${MERGE_STATUS}",
|
|
461
|
+
"ci_fixes": ${CI_FIXES},
|
|
462
|
+
"review_findings": ${REVIEW_FINDINGS},
|
|
463
|
+
"ci_gate": "$([ $CI_GATE_PASSED = true ] && echo passed || echo failed)",
|
|
464
|
+
"review_gate": "$([ $REVIEW_GATE_PASSED = true ] && echo passed || echo failed)",
|
|
465
|
+
"iterations": {
|
|
466
|
+
"ci": ${CI_ITER},
|
|
467
|
+
"review": ${REVIEW_ITER}
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
JSON
|
|
471
|
+
```
|
|
472
|
+
|
|
473
|
+
---
|
|
474
|
+
|
|
475
|
+
## Error Reference
|
|
476
|
+
|
|
477
|
+
| Exit Condition | Status Field | Action |
|
|
478
|
+
|---------------|-------------|--------|
|
|
479
|
+
| No commits to deploy | `nothing_to_deploy` | No PR created — exit 0 |
|
|
480
|
+
| PR creation failed | `pr_creation_failed` | Check gh auth and branch existence |
|
|
481
|
+
| CI failed after max iterations | `ci_failed` | Check failing CI job logs; fix manually |
|
|
482
|
+
| Review failed after max iterations | `review_failed` | Address review findings; re-invoke |
|
|
483
|
+
| Merge failed | `merge_failed` | Check merge conflicts; may need manual resolution |
|
|
484
|
+
|
|
485
|
+
**`needs-human` pattern**: When any exit condition produces a non-zero exit, callers (like `/autopilot`) should add a `needs-human` label to the parent issue and stop autonomous iteration for this branch.
|