fix: Extract lib/pr-lifecycle.sh — walk-PR-to-merge library (#795)
New reusable library with clean function boundaries for the PR lifecycle: - pr_create, pr_find_by_branch — PR creation and lookup - pr_poll_ci — poll CI with infra vs code failure classification - pr_poll_review — poll for review verdict (bot comments + formal reviews) - pr_merge, pr_is_merged — merge with 405 handling and race detection - pr_walk_to_merge — full orchestration loop (CI → review → merge) - build_phase_protocol_prompt — git push instructions for agent prompts The pr_walk_to_merge function uses agent_run() which callers must define (guarded stub provided). This bridges to the synchronous SDK architecture where the orchestrator bash loop IS the state machine — no phase files. Extracted from: dev/phase-handler.sh, dev/dev-poll.sh, lib/ci-helpers.sh Smoke test updated to include the new library. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
779584be2d
commit
b7e09d17ef
2 changed files with 517 additions and 1 deletions
|
|
@ -113,7 +113,7 @@ echo "=== 2/2 Function resolution ==="
|
||||||
# If a new lib file is added and sourced by agents, add it to LIB_FUNS below
|
# If a new lib file is added and sourced by agents, add it to LIB_FUNS below
|
||||||
# and add a check_script call for it in the lib files section further down.
|
# and add a check_script call for it in the lib files section further down.
|
||||||
LIB_FUNS=$(
|
LIB_FUNS=$(
|
||||||
for f in lib/agent-session.sh lib/env.sh lib/ci-helpers.sh lib/load-project.sh lib/secret-scan.sh lib/file-action-issue.sh lib/formula-session.sh lib/mirrors.sh lib/guard.sh; do
|
for f in lib/agent-session.sh lib/env.sh lib/ci-helpers.sh lib/load-project.sh lib/secret-scan.sh lib/file-action-issue.sh lib/formula-session.sh lib/mirrors.sh lib/guard.sh lib/pr-lifecycle.sh; do
|
||||||
if [ -f "$f" ]; then get_fns "$f"; fi
|
if [ -f "$f" ]; then get_fns "$f"; fi
|
||||||
done | sort -u
|
done | sort -u
|
||||||
)
|
)
|
||||||
|
|
@ -186,6 +186,7 @@ check_script lib/formula-session.sh lib/agent-session.sh
|
||||||
check_script lib/load-project.sh
|
check_script lib/load-project.sh
|
||||||
check_script lib/mirrors.sh lib/env.sh
|
check_script lib/mirrors.sh lib/env.sh
|
||||||
check_script lib/guard.sh
|
check_script lib/guard.sh
|
||||||
|
check_script lib/pr-lifecycle.sh
|
||||||
|
|
||||||
# Standalone lib scripts (not sourced by agents; run directly or as services).
|
# Standalone lib scripts (not sourced by agents; run directly or as services).
|
||||||
# Still checked for function resolution against LIB_FUNS + own definitions.
|
# Still checked for function resolution against LIB_FUNS + own definitions.
|
||||||
|
|
|
||||||
515
lib/pr-lifecycle.sh
Normal file
515
lib/pr-lifecycle.sh
Normal file
|
|
@ -0,0 +1,515 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# pr-lifecycle.sh — Reusable PR lifecycle library: create, poll, review, merge
|
||||||
|
#
|
||||||
|
# Source after lib/env.sh and lib/ci-helpers.sh:
|
||||||
|
# source "$FACTORY_ROOT/lib/ci-helpers.sh"
|
||||||
|
# source "$FACTORY_ROOT/lib/pr-lifecycle.sh"
|
||||||
|
#
|
||||||
|
# Required globals: FORGE_TOKEN, FORGE_API, PRIMARY_BRANCH
|
||||||
|
# Optional: FORGE_REMOTE (default: origin), WOODPECKER_REPO_ID,
|
||||||
|
# WOODPECKER_TOKEN, WOODPECKER_SERVER, FACTORY_ROOT
|
||||||
|
#
|
||||||
|
# For pr_walk_to_merge(): caller must define agent_run() — a synchronous Claude
|
||||||
|
# invocation (one-shot claude -p). Expected signature:
|
||||||
|
# agent_run [--resume SESSION] [--worktree DIR] PROMPT
|
||||||
|
#
|
||||||
|
# Functions:
|
||||||
|
# pr_create BRANCH TITLE BODY [BASE_BRANCH]
|
||||||
|
# pr_find_by_branch BRANCH
|
||||||
|
# pr_poll_ci PR_NUMBER [TIMEOUT_SECS] [POLL_INTERVAL]
|
||||||
|
# pr_poll_review PR_NUMBER [TIMEOUT_SECS] [POLL_INTERVAL]
|
||||||
|
# pr_merge PR_NUMBER [COMMIT_MSG]
|
||||||
|
# pr_is_merged PR_NUMBER
|
||||||
|
# pr_walk_to_merge PR_NUMBER SESSION_ID WORKTREE [MAX_CI_FIXES] [MAX_REVIEW_ROUNDS]
|
||||||
|
# build_phase_protocol_prompt BRANCH [REMOTE]
|
||||||
|
#
|
||||||
|
# Output variables (set by poll/merge functions, read by callers):
|
||||||
|
# _PR_CI_STATE success | failure | timeout
|
||||||
|
# _PR_CI_SHA commit SHA that was polled
|
||||||
|
# _PR_CI_PIPELINE Woodpecker pipeline number (on failure)
|
||||||
|
# _PR_CI_FAILURE_TYPE infra | code (on failure)
|
||||||
|
# _PR_CI_ERROR_LOG CI error log snippet (on failure)
|
||||||
|
# _PR_REVIEW_VERDICT APPROVE | REQUEST_CHANGES | DISCUSS | TIMEOUT |
|
||||||
|
# MERGED_EXTERNALLY | CLOSED_EXTERNALLY
|
||||||
|
# _PR_REVIEW_TEXT review feedback body text
|
||||||
|
# _PR_MERGE_ERROR merge error description (on failure)
|
||||||
|
# _PR_WALK_EXIT_REASON merged | ci_exhausted | review_exhausted |
|
||||||
|
# ci_timeout | review_timeout | merge_blocked |
|
||||||
|
# closed_externally | unexpected_verdict
|
||||||
|
#
|
||||||
|
# shellcheck shell=bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Default agent_run stub — callers override by defining agent_run() or sourcing
|
||||||
|
# an SDK (e.g., lib/sdk.sh) after this file.
|
||||||
|
if ! type agent_run &>/dev/null; then
|
||||||
|
agent_run() {
|
||||||
|
printf 'ERROR: agent_run() not defined — source your SDK before calling pr_walk_to_merge\n' >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Internal log helper.
|
||||||
|
_prl_log() {
|
||||||
|
if declare -f log >/dev/null 2>&1; then
|
||||||
|
log "pr-lifecycle: $*"
|
||||||
|
else
|
||||||
|
printf '[%s] pr-lifecycle: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >&2
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# pr_create — Create a PR via forge API.
|
||||||
|
# Args: branch title body [base_branch]
|
||||||
|
# Stdout: PR number
|
||||||
|
# Returns: 0=created (or found existing), 1=failed
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
pr_create() {
|
||||||
|
local branch="$1" title="$2" body="$3"
|
||||||
|
local base="${4:-${PRIMARY_BRANCH:-main}}"
|
||||||
|
local tmpfile resp http_code resp_body pr_num
|
||||||
|
|
||||||
|
tmpfile=$(mktemp /tmp/prl-create-XXXXXX.json)
|
||||||
|
jq -n --arg t "$title" --arg b "$body" --arg h "$branch" --arg base "$base" \
|
||||||
|
'{title:$t, body:$b, head:$h, base:$base}' > "$tmpfile"
|
||||||
|
|
||||||
|
resp=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
|
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
"${FORGE_API}/pulls" \
|
||||||
|
--data-binary @"$tmpfile") || true
|
||||||
|
rm -f "$tmpfile"
|
||||||
|
|
||||||
|
http_code=$(printf '%s\n' "$resp" | tail -1)
|
||||||
|
resp_body=$(printf '%s\n' "$resp" | sed '$d')
|
||||||
|
|
||||||
|
case "$http_code" in
|
||||||
|
200|201)
|
||||||
|
pr_num=$(printf '%s' "$resp_body" | jq -r '.number')
|
||||||
|
_prl_log "created PR #${pr_num}"
|
||||||
|
printf '%s' "$pr_num"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
409)
|
||||||
|
pr_num=$(pr_find_by_branch "$branch") || true
|
||||||
|
if [ -n "$pr_num" ]; then
|
||||||
|
_prl_log "PR already exists: #${pr_num}"
|
||||||
|
printf '%s' "$pr_num"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
_prl_log "PR creation failed: 409 conflict, no existing PR found"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
_prl_log "PR creation failed (HTTP ${http_code})"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# pr_find_by_branch — Find an open PR by head branch name.
|
||||||
|
# Args: branch
|
||||||
|
# Stdout: PR number
|
||||||
|
# Returns: 0=found, 1=not found
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
pr_find_by_branch() {
|
||||||
|
local branch="$1"
|
||||||
|
local pr_num
|
||||||
|
pr_num=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||||
|
"${FORGE_API}/pulls?state=open&limit=20" | \
|
||||||
|
jq -r --arg b "$branch" '.[] | select(.head.ref == $b) | .number' \
|
||||||
|
| head -1) || true
|
||||||
|
if [ -n "$pr_num" ]; then
|
||||||
|
printf '%s' "$pr_num"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# pr_poll_ci — Poll CI status until complete or timeout.
|
||||||
|
# Args: pr_number [timeout_secs=1800] [poll_interval=30]
|
||||||
|
# Sets: _PR_CI_STATE _PR_CI_SHA _PR_CI_PIPELINE _PR_CI_FAILURE_TYPE _PR_CI_ERROR_LOG
|
||||||
|
# Returns: 0=success, 1=failure, 2=timeout
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# shellcheck disable=SC2034 # output vars read by callers
|
||||||
|
pr_poll_ci() {
|
||||||
|
local pr_num="$1"
|
||||||
|
local timeout="${2:-1800}" interval="${3:-30}"
|
||||||
|
local elapsed=0
|
||||||
|
|
||||||
|
_PR_CI_STATE="" ; _PR_CI_SHA="" ; _PR_CI_PIPELINE=""
|
||||||
|
_PR_CI_FAILURE_TYPE="" ; _PR_CI_ERROR_LOG=""
|
||||||
|
|
||||||
|
_PR_CI_SHA=$(forge_api GET "/pulls/${pr_num}" | jq -r '.head.sha') || true
|
||||||
|
if [ -z "$_PR_CI_SHA" ]; then
|
||||||
|
_prl_log "cannot get HEAD SHA for PR #${pr_num}"
|
||||||
|
_PR_CI_STATE="failure"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${WOODPECKER_REPO_ID:-2}" = "0" ]; then
|
||||||
|
_PR_CI_STATE="success"
|
||||||
|
_prl_log "no CI configured"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! ci_required_for_pr "$pr_num"; then
|
||||||
|
_PR_CI_STATE="success"
|
||||||
|
_prl_log "PR #${pr_num} non-code — CI not required"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
_prl_log "polling CI for PR #${pr_num} SHA ${_PR_CI_SHA:0:7}"
|
||||||
|
while [ "$elapsed" -lt "$timeout" ]; do
|
||||||
|
sleep "$interval"
|
||||||
|
elapsed=$((elapsed + interval))
|
||||||
|
|
||||||
|
local state
|
||||||
|
state=$(ci_commit_status "$_PR_CI_SHA") || true
|
||||||
|
case "$state" in
|
||||||
|
success)
|
||||||
|
_PR_CI_STATE="success"
|
||||||
|
_prl_log "CI passed"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
failure|error)
|
||||||
|
_PR_CI_STATE="failure"
|
||||||
|
_PR_CI_PIPELINE=$(ci_pipeline_number "$_PR_CI_SHA") || true
|
||||||
|
if [ -n "$_PR_CI_PIPELINE" ] && [ -n "${WOODPECKER_REPO_ID:-}" ]; then
|
||||||
|
_PR_CI_FAILURE_TYPE=$(classify_pipeline_failure \
|
||||||
|
"$WOODPECKER_REPO_ID" "$_PR_CI_PIPELINE" 2>/dev/null \
|
||||||
|
| cut -d' ' -f1) || _PR_CI_FAILURE_TYPE="code"
|
||||||
|
if [ -n "${FACTORY_ROOT:-}" ]; then
|
||||||
|
_PR_CI_ERROR_LOG=$(bash "${FACTORY_ROOT}/lib/ci-debug.sh" \
|
||||||
|
failures "$_PR_CI_PIPELINE" 2>/dev/null \
|
||||||
|
| tail -80 | head -c 8000) || true
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
_prl_log "CI failed (type: ${_PR_CI_FAILURE_TYPE:-unknown})"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
_PR_CI_STATE="timeout"
|
||||||
|
_prl_log "CI timeout after ${timeout}s"
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# pr_poll_review — Poll for review verdict on a PR.
|
||||||
|
# Args: pr_number [timeout_secs=10800] [poll_interval=300]
|
||||||
|
# Sets: _PR_REVIEW_VERDICT _PR_REVIEW_TEXT
|
||||||
|
# Returns: 0=verdict found, 1=timeout, 2=PR closed/merged externally
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# shellcheck disable=SC2034 # output vars read by callers
|
||||||
|
pr_poll_review() {
|
||||||
|
local pr_num="$1"
|
||||||
|
local timeout="${2:-10800}" interval="${3:-300}"
|
||||||
|
local elapsed=0
|
||||||
|
|
||||||
|
_PR_REVIEW_VERDICT="" ; _PR_REVIEW_TEXT=""
|
||||||
|
|
||||||
|
_prl_log "polling review for PR #${pr_num}"
|
||||||
|
while [ "$elapsed" -lt "$timeout" ]; do
|
||||||
|
sleep "$interval"
|
||||||
|
elapsed=$((elapsed + interval))
|
||||||
|
|
||||||
|
local pr_json sha
|
||||||
|
pr_json=$(forge_api GET "/pulls/${pr_num}") || true
|
||||||
|
sha=$(printf '%s' "$pr_json" | jq -r '.head.sha // empty') || true
|
||||||
|
|
||||||
|
# Check if PR closed/merged externally
|
||||||
|
local pr_state pr_merged
|
||||||
|
pr_state=$(printf '%s' "$pr_json" | jq -r '.state // "unknown"')
|
||||||
|
pr_merged=$(printf '%s' "$pr_json" | jq -r '.merged // false')
|
||||||
|
if [ "$pr_state" != "open" ]; then
|
||||||
|
if [ "$pr_merged" = "true" ]; then
|
||||||
|
_PR_REVIEW_VERDICT="MERGED_EXTERNALLY"
|
||||||
|
_prl_log "PR #${pr_num} merged externally"
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
_PR_REVIEW_VERDICT="CLOSED_EXTERNALLY"
|
||||||
|
_prl_log "PR #${pr_num} closed externally"
|
||||||
|
return 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Check bot review comment (<!-- reviewed: SHA -->)
|
||||||
|
local review_comment review_text="" verdict=""
|
||||||
|
review_comment=$(forge_api_all "/issues/${pr_num}/comments" | \
|
||||||
|
jq -r --arg sha "${sha:-}" \
|
||||||
|
'[.[] | select(.body | contains("<!-- reviewed: " + $sha))] | last // empty') || true
|
||||||
|
|
||||||
|
if [ -n "$review_comment" ] && [ "$review_comment" != "null" ]; then
|
||||||
|
review_text=$(printf '%s' "$review_comment" | jq -r '.body')
|
||||||
|
# Skip error reviews
|
||||||
|
if printf '%s' "$review_text" | grep -q 'review-error\|Review — Error'; then
|
||||||
|
_prl_log "review error — waiting for re-review"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
verdict=$(printf '%s' "$review_text" | \
|
||||||
|
grep -oP '\*\*(APPROVE|REQUEST_CHANGES|DISCUSS)\*\*' | head -1 | tr -d '*') || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback: formal forge reviews
|
||||||
|
if [ -z "$verdict" ]; then
|
||||||
|
verdict=$(forge_api GET "/pulls/${pr_num}/reviews" | \
|
||||||
|
jq -r '[.[] | select(.stale == false)] | last | .state // empty') || true
|
||||||
|
case "$verdict" in
|
||||||
|
APPROVED) verdict="APPROVE" ;;
|
||||||
|
REQUEST_CHANGES) ;;
|
||||||
|
*) verdict="" ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$verdict" ]; then
|
||||||
|
_PR_REVIEW_VERDICT="$verdict"
|
||||||
|
_PR_REVIEW_TEXT="${review_text:-}"
|
||||||
|
_prl_log "review verdict: ${verdict}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
_prl_log "waiting for review on PR #${pr_num} (${elapsed}s)"
|
||||||
|
done
|
||||||
|
|
||||||
|
_PR_REVIEW_VERDICT="TIMEOUT"
|
||||||
|
_prl_log "review timeout after ${timeout}s"
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# pr_merge — Merge a PR via forge API.
|
||||||
|
# Args: pr_number [commit_message]
|
||||||
|
# Sets: _PR_MERGE_ERROR (on failure)
|
||||||
|
# Returns: 0=merged, 1=error, 2=blocked (HTTP 405)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# shellcheck disable=SC2034 # _PR_MERGE_ERROR read by callers
|
||||||
|
pr_merge() {
|
||||||
|
local pr_num="$1" commit_msg="${2:-}"
|
||||||
|
local merge_data resp http_code body
|
||||||
|
|
||||||
|
_PR_MERGE_ERROR=""
|
||||||
|
|
||||||
|
merge_data='{"Do":"merge","delete_branch_after_merge":true}'
|
||||||
|
if [ -n "$commit_msg" ]; then
|
||||||
|
merge_data=$(jq -nc --arg m "$commit_msg" \
|
||||||
|
'{Do:"merge",delete_branch_after_merge:true,MergeMessageField:$m}')
|
||||||
|
fi
|
||||||
|
|
||||||
|
resp=$(curl -s -w "\n%{http_code}" -X POST \
|
||||||
|
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||||
|
-H 'Content-Type: application/json' \
|
||||||
|
"${FORGE_API}/pulls/${pr_num}/merge" \
|
||||||
|
-d "$merge_data") || true
|
||||||
|
http_code=$(printf '%s\n' "$resp" | tail -1)
|
||||||
|
body=$(printf '%s\n' "$resp" | sed '$d')
|
||||||
|
|
||||||
|
case "$http_code" in
|
||||||
|
200|204)
|
||||||
|
_prl_log "PR #${pr_num} merged"
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
405)
|
||||||
|
# Check if already merged (race with another agent)
|
||||||
|
local merged
|
||||||
|
merged=$(forge_api GET "/pulls/${pr_num}" | jq -r '.merged // false') || true
|
||||||
|
if [ "$merged" = "true" ]; then
|
||||||
|
_prl_log "PR #${pr_num} already merged"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
_PR_MERGE_ERROR="blocked (HTTP 405): ${body:0:200}"
|
||||||
|
_prl_log "PR #${pr_num} merge blocked: ${_PR_MERGE_ERROR}"
|
||||||
|
return 2
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
_PR_MERGE_ERROR="failed (HTTP ${http_code}): ${body:0:200}"
|
||||||
|
_prl_log "PR #${pr_num} merge failed: ${_PR_MERGE_ERROR}"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# pr_is_merged — Check if a PR is merged.
|
||||||
|
# Args: pr_number
|
||||||
|
# Returns: 0=merged, 1=not merged
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
pr_is_merged() {
|
||||||
|
local pr_num="$1"
|
||||||
|
local merged
|
||||||
|
merged=$(forge_api GET "/pulls/${pr_num}" | jq -r '.merged // false') || true
|
||||||
|
[ "$merged" = "true" ]
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# pr_walk_to_merge — Walk a PR through CI, review, and merge.
|
||||||
|
#
|
||||||
|
# Requires agent_run() defined by the caller (synchronous Claude invocation).
|
||||||
|
# The orchestrator bash loop IS the state machine — no phase files needed.
|
||||||
|
#
|
||||||
|
# Args: pr_number session_id worktree [max_ci_fixes=3] [max_review_rounds=5]
|
||||||
|
# Returns: 0=merged, 1=exhausted or unrecoverable failure
|
||||||
|
# Sets: _PR_WALK_EXIT_REASON
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# shellcheck disable=SC2034 # _PR_WALK_EXIT_REASON read by callers
|
||||||
|
pr_walk_to_merge() {
|
||||||
|
local pr_num="$1" session_id="$2" worktree="$3"
|
||||||
|
local max_ci_fixes="${4:-3}" max_review_rounds="${5:-5}"
|
||||||
|
local ci_fix_count=0 ci_retry_count=0 review_round=0
|
||||||
|
local rc=0 remote="${FORGE_REMOTE:-origin}"
|
||||||
|
|
||||||
|
_PR_WALK_EXIT_REASON=""
|
||||||
|
_prl_log "walking PR #${pr_num} to merge (max CI: ${max_ci_fixes}, max review: ${max_review_rounds})"
|
||||||
|
|
||||||
|
while true; do
|
||||||
|
# ── Poll CI ────────────────────────────────────────────────────────
|
||||||
|
rc=0; pr_poll_ci "$pr_num" || rc=$?
|
||||||
|
|
||||||
|
if [ "$rc" -eq 2 ]; then
|
||||||
|
_PR_WALK_EXIT_REASON="ci_timeout"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$rc" -eq 1 ]; then
|
||||||
|
# Infra failure — retry once via empty commit + push
|
||||||
|
if [ "${_PR_CI_FAILURE_TYPE:-}" = "infra" ] && [ "$ci_retry_count" -lt 1 ]; then
|
||||||
|
ci_retry_count=$((ci_retry_count + 1))
|
||||||
|
_prl_log "infra failure — retriggering CI (retry ${ci_retry_count})"
|
||||||
|
( cd "$worktree" && \
|
||||||
|
git commit --allow-empty -m "ci: retrigger after infra failure" --no-verify && \
|
||||||
|
git fetch "$remote" "${PRIMARY_BRANCH}" 2>/dev/null && \
|
||||||
|
git rebase "${remote}/${PRIMARY_BRANCH}" && \
|
||||||
|
git push --force-with-lease "$remote" HEAD ) 2>&1 | tail -5 || true
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
ci_fix_count=$((ci_fix_count + 1))
|
||||||
|
if [ "$ci_fix_count" -gt "$max_ci_fixes" ]; then
|
||||||
|
_prl_log "CI fix budget exhausted (${ci_fix_count}/${max_ci_fixes})"
|
||||||
|
_PR_WALK_EXIT_REASON="ci_exhausted"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
_prl_log "CI failed — invoking agent (attempt ${ci_fix_count}/${max_ci_fixes})"
|
||||||
|
agent_run --resume "$session_id" --worktree "$worktree" \
|
||||||
|
"CI failed on PR #${pr_num} (attempt ${ci_fix_count}/${max_ci_fixes}).
|
||||||
|
|
||||||
|
Pipeline: #${_PR_CI_PIPELINE:-?}
|
||||||
|
Failure type: ${_PR_CI_FAILURE_TYPE:-unknown}
|
||||||
|
|
||||||
|
Error log:
|
||||||
|
${_PR_CI_ERROR_LOG:-No logs available.}
|
||||||
|
|
||||||
|
Fix the issue, run tests, commit, rebase on ${PRIMARY_BRANCH}, and push:
|
||||||
|
git fetch ${remote} ${PRIMARY_BRANCH} && git rebase ${remote}/${PRIMARY_BRANCH}
|
||||||
|
git push --force-with-lease ${remote} HEAD" || true
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
# CI passed — reset fix budget
|
||||||
|
ci_fix_count=0
|
||||||
|
|
||||||
|
# ── Poll review ──────────────────────────────────────────────────────
|
||||||
|
rc=0; pr_poll_review "$pr_num" || rc=$?
|
||||||
|
|
||||||
|
if [ "$rc" -eq 1 ]; then
|
||||||
|
_PR_WALK_EXIT_REASON="review_timeout"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$rc" -eq 2 ]; then
|
||||||
|
if [ "$_PR_REVIEW_VERDICT" = "MERGED_EXTERNALLY" ]; then
|
||||||
|
_PR_WALK_EXIT_REASON="merged"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
_PR_WALK_EXIT_REASON="closed_externally"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
case "$_PR_REVIEW_VERDICT" in
|
||||||
|
APPROVE)
|
||||||
|
# ── Merge ──────────────────────────────────────────────────────
|
||||||
|
rc=0; pr_merge "$pr_num" || rc=$?
|
||||||
|
if [ "$rc" -eq 0 ]; then
|
||||||
|
_PR_WALK_EXIT_REASON="merged"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$rc" -eq 2 ]; then
|
||||||
|
_PR_WALK_EXIT_REASON="merge_blocked"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
# Merge failed (conflict) — ask agent to rebase
|
||||||
|
_prl_log "merge failed — invoking agent to rebase"
|
||||||
|
agent_run --resume "$session_id" --worktree "$worktree" \
|
||||||
|
"PR #${pr_num} approved but merge failed: ${_PR_MERGE_ERROR:-unknown}
|
||||||
|
|
||||||
|
Rebase onto ${PRIMARY_BRANCH} and push:
|
||||||
|
git fetch ${remote} ${PRIMARY_BRANCH} && git rebase ${remote}/${PRIMARY_BRANCH}
|
||||||
|
git push --force-with-lease ${remote} HEAD" || true
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
|
||||||
|
REQUEST_CHANGES|DISCUSS)
|
||||||
|
review_round=$((review_round + 1))
|
||||||
|
if [ "$review_round" -gt "$max_review_rounds" ]; then
|
||||||
|
_prl_log "review budget exhausted (${review_round}/${max_review_rounds})"
|
||||||
|
_PR_WALK_EXIT_REASON="review_exhausted"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
ci_fix_count=0 # Reset CI fix budget per review cycle
|
||||||
|
|
||||||
|
_prl_log "review changes requested (round ${review_round}/${max_review_rounds})"
|
||||||
|
agent_run --resume "$session_id" --worktree "$worktree" \
|
||||||
|
"Review feedback (round ${review_round}/${max_review_rounds}) on PR #${pr_num}:
|
||||||
|
|
||||||
|
${_PR_REVIEW_TEXT:-No review text available.}
|
||||||
|
|
||||||
|
Address each piece of feedback. Run lint and tests.
|
||||||
|
Commit, rebase on ${PRIMARY_BRANCH}, and push:
|
||||||
|
git fetch ${remote} ${PRIMARY_BRANCH} && git rebase ${remote}/${PRIMARY_BRANCH}
|
||||||
|
git push --force-with-lease ${remote} HEAD" || true
|
||||||
|
continue
|
||||||
|
;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
_prl_log "unexpected verdict: ${_PR_REVIEW_VERDICT:-empty}"
|
||||||
|
_PR_WALK_EXIT_REASON="unexpected_verdict"
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# build_phase_protocol_prompt — Generate push/commit instructions for Claude.
|
||||||
|
#
|
||||||
|
# For the synchronous agent_run architecture: tells Claude how to commit and
|
||||||
|
# push (no phase files). For the tmux session architecture, use the
|
||||||
|
# build_phase_protocol_prompt in dev/phase-handler.sh instead.
|
||||||
|
#
|
||||||
|
# Args: branch [remote]
|
||||||
|
# Stdout: instruction text
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
build_phase_protocol_prompt() {
|
||||||
|
local branch="$1" remote="${2:-${FORGE_REMOTE:-origin}}"
|
||||||
|
cat <<_PRL_PROMPT_EOF_
|
||||||
|
## Git workflow
|
||||||
|
|
||||||
|
After implementing changes:
|
||||||
|
1. Stage and commit with a descriptive message.
|
||||||
|
2. Rebase on the target branch before pushing:
|
||||||
|
git fetch ${remote} ${PRIMARY_BRANCH} && git rebase ${remote}/${PRIMARY_BRANCH}
|
||||||
|
3. Push your branch:
|
||||||
|
git push ${remote} ${branch}
|
||||||
|
If rejected, use: git push --force-with-lease ${remote} ${branch}
|
||||||
|
|
||||||
|
If you encounter rebase conflicts:
|
||||||
|
1. Resolve conflicts in the affected files.
|
||||||
|
2. Stage resolved files: git add <files>
|
||||||
|
3. Continue rebase: git rebase --continue
|
||||||
|
4. Push with --force-with-lease.
|
||||||
|
_PRL_PROMPT_EOF_
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue