#!/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] [api_url] # Stdout: PR number # Returns: 0=created (or found existing), 1=failed # api_url defaults to FORGE_API if not provided # --------------------------------------------------------------------------- pr_create() { local branch="$1" title="$2" body="$3" local base="${4:-${PRIMARY_BRANCH:-main}}" local api_url="${5:-${FORGE_API}}" 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" \ "${api_url}/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" "$api_url") || 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 [api_url] # Stdout: PR number # Returns: 0=found, 1=not found # api_url defaults to FORGE_API if not provided # --------------------------------------------------------------------------- pr_find_by_branch() { local branch="$1" local api_url="${2:-${FORGE_API}}" local pr_num pr_num=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ "${api_url}/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 () local review_comment review_text="" verdict="" review_comment=$(forge_api_all "/issues/${pr_num}/comments" | \ jq -r --arg sha "${sha:-}" \ '[.[] | select(.body | contains("