diff --git a/.woodpecker/agent-smoke.sh b/.woodpecker/agent-smoke.sh index dd8bf6a..94e9258 100644 --- a/.woodpecker/agent-smoke.sh +++ b/.woodpecker/agent-smoke.sh @@ -96,6 +96,7 @@ echo "=== 2/2 Function resolution ===" # Included — these are inline-sourced by agent scripts: # lib/env.sh — sourced by every agent (log, forge_api, etc.) # lib/agent-session.sh — sourced by orchestrators (create_agent_session, monitor_phase_loop, etc.) +# lib/agent-sdk.sh — sourced by SDK agents (agent_run, agent_recover_session) # lib/ci-helpers.sh — sourced by pollers and review (ci_passed, classify_pipeline_failure, etc.) # lib/load-project.sh — sourced by env.sh when PROJECT_TOML is set # lib/file-action-issue.sh — sourced by gardener-run.sh (file_action_issue) @@ -115,7 +116,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 lib/pr-lifecycle.sh lib/issue-lifecycle.sh lib/worktree.sh; do + for f in lib/agent-session.sh lib/agent-sdk.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 lib/issue-lifecycle.sh lib/worktree.sh; do if [ -f "$f" ]; then get_fns "$f"; fi done | sort -u ) @@ -180,6 +181,7 @@ check_script() { # but this verifies calls *within* each lib file are also resolvable. check_script lib/env.sh lib/mirrors.sh check_script lib/agent-session.sh +check_script lib/agent-sdk.sh check_script lib/ci-helpers.sh check_script lib/secret-scan.sh check_script lib/file-action-issue.sh lib/secret-scan.sh @@ -203,7 +205,7 @@ check_script dev/phase-handler.sh action/action-agent.sh lib/secret-scan.sh check_script dev/dev-poll.sh check_script dev/phase-test.sh check_script gardener/gardener-run.sh -check_script review/review-pr.sh lib/agent-session.sh +check_script review/review-pr.sh lib/agent-sdk.sh check_script review/review-poll.sh check_script planner/planner-run.sh lib/agent-session.sh lib/formula-session.sh check_script supervisor/supervisor-poll.sh diff --git a/bin/disinto b/bin/disinto index 3ec1ce0..ef6924d 100755 --- a/bin/disinto +++ b/bin/disinto @@ -260,10 +260,37 @@ services: networks: - disinto-net + # Edge proxy — reverse proxy to Forgejo, Woodpecker, and staging + # Serves on ports 80/443, routes based on path + edge: + image: caddy:alpine + ports: + - "80:80" + - "443:443" + volumes: + - ./docker/Caddyfile:/etc/caddy/Caddyfile + - caddy_data:/data + depends_on: + - forgejo + - woodpecker + - staging + networks: + - disinto-net + + # Staging container — static file server for staging artifacts + # Edge proxy routes to this container for default requests + staging: + image: caddy:alpine + command: ["caddy", "file-server", "--root", "/srv/site"] + volumes: + - ./docker:/srv/site:ro + networks: + - disinto-net + # Staging deployment slot — activated by Woodpecker staging pipeline (#755). # Profile-gated: only starts when explicitly targeted by deploy commands. # Customize image/ports/volumes for your project after init. - staging: + staging-deploy: image: alpine:3 profiles: ["staging"] security_opt: @@ -279,6 +306,7 @@ volumes: woodpecker-data: agent-data: project-repos: + caddy_data: networks: disinto-net: @@ -321,6 +349,95 @@ generate_agent_docker() { fi } +# Generate docker/Caddyfile template for edge proxy. +generate_caddyfile() { + local docker_dir="${FACTORY_ROOT}/docker" + local caddyfile="${docker_dir}/Caddyfile" + + if [ -f "$caddyfile" ]; then + echo "Caddyfile: ${caddyfile} (already exists, skipping)" + return + fi + + cat > "$caddyfile" <<'CADDYFILEEOF' +# Caddyfile — edge proxy configuration +# IP-only binding at bootstrap; domain + TLS added later via vault resource request + +:80 { + # Reverse proxy to Forgejo + handle /forgejo/* { + reverse_proxy forgejo:3000 + } + + # Reverse proxy to Woodpecker CI + handle /ci/* { + reverse_proxy woodpecker:8000 + } + + # Default: proxy to staging container + handle { + reverse_proxy staging:80 + } +} +CADDYFILEEOF + + echo "Created: ${caddyfile}" +} + +# Generate docker/index.html default page. +generate_staging_index() { + local docker_dir="${FACTORY_ROOT}/docker" + local index_file="${docker_dir}/index.html" + + if [ -f "$index_file" ]; then + echo "Staging: ${index_file} (already exists, skipping)" + return + fi + + cat > "$index_file" <<'INDEXEOF' + + + + + + Nothing shipped yet + + + +
+

Nothing shipped yet

+

CI pipelines will update this page with your staging artifacts.

+
+ + +INDEXEOF + + echo "Created: ${index_file}" +} + # Generate template .woodpecker/ deployment pipeline configs in a project repo. # Creates staging.yml and production.yml alongside the project's existing CI config. # These pipelines trigger on Woodpecker's deployment event with environment filters. @@ -1599,6 +1716,8 @@ p.write_text(text) forge_port="${forge_port:-3000}" generate_compose "$forge_port" generate_agent_docker + generate_caddyfile + generate_staging_index # Create empty .env so docker compose can parse the agents service # env_file reference before setup_forge generates the real tokens (#769) touch "${FACTORY_ROOT}/.env" diff --git a/dev/dev-agent.sh b/dev/dev-agent.sh index bd33136..3a78f53 100755 --- a/dev/dev-agent.sh +++ b/dev/dev-agent.sh @@ -29,6 +29,7 @@ source "$(dirname "$0")/../lib/issue-lifecycle.sh" source "$(dirname "$0")/../lib/worktree.sh" source "$(dirname "$0")/../lib/pr-lifecycle.sh" source "$(dirname "$0")/../lib/mirrors.sh" +source "$(dirname "$0")/../lib/agent-sdk.sh" # Auto-pull factory code to pick up merged fixes before any logic runs git -C "$FACTORY_ROOT" pull --ff-only origin main 2>/dev/null || true @@ -56,43 +57,6 @@ status() { log "$*" } -# ============================================================================= -# agent_run — synchronous Claude invocation (one-shot claude -p) -# ============================================================================= -# Usage: agent_run [--resume SESSION_ID] [--worktree DIR] PROMPT -# Sets: _AGENT_SESSION_ID (updated each call, persisted to SID_FILE) -_AGENT_SESSION_ID="" - -agent_run() { - local resume_id="" worktree_dir="" - while [[ "${1:-}" == --* ]]; do - case "$1" in - --resume) shift; resume_id="${1:-}"; shift ;; - --worktree) shift; worktree_dir="${1:-}"; shift ;; - *) shift ;; - esac - done - local prompt="${1:-}" - - local -a args=(-p "$prompt" --output-format json --dangerously-skip-permissions --max-turns 200) - [ -n "$resume_id" ] && args+=(--resume "$resume_id") - [ -n "${CLAUDE_MODEL:-}" ] && args+=(--model "$CLAUDE_MODEL") - - local run_dir="${worktree_dir:-$(pwd)}" - local output - log "agent_run: starting (resume=${resume_id:-(new)}, dir=${run_dir})" - output=$(cd "$run_dir" && timeout "${CLAUDE_TIMEOUT:-7200}" claude "${args[@]}" 2>>"$LOGFILE") || true - - # Extract and persist session_id - local new_sid - new_sid=$(printf '%s' "$output" | jq -r '.session_id // empty' 2>/dev/null) || true - if [ -n "$new_sid" ]; then - _AGENT_SESSION_ID="$new_sid" - printf '%s' "$new_sid" > "$SID_FILE" - log "agent_run: session_id=${new_sid:0:12}..." - fi -} - # ============================================================================= # CLEANUP # ============================================================================= @@ -279,10 +243,7 @@ if [ -n "$PR_NUMBER" ]; then fi # Recover session_id from .sid file (crash recovery) -if [ -f "$SID_FILE" ]; then - _AGENT_SESSION_ID=$(cat "$SID_FILE") - log "recovered session_id: ${_AGENT_SESSION_ID:0:12}..." -fi +agent_recover_session # ============================================================================= # WORKTREE SETUP diff --git a/dev/dev-poll.sh b/dev/dev-poll.sh index bddd05f..98b8b7d 100755 --- a/dev/dev-poll.sh +++ b/dev/dev-poll.sh @@ -1,6 +1,9 @@ #!/usr/bin/env bash # dev-poll.sh — Pull-based scheduler: find the next ready issue and start dev-agent # +# SDK version: No tmux — checks PID lockfile for active agents. +# Uses pr_merge() and issue_block() from shared libraries. +# # Pull system: issues labeled "backlog" are candidates. An issue is READY when # ALL its dependency issues are closed (and their PRs merged). # No "todo" label needed — readiness is derived from reality. @@ -16,38 +19,39 @@ set -euo pipefail -# Load shared environment (with optional project TOML override) +# Load shared environment and libraries export PROJECT_TOML="${1:-}" source "$(dirname "$0")/../lib/env.sh" source "$(dirname "$0")/../lib/ci-helpers.sh" +# shellcheck source=../lib/pr-lifecycle.sh +source "$(dirname "$0")/../lib/pr-lifecycle.sh" +# shellcheck source=../lib/issue-lifecycle.sh +source "$(dirname "$0")/../lib/issue-lifecycle.sh" # shellcheck source=../lib/mirrors.sh source "$(dirname "$0")/../lib/mirrors.sh" # shellcheck source=../lib/guard.sh source "$(dirname "$0")/../lib/guard.sh" check_active dev -# Gitea labels API requires []int64 — look up the "underspecified" label ID once -UNDERSPECIFIED_LABEL_ID=$(forge_api GET "/labels" 2>/dev/null \ - | jq -r '.[] | select(.name == "underspecified") | .id' 2>/dev/null || true) -UNDERSPECIFIED_LABEL_ID="${UNDERSPECIFIED_LABEL_ID:-1300816}" +API="${FORGE_API}" +LOCKFILE="/tmp/dev-agent-${PROJECT_NAME:-default}.lock" +LOGFILE="${DISINTO_LOG_DIR}/dev/dev-agent-${PROJECT_NAME:-default}.log" +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -# Track CI fix attempts per PR to avoid infinite respawn loops +log() { + printf '[%s] poll: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE" +} + +# ============================================================================= +# CI FIX TRACKER: per-PR counter to avoid infinite respawn loops (max 3) +# ============================================================================= CI_FIX_TRACKER="${DISINTO_LOG_DIR}/dev/ci-fixes-${PROJECT_NAME:-default}.json" CI_FIX_LOCK="${CI_FIX_TRACKER}.lock" + ci_fix_count() { local pr="$1" flock "$CI_FIX_LOCK" python3 -c "import json,sys;d=json.load(open('$CI_FIX_TRACKER')) if __import__('os').path.exists('$CI_FIX_TRACKER') else {};print(d.get(str($pr),0))" 2>/dev/null || echo 0 } -ci_fix_increment() { - local pr="$1" - flock "$CI_FIX_LOCK" python3 -c " -import json,os -f='$CI_FIX_TRACKER' -d=json.load(open(f)) if os.path.exists(f) else {} -d[str($pr)]=d.get(str($pr),0)+1 -json.dump(d,open(f,'w')) -" 2>/dev/null || true -} ci_fix_reset() { local pr="$1" flock "$CI_FIX_LOCK" python3 -c " @@ -90,44 +94,14 @@ is_blocked() { | jq -e '.[] | select(.name == "blocked")' >/dev/null 2>&1 } -# Post a CI-exhaustion diagnostic comment and label issue as blocked. -# Args: issue_num pr_num attempts -_post_ci_blocked_comment() { - local issue_num="$1" pr_num="$2" attempts="$3" - local blocked_id - blocked_id=$(ensure_blocked_label_id) - [ -z "$blocked_id" ] && return 0 - - local comment - comment="### Session failure diagnostic - -| Field | Value | -|---|---| -| Exit reason | \`ci_exhausted_poll (${attempts} attempts)\` | -| Timestamp | \`$(date -u +%Y-%m-%dT%H:%M:%SZ)\` | -| PR | #${pr_num} |" - - curl -sf -X POST \ - -H "Authorization: token ${FORGE_TOKEN}" \ - -H "Content-Type: application/json" \ - "${FORGE_API}/issues/${issue_num}/comments" \ - -d "$(jq -nc --arg b "$comment" '{body:$b}')" >/dev/null 2>&1 || true - curl -sf -X POST \ - -H "Authorization: token ${FORGE_TOKEN}" \ - -H "Content-Type: application/json" \ - "${FORGE_API}/issues/${issue_num}/labels" \ - -d "{\"labels\":[${blocked_id}]}" >/dev/null 2>&1 || true -} - # ============================================================================= # HELPER: handle CI-exhaustion check/block (DRY for 3 call sites) # Sets CI_FIX_ATTEMPTS for caller use. Returns 0 if exhausted, 1 if not. +# Uses issue_block() from lib/issue-lifecycle.sh for blocking. # # Pass "check_only" as third arg for the backlog scan path: ok-counts are # returned without incrementing (deferred to launch time so a WAITING_PRS -# exit cannot waste a fix attempt). The 3→4 sentinel bump is always atomic -# regardless of mode, preventing duplicate blocked labels from concurrent -# pollers. +# exit cannot waste a fix attempt). The 3->4 sentinel bump is always atomic. # ============================================================================= handle_ci_exhaustion() { local pr_num="$1" issue_num="$2" @@ -141,11 +115,6 @@ handle_ci_exhaustion() { return 0 fi - # Single flock-protected call: read + threshold-check + conditional bump. - # In check_only mode, ok-counts are returned without incrementing (deferred - # to launch time). In both modes, the 3→4 sentinel bump is atomic, so only - # one concurrent poller can ever receive exhausted_first_time:3 and label - # the issue blocked. result=$(ci_fix_check_and_increment "$pr_num" "$check_only") case "$result" in ok:*) @@ -155,7 +124,7 @@ handle_ci_exhaustion() { exhausted_first_time:*) CI_FIX_ATTEMPTS="${result#exhausted_first_time:}" log "PR #${pr_num} (issue #${issue_num}) CI exhausted (${CI_FIX_ATTEMPTS} attempts) — marking blocked" - _post_ci_blocked_comment "$issue_num" "$pr_num" "$CI_FIX_ATTEMPTS" + issue_block "$issue_num" "ci_exhausted_poll (${CI_FIX_ATTEMPTS} attempts, PR #${pr_num})" ;; exhausted:*) CI_FIX_ATTEMPTS="${result#exhausted:}" @@ -170,7 +139,7 @@ handle_ci_exhaustion() { } # ============================================================================= -# HELPER: merge an approved PR directly (no Claude needed) +# HELPER: merge an approved PR directly via pr_merge() (no Claude needed) # # Merging an approved, CI-green PR is a single API call. Spawning dev-agent # for this fails when the issue is already closed (forge auto-closes issues @@ -181,30 +150,15 @@ try_direct_merge() { log "PR #${pr_num} (issue #${issue_num}) approved + CI green → attempting direct merge" - local merge_resp merge_http - merge_resp=$(curl -sf -w '\n%{http_code}' -X POST \ - -H "Authorization: token ${FORGE_TOKEN}" \ - -H 'Content-Type: application/json' \ - "${API}/pulls/${pr_num}/merge" \ - -d '{"Do":"merge","delete_branch_after_merge":true}' 2>/dev/null) || true - - merge_http=$(echo "$merge_resp" | tail -1) - - if [ "${merge_http:-0}" = "200" ] || [ "${merge_http:-0}" = "204" ]; then + if pr_merge "$pr_num"; then log "PR #${pr_num} merged successfully" if [ "$issue_num" -gt 0 ]; then - # Close the issue (may already be closed by forge auto-close) - curl -sf -X PATCH \ - -H "Authorization: token ${FORGE_TOKEN}" \ - -H 'Content-Type: application/json' \ - "${API}/issues/${issue_num}" \ - -d '{"state":"closed"}' >/dev/null 2>&1 || true - # Remove in-progress label + issue_close "$issue_num" + # Remove in-progress label (don't re-add backlog — issue is closed) curl -sf -X DELETE \ -H "Authorization: token ${FORGE_TOKEN}" \ "${API}/issues/${issue_num}/labels/in-progress" >/dev/null 2>&1 || true - # Clean up phase/session artifacts - rm -f "/tmp/dev-session-${PROJECT_NAME}-${issue_num}.phase" \ + rm -f "/tmp/dev-session-${PROJECT_NAME}-${issue_num}.sid" \ "/tmp/dev-impl-summary-${PROJECT_NAME}-${issue_num}.txt" fi # Pull merged primary branch and push to mirrors @@ -212,199 +166,68 @@ try_direct_merge() { git -C "${PROJECT_REPO_ROOT:-}" checkout "${PRIMARY_BRANCH:-}" 2>/dev/null || true git -C "${PROJECT_REPO_ROOT:-}" pull --ff-only origin "${PRIMARY_BRANCH:-}" 2>/dev/null || true mirror_push - # Clean up CI fix tracker ci_fix_reset "$pr_num" return 0 fi - log "PR #${pr_num} direct merge failed (HTTP ${merge_http:-?}) — falling back to dev-agent" + log "PR #${pr_num} direct merge failed — falling back to dev-agent" return 1 } # ============================================================================= -# HELPER: inject text into a tmux session via load-buffer + paste (#771) -# All tmux calls guarded with || true to prevent aborting under set -euo pipefail. -# Args: session text +# HELPER: extract issue number from PR branch/title/body # ============================================================================= -_inject_into_session() { - local session="$1" text="$2" - local tmpfile - tmpfile=$(mktemp /tmp/dev-poll-inject-XXXXXX) - printf '%s' "$text" > "$tmpfile" - tmux load-buffer -b "poll-inject-$$" "$tmpfile" || true - tmux paste-buffer -t "$session" -b "poll-inject-$$" || true - sleep 0.5 - tmux send-keys -t "$session" "" Enter || true - tmux delete-buffer -b "poll-inject-$$" 2>/dev/null || true - rm -f "$tmpfile" +extract_issue_from_pr() { + local branch="$1" title="$2" body="$3" + local issue + issue=$(echo "$branch" | grep -oP '(?<=fix/issue-)\d+' || true) + if [ -z "$issue" ]; then + issue=$(echo "$title" | grep -oP '#\K\d+' | tail -1 || true) + fi + if [ -z "$issue" ]; then + issue=$(echo "$body" | grep -oiP '(?:closes|fixes|resolves)\s*#\K\d+' | head -1 || true) + fi + printf '%s' "$issue" } # ============================================================================= -# HELPER: handle events for a running dev session (#771) -# -# When a tmux session is alive, check for injectable events instead of skipping. -# Handles: externally merged/closed PRs, CI results (awaiting_ci), and -# review feedback (awaiting_review). -# -# Args: session_name issue_num [pr_num] -# Sets: ACTIVE_SESSION_ACTION = "cleaned" | "injected" | "skip" +# DEPENDENCY HELPERS # ============================================================================= -# shellcheck disable=SC2034 # ACTIVE_SESSION_ACTION is read by callers -handle_active_session() { - local session="$1" issue_num="$2" pr_num="${3:-}" - local phase_file="/tmp/dev-session-${PROJECT_NAME}-${issue_num}.phase" - local sentinel="/tmp/dev-poll-injected-${PROJECT_NAME}-${issue_num}" - ACTIVE_SESSION_ACTION="skip" - - local phase - phase=$(head -1 "$phase_file" 2>/dev/null | tr -d '[:space:]' || true) - - local pr_json="" pr_sha="" pr_branch="" - - # --- Detect externally merged/closed PR --- - if [ -n "$pr_num" ]; then - local pr_state pr_merged - pr_json=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ - "${API}/pulls/${pr_num}") || true - pr_state=$(printf '%s' "$pr_json" | jq -r '.state // "unknown"') - pr_sha=$(printf '%s' "$pr_json" | jq -r '.head.sha // ""') - pr_branch=$(printf '%s' "$pr_json" | jq -r '.head.ref // ""') - - if [ "$pr_state" != "open" ]; then - pr_merged=$(printf '%s' "$pr_json" | jq -r '.merged // false') - tmux kill-session -t "$session" 2>/dev/null || true - rm -f "$phase_file" "/tmp/dev-impl-summary-${PROJECT_NAME}-${issue_num}.txt" "$sentinel" - if [ "$pr_merged" = "true" ]; then - curl -sf -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \ - -H "Content-Type: application/json" \ - "${API}/issues/${issue_num}" -d '{"state":"closed"}' >/dev/null 2>&1 || true - fi - curl -sf -X DELETE -H "Authorization: token ${FORGE_TOKEN}" \ - "${API}/issues/${issue_num}/labels/in-progress" >/dev/null 2>&1 || true - ci_fix_reset "$pr_num" - log "PR #${pr_num} (issue #${issue_num}) merged/closed externally — cleaned up session ${session}" - ACTIVE_SESSION_ACTION="cleaned" - return 0 - fi - else - # No PR number — check if a merged PR exists for this issue's branch - local closed_pr closed_merged - closed_pr=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ - "${API}/pulls?state=closed&limit=10" | \ - jq -r --arg branch "fix/issue-${issue_num}" \ - '.[] | select(.head.ref == $branch) | .number' | head -1) || true - if [ -n "$closed_pr" ]; then - closed_merged=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ - "${API}/pulls/${closed_pr}" | jq -r '.merged // false') || true - if [ "$closed_merged" = "true" ]; then - tmux kill-session -t "$session" 2>/dev/null || true - rm -f "$phase_file" "/tmp/dev-impl-summary-${PROJECT_NAME}-${issue_num}.txt" "$sentinel" - curl -sf -X PATCH -H "Authorization: token ${FORGE_TOKEN}" \ - -H "Content-Type: application/json" \ - "${API}/issues/${issue_num}" -d '{"state":"closed"}' >/dev/null 2>&1 || true - curl -sf -X DELETE -H "Authorization: token ${FORGE_TOKEN}" \ - "${API}/issues/${issue_num}/labels/in-progress" >/dev/null 2>&1 || true - log "issue #${issue_num} PR #${closed_pr} merged externally — cleaned up session ${session}" - ACTIVE_SESSION_ACTION="cleaned" - return 0 - fi - fi - return 0 # no PR — can't inject CI/review events +dep_is_merged() { + local dep_num="$1" + local dep_state + dep_state=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ + "${API}/issues/${dep_num}" | jq -r '.state // "open"') + if [ "$dep_state" != "closed" ]; then + return 1 fi - - # Sentinel: avoid re-injecting for the same SHA across poll cycles - local last_injected - last_injected=$(cat "$sentinel" 2>/dev/null || true) - if [ -n "$last_injected" ] && [ "$last_injected" = "$pr_sha" ]; then - log "already injected for ${session} SHA ${pr_sha:0:7} — skipping" - return 0 - fi - - # --- Inject CI result into awaiting_ci session --- - if [ "$phase" = "PHASE:awaiting_ci" ] && [ -n "$pr_sha" ]; then - local ci_state - ci_state=$(ci_commit_status "$pr_sha") || true - - if ci_passed "$ci_state"; then - _inject_into_session "$session" "CI passed on PR #${pr_num}. - -Write PHASE:awaiting_review to the phase file, then stop and wait for review feedback: - echo \"PHASE:awaiting_review\" > \"${phase_file}\"" - printf '%s' "$pr_sha" > "$sentinel" - log "injected CI success into session ${session} for PR #${pr_num}" - ACTIVE_SESSION_ACTION="injected" - return 0 - fi - - if ci_failed "$ci_state"; then - local pipeline_num error_log - pipeline_num=$(ci_pipeline_number "$pr_sha") || true - error_log="" - if [ -n "$pipeline_num" ]; then - error_log=$(bash "${FACTORY_ROOT}/lib/ci-debug.sh" failures "$pipeline_num" 2>/dev/null \ - | tail -80 | head -c 4000 || true) - fi - _inject_into_session "$session" "CI failed on PR #${pr_num} (pipeline #${pipeline_num:-?}). - -Error excerpt: -${error_log:-No logs available. Run: bash ${FACTORY_ROOT}/lib/ci-debug.sh failures ${pipeline_num:-0}} - -Fix the issue, commit, push, then write: - echo \"PHASE:awaiting_ci\" > \"${phase_file}\"" - printf '%s' "$pr_sha" > "$sentinel" - log "injected CI failure into session ${session} for PR #${pr_num}" - ACTIVE_SESSION_ACTION="injected" - return 0 - fi - fi - - # --- Inject review feedback into awaiting_review session --- - if [ "$phase" = "PHASE:awaiting_review" ] && [ -n "$pr_sha" ]; then - local reviews_json has_changes review_body - reviews_json=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \ - "${API}/pulls/${pr_num}/reviews") || true - has_changes=$(printf '%s' "$reviews_json" | \ - jq -r '[.[] | select(.state == "REQUEST_CHANGES") | select(.stale == false)] | length') || true - - if [ "${has_changes:-0}" -gt 0 ]; then - review_body=$(printf '%s' "$reviews_json" | \ - jq -r '[.[] | select(.state == "REQUEST_CHANGES") | select(.stale == false)] | last | .body // ""') || true - - # Prefer bot review comment if available (richer content) - local review_comment - review_comment=$(forge_api_all "/issues/${pr_num}/comments" | \ - jq -r --arg sha "$pr_sha" \ - '[.[] | select(.body | contains(""))]|length') @@ -61,6 +111,10 @@ HAS_CMT=$(printf '%s' "$ALL_COMMENTS" | jq --arg s "$PR_SHA" \ HAS_FML=$(forge_api_all "/pulls/${PR_NUMBER}/reviews" | jq --arg s "$PR_SHA" \ '[.[]|select(.commit_id==$s)|select(.state!="COMMENT")]|length') [ "${HAS_FML:-0}" -gt 0 ] && [ "$FORCE" != "--force" ] && { log "SKIP: formal review"; exit 0; } + +# ============================================================================= +# RE-REVIEW DETECTION +# ============================================================================= PREV_CONTEXT="" IS_RE_REVIEW=false PREV_SHA="" PREV_REV=$(printf '%s' "$ALL_COMMENTS" | jq -r --arg s "$PR_SHA" \ '[.[]|select(.body|contains("\nReview failed.\n---\n*${PR_SHA:0:7}*" \ @@ -162,11 +235,15 @@ if [ -z "$REVIEW_JSON" ]; then -H "Content-Type: application/json" "${API}/issues/${PR_NUMBER}/comments" -d @- || true exit 1 fi + VERDICT=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict' | tr '[:lower:]' '[:upper:]' | tr '-' '_') REASON=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict_reason // ""') REVIEW_MD=$(printf '%s' "$REVIEW_JSON" | jq -r '.review_markdown // ""') log "verdict: ${VERDICT}" +# ============================================================================= +# POST REVIEW +# ============================================================================= status "posting review" RTYPE="Review" if [ "$IS_RE_REVIEW" = true ]; then @@ -184,6 +261,9 @@ POST_RC=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ [ "$POST_RC" != "201" ] && { log "ERROR: comment HTTP ${POST_RC}"; exit 1; } log "posted review comment" +# ============================================================================= +# POST FORMAL REVIEW +# ============================================================================= REVENT="COMMENT" case "$VERDICT" in APPROVE) REVENT="APPROVED" ;; REQUEST_CHANGES|DISCUSS) REVENT="REQUEST_CHANGES" ;; esac if [ "$REVENT" = "APPROVED" ]; then @@ -204,10 +284,18 @@ curl -s -o /dev/null -X POST -H "Authorization: token ${FORGE_REVIEW_TOKEN}" \ --data-binary @"${REVIEW_TMPDIR}/formal.json" >/dev/null 2>&1 || true log "formal ${REVENT} submitted" +# ============================================================================= +# FINAL CLEANUP +# ============================================================================= case "$VERDICT" in - REQUEST_CHANGES|DISCUSS) printf 'PHASE:awaiting_changes\nSHA:%s\n' "$PR_SHA" > "$PHASE_FILE" ;; - *) rm -f "$PHASE_FILE" "$OUTPUT_FILE"; cd "${PROJECT_REPO_ROOT}" - git worktree remove "$WORKTREE" --force 2>/dev/null || true - rm -rf "$WORKTREE" 2>/dev/null || true ;; + REQUEST_CHANGES|DISCUSS) + # Keep session and worktree for re-review continuity + log "keeping session for re-review (SID: ${_AGENT_SESSION_ID:0:12}...)" + ;; + *) + rm -f "$SID_FILE" "$OUTPUT_FILE" + worktree_cleanup "$WORKTREE" + ;; esac + log "DONE: ${VERDICT} (re-review: ${IS_RE_REVIEW})"