diff --git a/.woodpecker/agent-smoke.sh b/.woodpecker/agent-smoke.sh index 14a4607..eed9512 100644 --- a/.woodpecker/agent-smoke.sh +++ b/.woodpecker/agent-smoke.sh @@ -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 # and add a check_script call for it in the lib files section further down. 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 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/mirrors.sh lib/env.sh check_script lib/guard.sh +check_script lib/pr-lifecycle.sh # Standalone lib scripts (not sourced by agents; run directly or as services). # Still checked for function resolution against LIB_FUNS + own definitions. diff --git a/lib/pr-lifecycle.sh b/lib/pr-lifecycle.sh new file mode 100644 index 0000000..ad6f0de --- /dev/null +++ b/lib/pr-lifecycle.sh @@ -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 () + local review_comment review_text="" verdict="" + review_comment=$(forge_api_all "/issues/${pr_num}/comments" | \ + jq -r --arg sha "${sha:-}" \ + '[.[] | select(.body | contains("