Compare commits
11 commits
27c5ab996d
...
2b8e250247
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b8e250247 | ||
|
|
6ab1aeb17c | ||
|
|
5adf34e695 | ||
|
|
1912a24c46 | ||
|
|
15f87ead85 | ||
|
|
d2c71e5dcd | ||
|
|
8f41230fa0 | ||
|
|
c2e95799a0 | ||
|
|
b9d657f5eb | ||
|
|
e8328fb297 | ||
|
|
8f93ea3af1 |
8 changed files with 527 additions and 932 deletions
|
|
@ -96,6 +96,7 @@ echo "=== 2/2 Function resolution ==="
|
||||||
# Included — these are inline-sourced by agent scripts:
|
# Included — these are inline-sourced by agent scripts:
|
||||||
# lib/env.sh — sourced by every agent (log, forge_api, etc.)
|
# 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-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/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/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)
|
# 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
|
# 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 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
|
if [ -f "$f" ]; then get_fns "$f"; fi
|
||||||
done | sort -u
|
done | sort -u
|
||||||
)
|
)
|
||||||
|
|
@ -180,6 +181,7 @@ check_script() {
|
||||||
# but this verifies calls *within* each lib file are also resolvable.
|
# but this verifies calls *within* each lib file are also resolvable.
|
||||||
check_script lib/env.sh lib/mirrors.sh
|
check_script lib/env.sh lib/mirrors.sh
|
||||||
check_script lib/agent-session.sh
|
check_script lib/agent-session.sh
|
||||||
|
check_script lib/agent-sdk.sh
|
||||||
check_script lib/ci-helpers.sh
|
check_script lib/ci-helpers.sh
|
||||||
check_script lib/secret-scan.sh
|
check_script lib/secret-scan.sh
|
||||||
check_script lib/file-action-issue.sh 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/dev-poll.sh
|
||||||
check_script dev/phase-test.sh
|
check_script dev/phase-test.sh
|
||||||
check_script gardener/gardener-run.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 review/review-poll.sh
|
||||||
check_script planner/planner-run.sh lib/agent-session.sh lib/formula-session.sh
|
check_script planner/planner-run.sh lib/agent-session.sh lib/formula-session.sh
|
||||||
check_script supervisor/supervisor-poll.sh
|
check_script supervisor/supervisor-poll.sh
|
||||||
|
|
|
||||||
121
bin/disinto
121
bin/disinto
|
|
@ -260,10 +260,37 @@ services:
|
||||||
networks:
|
networks:
|
||||||
- disinto-net
|
- 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).
|
# Staging deployment slot — activated by Woodpecker staging pipeline (#755).
|
||||||
# Profile-gated: only starts when explicitly targeted by deploy commands.
|
# Profile-gated: only starts when explicitly targeted by deploy commands.
|
||||||
# Customize image/ports/volumes for your project after init.
|
# Customize image/ports/volumes for your project after init.
|
||||||
staging:
|
staging-deploy:
|
||||||
image: alpine:3
|
image: alpine:3
|
||||||
profiles: ["staging"]
|
profiles: ["staging"]
|
||||||
security_opt:
|
security_opt:
|
||||||
|
|
@ -279,6 +306,7 @@ volumes:
|
||||||
woodpecker-data:
|
woodpecker-data:
|
||||||
agent-data:
|
agent-data:
|
||||||
project-repos:
|
project-repos:
|
||||||
|
caddy_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
disinto-net:
|
disinto-net:
|
||||||
|
|
@ -321,6 +349,95 @@ generate_agent_docker() {
|
||||||
fi
|
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'
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Nothing shipped yet</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Nothing shipped yet</h1>
|
||||||
|
<p>CI pipelines will update this page with your staging artifacts.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
INDEXEOF
|
||||||
|
|
||||||
|
echo "Created: ${index_file}"
|
||||||
|
}
|
||||||
|
|
||||||
# Generate template .woodpecker/ deployment pipeline configs in a project repo.
|
# Generate template .woodpecker/ deployment pipeline configs in a project repo.
|
||||||
# Creates staging.yml and production.yml alongside the project's existing CI config.
|
# Creates staging.yml and production.yml alongside the project's existing CI config.
|
||||||
# These pipelines trigger on Woodpecker's deployment event with environment filters.
|
# These pipelines trigger on Woodpecker's deployment event with environment filters.
|
||||||
|
|
@ -1599,6 +1716,8 @@ p.write_text(text)
|
||||||
forge_port="${forge_port:-3000}"
|
forge_port="${forge_port:-3000}"
|
||||||
generate_compose "$forge_port"
|
generate_compose "$forge_port"
|
||||||
generate_agent_docker
|
generate_agent_docker
|
||||||
|
generate_caddyfile
|
||||||
|
generate_staging_index
|
||||||
# Create empty .env so docker compose can parse the agents service
|
# Create empty .env so docker compose can parse the agents service
|
||||||
# env_file reference before setup_forge generates the real tokens (#769)
|
# env_file reference before setup_forge generates the real tokens (#769)
|
||||||
touch "${FACTORY_ROOT}/.env"
|
touch "${FACTORY_ROOT}/.env"
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ source "$(dirname "$0")/../lib/issue-lifecycle.sh"
|
||||||
source "$(dirname "$0")/../lib/worktree.sh"
|
source "$(dirname "$0")/../lib/worktree.sh"
|
||||||
source "$(dirname "$0")/../lib/pr-lifecycle.sh"
|
source "$(dirname "$0")/../lib/pr-lifecycle.sh"
|
||||||
source "$(dirname "$0")/../lib/mirrors.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
|
# 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
|
git -C "$FACTORY_ROOT" pull --ff-only origin main 2>/dev/null || true
|
||||||
|
|
@ -56,43 +57,6 @@ status() {
|
||||||
log "$*"
|
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
|
# CLEANUP
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -279,10 +243,7 @@ if [ -n "$PR_NUMBER" ]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Recover session_id from .sid file (crash recovery)
|
# Recover session_id from .sid file (crash recovery)
|
||||||
if [ -f "$SID_FILE" ]; then
|
agent_recover_session
|
||||||
_AGENT_SESSION_ID=$(cat "$SID_FILE")
|
|
||||||
log "recovered session_id: ${_AGENT_SESSION_ID:0:12}..."
|
|
||||||
fi
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# WORKTREE SETUP
|
# WORKTREE SETUP
|
||||||
|
|
|
||||||
518
dev/dev-poll.sh
518
dev/dev-poll.sh
|
|
@ -1,6 +1,9 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# dev-poll.sh — Pull-based scheduler: find the next ready issue and start dev-agent
|
# 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
|
# Pull system: issues labeled "backlog" are candidates. An issue is READY when
|
||||||
# ALL its dependency issues are closed (and their PRs merged).
|
# ALL its dependency issues are closed (and their PRs merged).
|
||||||
# No "todo" label needed — readiness is derived from reality.
|
# No "todo" label needed — readiness is derived from reality.
|
||||||
|
|
@ -16,38 +19,39 @@
|
||||||
|
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
# Load shared environment (with optional project TOML override)
|
# Load shared environment and libraries
|
||||||
export PROJECT_TOML="${1:-}"
|
export PROJECT_TOML="${1:-}"
|
||||||
source "$(dirname "$0")/../lib/env.sh"
|
source "$(dirname "$0")/../lib/env.sh"
|
||||||
source "$(dirname "$0")/../lib/ci-helpers.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
|
# shellcheck source=../lib/mirrors.sh
|
||||||
source "$(dirname "$0")/../lib/mirrors.sh"
|
source "$(dirname "$0")/../lib/mirrors.sh"
|
||||||
# shellcheck source=../lib/guard.sh
|
# shellcheck source=../lib/guard.sh
|
||||||
source "$(dirname "$0")/../lib/guard.sh"
|
source "$(dirname "$0")/../lib/guard.sh"
|
||||||
check_active dev
|
check_active dev
|
||||||
|
|
||||||
# Gitea labels API requires []int64 — look up the "underspecified" label ID once
|
API="${FORGE_API}"
|
||||||
UNDERSPECIFIED_LABEL_ID=$(forge_api GET "/labels" 2>/dev/null \
|
LOCKFILE="/tmp/dev-agent-${PROJECT_NAME:-default}.lock"
|
||||||
| jq -r '.[] | select(.name == "underspecified") | .id' 2>/dev/null || true)
|
LOGFILE="${DISINTO_LOG_DIR}/dev/dev-agent-${PROJECT_NAME:-default}.log"
|
||||||
UNDERSPECIFIED_LABEL_ID="${UNDERSPECIFIED_LABEL_ID:-1300816}"
|
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_TRACKER="${DISINTO_LOG_DIR}/dev/ci-fixes-${PROJECT_NAME:-default}.json"
|
||||||
CI_FIX_LOCK="${CI_FIX_TRACKER}.lock"
|
CI_FIX_LOCK="${CI_FIX_TRACKER}.lock"
|
||||||
|
|
||||||
ci_fix_count() {
|
ci_fix_count() {
|
||||||
local pr="$1"
|
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
|
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() {
|
ci_fix_reset() {
|
||||||
local pr="$1"
|
local pr="$1"
|
||||||
flock "$CI_FIX_LOCK" python3 -c "
|
flock "$CI_FIX_LOCK" python3 -c "
|
||||||
|
|
@ -90,44 +94,14 @@ is_blocked() {
|
||||||
| jq -e '.[] | select(.name == "blocked")' >/dev/null 2>&1
|
| 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)
|
# 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.
|
# 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
|
# 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
|
# 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
|
# exit cannot waste a fix attempt). The 3->4 sentinel bump is always atomic.
|
||||||
# regardless of mode, preventing duplicate blocked labels from concurrent
|
|
||||||
# pollers.
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
handle_ci_exhaustion() {
|
handle_ci_exhaustion() {
|
||||||
local pr_num="$1" issue_num="$2"
|
local pr_num="$1" issue_num="$2"
|
||||||
|
|
@ -141,11 +115,6 @@ handle_ci_exhaustion() {
|
||||||
return 0
|
return 0
|
||||||
fi
|
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")
|
result=$(ci_fix_check_and_increment "$pr_num" "$check_only")
|
||||||
case "$result" in
|
case "$result" in
|
||||||
ok:*)
|
ok:*)
|
||||||
|
|
@ -155,7 +124,7 @@ handle_ci_exhaustion() {
|
||||||
exhausted_first_time:*)
|
exhausted_first_time:*)
|
||||||
CI_FIX_ATTEMPTS="${result#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"
|
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:*)
|
exhausted:*)
|
||||||
CI_FIX_ATTEMPTS="${result#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
|
# 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
|
# 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"
|
log "PR #${pr_num} (issue #${issue_num}) approved + CI green → attempting direct merge"
|
||||||
|
|
||||||
local merge_resp merge_http
|
if pr_merge "$pr_num"; then
|
||||||
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
|
|
||||||
log "PR #${pr_num} merged successfully"
|
log "PR #${pr_num} merged successfully"
|
||||||
if [ "$issue_num" -gt 0 ]; then
|
if [ "$issue_num" -gt 0 ]; then
|
||||||
# Close the issue (may already be closed by forge auto-close)
|
issue_close "$issue_num"
|
||||||
curl -sf -X PATCH \
|
# Remove in-progress label (don't re-add backlog — issue is closed)
|
||||||
-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
|
|
||||||
curl -sf -X DELETE \
|
curl -sf -X DELETE \
|
||||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
-H "Authorization: token ${FORGE_TOKEN}" \
|
||||||
"${API}/issues/${issue_num}/labels/in-progress" >/dev/null 2>&1 || true
|
"${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}.sid" \
|
||||||
rm -f "/tmp/dev-session-${PROJECT_NAME}-${issue_num}.phase" \
|
|
||||||
"/tmp/dev-impl-summary-${PROJECT_NAME}-${issue_num}.txt"
|
"/tmp/dev-impl-summary-${PROJECT_NAME}-${issue_num}.txt"
|
||||||
fi
|
fi
|
||||||
# Pull merged primary branch and push to mirrors
|
# 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:-}" checkout "${PRIMARY_BRANCH:-}" 2>/dev/null || true
|
||||||
git -C "${PROJECT_REPO_ROOT:-}" pull --ff-only origin "${PRIMARY_BRANCH:-}" 2>/dev/null || true
|
git -C "${PROJECT_REPO_ROOT:-}" pull --ff-only origin "${PRIMARY_BRANCH:-}" 2>/dev/null || true
|
||||||
mirror_push
|
mirror_push
|
||||||
# Clean up CI fix tracker
|
|
||||||
ci_fix_reset "$pr_num"
|
ci_fix_reset "$pr_num"
|
||||||
return 0
|
return 0
|
||||||
fi
|
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
|
return 1
|
||||||
}
|
}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# HELPER: inject text into a tmux session via load-buffer + paste (#771)
|
# HELPER: extract issue number from PR branch/title/body
|
||||||
# All tmux calls guarded with || true to prevent aborting under set -euo pipefail.
|
|
||||||
# Args: session text
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
_inject_into_session() {
|
extract_issue_from_pr() {
|
||||||
local session="$1" text="$2"
|
local branch="$1" title="$2" body="$3"
|
||||||
local tmpfile
|
local issue
|
||||||
tmpfile=$(mktemp /tmp/dev-poll-inject-XXXXXX)
|
issue=$(echo "$branch" | grep -oP '(?<=fix/issue-)\d+' || true)
|
||||||
printf '%s' "$text" > "$tmpfile"
|
if [ -z "$issue" ]; then
|
||||||
tmux load-buffer -b "poll-inject-$$" "$tmpfile" || true
|
issue=$(echo "$title" | grep -oP '#\K\d+' | tail -1 || true)
|
||||||
tmux paste-buffer -t "$session" -b "poll-inject-$$" || true
|
fi
|
||||||
sleep 0.5
|
if [ -z "$issue" ]; then
|
||||||
tmux send-keys -t "$session" "" Enter || true
|
issue=$(echo "$body" | grep -oiP '(?:closes|fixes|resolves)\s*#\K\d+' | head -1 || true)
|
||||||
tmux delete-buffer -b "poll-inject-$$" 2>/dev/null || true
|
fi
|
||||||
rm -f "$tmpfile"
|
printf '%s' "$issue"
|
||||||
}
|
}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# HELPER: handle events for a running dev session (#771)
|
# DEPENDENCY HELPERS
|
||||||
#
|
|
||||||
# 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"
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# shellcheck disable=SC2034 # ACTIVE_SESSION_ACTION is read by callers
|
dep_is_merged() {
|
||||||
handle_active_session() {
|
local dep_num="$1"
|
||||||
local session="$1" issue_num="$2" pr_num="${3:-}"
|
local dep_state
|
||||||
local phase_file="/tmp/dev-session-${PROJECT_NAME}-${issue_num}.phase"
|
dep_state=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||||
local sentinel="/tmp/dev-poll-injected-${PROJECT_NAME}-${issue_num}"
|
"${API}/issues/${dep_num}" | jq -r '.state // "open"')
|
||||||
ACTIVE_SESSION_ACTION="skip"
|
if [ "$dep_state" != "closed" ]; then
|
||||||
|
return 1
|
||||||
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
|
|
||||||
fi
|
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("<!-- reviewed: " + $sha))] | last | .body // empty') || true
|
|
||||||
[ -n "$review_comment" ] && review_body="$review_comment"
|
|
||||||
|
|
||||||
_inject_into_session "$session" "Review: REQUEST_CHANGES on PR #${pr_num}:
|
|
||||||
|
|
||||||
${review_body:-Review requested changes but no details provided.}
|
|
||||||
|
|
||||||
Instructions:
|
|
||||||
1. Address each piece of feedback carefully.
|
|
||||||
2. Run lint and tests when done.
|
|
||||||
3. Commit your changes and push: git push origin ${pr_branch}
|
|
||||||
4. Write: echo \"PHASE:awaiting_ci\" > \"${phase_file}\"
|
|
||||||
5. Stop and wait for the next CI result."
|
|
||||||
printf '%s' "$pr_sha" > "$sentinel"
|
|
||||||
log "injected review feedback into session ${session} for PR #${pr_num}"
|
|
||||||
ACTIVE_SESSION_ACTION="injected"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
API="${FORGE_API}"
|
get_deps() {
|
||||||
LOCKFILE="/tmp/dev-agent-${PROJECT_NAME:-default}.lock"
|
local issue_body="$1"
|
||||||
LOGFILE="${DISINTO_LOG_DIR}/dev/dev-agent-${PROJECT_NAME:-default}.log"
|
echo "$issue_body" | bash "${FACTORY_ROOT}/lib/parse-deps.sh"
|
||||||
PREFLIGHT_RESULT="/tmp/dev-agent-preflight.json"
|
}
|
||||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
||||||
|
|
||||||
log() {
|
issue_is_ready() {
|
||||||
printf '[%s] poll: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE"
|
local issue_num="$1"
|
||||||
|
local issue_body="$2"
|
||||||
|
local deps
|
||||||
|
deps=$(get_deps "$issue_body")
|
||||||
|
|
||||||
|
if [ -z "$deps" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
while IFS= read -r dep; do
|
||||||
|
[ -z "$dep" ] && continue
|
||||||
|
if ! dep_is_merged "$dep"; then
|
||||||
|
log " #${issue_num} blocked: dep #${dep} not merged"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
done <<< "$deps"
|
||||||
|
|
||||||
|
return 0
|
||||||
}
|
}
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -426,14 +249,7 @@ for i in $(seq 0 $(($(echo "$PL_PRS" | jq 'length') - 1))); do
|
||||||
PL_PR_TITLE=$(echo "$PL_PRS" | jq -r ".[$i].title")
|
PL_PR_TITLE=$(echo "$PL_PRS" | jq -r ".[$i].title")
|
||||||
PL_PR_BODY=$(echo "$PL_PRS" | jq -r ".[$i].body // \"\"")
|
PL_PR_BODY=$(echo "$PL_PRS" | jq -r ".[$i].body // \"\"")
|
||||||
|
|
||||||
# Extract issue number from branch name, PR title, or PR body
|
PL_ISSUE=$(extract_issue_from_pr "$PL_PR_BRANCH" "$PL_PR_TITLE" "$PL_PR_BODY")
|
||||||
PL_ISSUE=$(echo "$PL_PR_BRANCH" | grep -oP '(?<=fix/issue-)\d+' || true)
|
|
||||||
if [ -z "$PL_ISSUE" ]; then
|
|
||||||
PL_ISSUE=$(echo "$PL_PR_TITLE" | grep -oP '#\K\d+' | tail -1 || true)
|
|
||||||
fi
|
|
||||||
if [ -z "$PL_ISSUE" ]; then
|
|
||||||
PL_ISSUE=$(echo "$PL_PR_BODY" | grep -oiP '(?:closes|fixes|resolves)\s*#\K\d+' | head -1 || true)
|
|
||||||
fi
|
|
||||||
if [ -z "$PL_ISSUE" ]; then
|
if [ -z "$PL_ISSUE" ]; then
|
||||||
# Allow chore PRs from gardener/planner/predictor to merge without issue number
|
# Allow chore PRs from gardener/planner/predictor to merge without issue number
|
||||||
if [[ "$PL_PR_BRANCH" =~ ^chore/(gardener|planner|predictor)- ]]; then
|
if [[ "$PL_PR_BRANCH" =~ ^chore/(gardener|planner|predictor)- ]]; then
|
||||||
|
|
@ -474,7 +290,7 @@ if [ "$PL_MERGED_ANY" = true ]; then
|
||||||
fi
|
fi
|
||||||
log "pre-lock: no PRs merged, checking agent lock"
|
log "pre-lock: no PRs merged, checking agent lock"
|
||||||
|
|
||||||
# --- Check if dev-agent already running ---
|
# --- Check if dev-agent already running (PID lockfile) ---
|
||||||
if [ -f "$LOCKFILE" ]; then
|
if [ -f "$LOCKFILE" ]; then
|
||||||
LOCK_PID=$(cat "$LOCKFILE" 2>/dev/null || echo "")
|
LOCK_PID=$(cat "$LOCKFILE" 2>/dev/null || echo "")
|
||||||
if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then
|
if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then
|
||||||
|
|
@ -487,61 +303,6 @@ fi
|
||||||
# --- Memory guard ---
|
# --- Memory guard ---
|
||||||
memory_guard 2000
|
memory_guard 2000
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# HELPER: check if a dependency issue is fully resolved (closed + PR merged)
|
|
||||||
# =============================================================================
|
|
||||||
dep_is_merged() {
|
|
||||||
local dep_num="$1"
|
|
||||||
|
|
||||||
# Check issue is closed
|
|
||||||
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
|
|
||||||
|
|
||||||
# Issue closed = dep satisfied. The scheduler only closes issues after
|
|
||||||
# merging, so closed state is trustworthy. No need to hunt for the
|
|
||||||
# specific PR — that was over-engineering that caused false negatives.
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# HELPER: extract dependency numbers from issue body
|
|
||||||
# =============================================================================
|
|
||||||
get_deps() {
|
|
||||||
local issue_body="$1"
|
|
||||||
# Shared parser: lib/parse-deps.sh (single source of truth)
|
|
||||||
echo "$issue_body" | bash "${FACTORY_ROOT}/lib/parse-deps.sh"
|
|
||||||
}
|
|
||||||
|
|
||||||
# =============================================================================
|
|
||||||
# HELPER: check if issue is ready (all deps merged)
|
|
||||||
# =============================================================================
|
|
||||||
issue_is_ready() {
|
|
||||||
local issue_num="$1"
|
|
||||||
local issue_body="$2"
|
|
||||||
|
|
||||||
local deps
|
|
||||||
deps=$(get_deps "$issue_body")
|
|
||||||
|
|
||||||
if [ -z "$deps" ]; then
|
|
||||||
# No dependencies — always ready
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
while IFS= read -r dep; do
|
|
||||||
[ -z "$dep" ] && continue
|
|
||||||
if ! dep_is_merged "$dep"; then
|
|
||||||
log " #${issue_num} blocked: dep #${dep} not merged"
|
|
||||||
return 1
|
|
||||||
fi
|
|
||||||
done <<< "$deps"
|
|
||||||
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# PRIORITY 1: orphaned in-progress issues
|
# PRIORITY 1: orphaned in-progress issues
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
@ -594,35 +355,20 @@ if [ "$ORPHAN_COUNT" -gt 0 ]; then
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
# Direct merge failed (conflicts?) — fall back to dev-agent
|
# Direct merge failed (conflicts?) — fall back to dev-agent
|
||||||
SESSION_NAME="dev-${PROJECT_NAME}-${ISSUE_NUM}"
|
log "falling back to dev-agent for PR #${HAS_PR} merge"
|
||||||
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
|
nohup "${SCRIPT_DIR}/dev-agent.sh" "$ISSUE_NUM" >> "$LOGFILE" 2>&1 &
|
||||||
handle_active_session "$SESSION_NAME" "$ISSUE_NUM" "$HAS_PR"
|
log "started dev-agent PID $! for issue #${ISSUE_NUM} (agent-merge)"
|
||||||
else
|
|
||||||
log "falling back to dev-agent for PR #${HAS_PR} merge"
|
|
||||||
nohup "${SCRIPT_DIR}/dev-agent.sh" "$ISSUE_NUM" >> "$LOGFILE" 2>&1 &
|
|
||||||
log "started dev-agent PID $! for issue #${ISSUE_NUM} (agent-merge)"
|
|
||||||
fi
|
|
||||||
exit 0
|
exit 0
|
||||||
|
|
||||||
# Do NOT gate REQUEST_CHANGES on ci_passed: act immediately even if CI is
|
# Do NOT gate REQUEST_CHANGES on ci_passed: act immediately even if CI is
|
||||||
# pending/unknown. Definitive CI failure is handled by the elif below.
|
# pending/unknown. Definitive CI failure is handled by the elif below.
|
||||||
elif [ "${HAS_CHANGES:-0}" -gt 0 ] && { ci_passed "$CI_STATE" || [ "$CI_STATE" = "pending" ] || [ "$CI_STATE" = "unknown" ] || [ -z "$CI_STATE" ]; }; then
|
elif [ "${HAS_CHANGES:-0}" -gt 0 ] && { ci_passed "$CI_STATE" || [ "$CI_STATE" = "pending" ] || [ "$CI_STATE" = "unknown" ] || [ -z "$CI_STATE" ]; }; then
|
||||||
SESSION_NAME="dev-${PROJECT_NAME}-${ISSUE_NUM}"
|
log "issue #${ISSUE_NUM} PR #${HAS_PR} has REQUEST_CHANGES — spawning agent"
|
||||||
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
|
nohup "${SCRIPT_DIR}/dev-agent.sh" "$ISSUE_NUM" >> "$LOGFILE" 2>&1 &
|
||||||
handle_active_session "$SESSION_NAME" "$ISSUE_NUM" "$HAS_PR"
|
log "started dev-agent PID $! for issue #${ISSUE_NUM} (review fix)"
|
||||||
else
|
|
||||||
log "issue #${ISSUE_NUM} PR #${HAS_PR} has REQUEST_CHANGES — spawning agent"
|
|
||||||
nohup "${SCRIPT_DIR}/dev-agent.sh" "$ISSUE_NUM" >> "$LOGFILE" 2>&1 &
|
|
||||||
log "started dev-agent PID $! for issue #${ISSUE_NUM} (review fix)"
|
|
||||||
fi
|
|
||||||
exit 0
|
exit 0
|
||||||
|
|
||||||
elif ci_failed "$CI_STATE"; then
|
elif ci_failed "$CI_STATE"; then
|
||||||
SESSION_NAME="dev-${PROJECT_NAME}-${ISSUE_NUM}"
|
|
||||||
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
|
|
||||||
handle_active_session "$SESSION_NAME" "$ISSUE_NUM" "$HAS_PR"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
if handle_ci_exhaustion "$HAS_PR" "$ISSUE_NUM" "check_only"; then
|
if handle_ci_exhaustion "$HAS_PR" "$ISSUE_NUM" "check_only"; then
|
||||||
# Fall through to backlog scan instead of exit
|
# Fall through to backlog scan instead of exit
|
||||||
:
|
:
|
||||||
|
|
@ -641,14 +387,9 @@ if [ "$ORPHAN_COUNT" -gt 0 ]; then
|
||||||
log "issue #${ISSUE_NUM} has open PR #${HAS_PR} (CI: ${CI_STATE}, waiting)"
|
log "issue #${ISSUE_NUM} has open PR #${HAS_PR} (CI: ${CI_STATE}, waiting)"
|
||||||
fi
|
fi
|
||||||
else
|
else
|
||||||
SESSION_NAME="dev-${PROJECT_NAME}-${ISSUE_NUM}"
|
log "recovering orphaned issue #${ISSUE_NUM} (no PR found)"
|
||||||
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
|
nohup "${SCRIPT_DIR}/dev-agent.sh" "$ISSUE_NUM" >> "$LOGFILE" 2>&1 &
|
||||||
handle_active_session "$SESSION_NAME" "$ISSUE_NUM" ""
|
log "started dev-agent PID $! for issue #${ISSUE_NUM} (recovery)"
|
||||||
else
|
|
||||||
log "recovering orphaned issue #${ISSUE_NUM} (no PR found)"
|
|
||||||
nohup "${SCRIPT_DIR}/dev-agent.sh" "$ISSUE_NUM" >> "$LOGFILE" 2>&1 &
|
|
||||||
log "started dev-agent PID $! for issue #${ISSUE_NUM} (recovery)"
|
|
||||||
fi
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
@ -664,17 +405,10 @@ for i in $(seq 0 $(($(echo "$OPEN_PRS" | jq 'length') - 1))); do
|
||||||
PR_NUM=$(echo "$OPEN_PRS" | jq -r ".[$i].number")
|
PR_NUM=$(echo "$OPEN_PRS" | jq -r ".[$i].number")
|
||||||
PR_BRANCH=$(echo "$OPEN_PRS" | jq -r ".[$i].head.ref")
|
PR_BRANCH=$(echo "$OPEN_PRS" | jq -r ".[$i].head.ref")
|
||||||
PR_SHA=$(echo "$OPEN_PRS" | jq -r ".[$i].head.sha")
|
PR_SHA=$(echo "$OPEN_PRS" | jq -r ".[$i].head.sha")
|
||||||
|
|
||||||
# Extract issue number from branch name (fix/issue-NNN), PR title (#NNN), or PR body (Closes #NNN)
|
|
||||||
PR_TITLE=$(echo "$OPEN_PRS" | jq -r ".[$i].title")
|
PR_TITLE=$(echo "$OPEN_PRS" | jq -r ".[$i].title")
|
||||||
PR_BODY=$(echo "$OPEN_PRS" | jq -r ".[$i].body // \"\"")
|
PR_BODY=$(echo "$OPEN_PRS" | jq -r ".[$i].body // \"\"")
|
||||||
STUCK_ISSUE=$(echo "$PR_BRANCH" | grep -oP '(?<=fix/issue-)\d+' || true)
|
|
||||||
if [ -z "$STUCK_ISSUE" ]; then
|
STUCK_ISSUE=$(extract_issue_from_pr "$PR_BRANCH" "$PR_TITLE" "$PR_BODY")
|
||||||
STUCK_ISSUE=$(echo "$PR_TITLE" | grep -oP '#\K\d+' | tail -1 || true)
|
|
||||||
fi
|
|
||||||
if [ -z "$STUCK_ISSUE" ]; then
|
|
||||||
STUCK_ISSUE=$(echo "$PR_BODY" | grep -oiP '(?:closes|fixes|resolves)\s*#\K\d+' | head -1 || true)
|
|
||||||
fi
|
|
||||||
if [ -z "$STUCK_ISSUE" ]; then
|
if [ -z "$STUCK_ISSUE" ]; then
|
||||||
# Allow chore PRs from gardener/planner/predictor to merge without issue number
|
# Allow chore PRs from gardener/planner/predictor to merge without issue number
|
||||||
if [[ "$PR_BRANCH" =~ ^chore/(gardener|planner|predictor)- ]]; then
|
if [[ "$PR_BRANCH" =~ ^chore/(gardener|planner|predictor)- ]]; then
|
||||||
|
|
@ -712,14 +446,9 @@ for i in $(seq 0 $(($(echo "$OPEN_PRS" | jq 'length') - 1))); do
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
# Direct merge failed (conflicts?) — fall back to dev-agent
|
# Direct merge failed (conflicts?) — fall back to dev-agent
|
||||||
SESSION_NAME="dev-${PROJECT_NAME}-${STUCK_ISSUE}"
|
log "falling back to dev-agent for PR #${PR_NUM} merge"
|
||||||
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
|
nohup "${SCRIPT_DIR}/dev-agent.sh" "$STUCK_ISSUE" >> "$LOGFILE" 2>&1 &
|
||||||
handle_active_session "$SESSION_NAME" "$STUCK_ISSUE" "$PR_NUM"
|
log "started dev-agent PID $! for stuck PR #${PR_NUM} (agent-merge)"
|
||||||
else
|
|
||||||
log "falling back to dev-agent for PR #${PR_NUM} merge"
|
|
||||||
nohup "${SCRIPT_DIR}/dev-agent.sh" "$STUCK_ISSUE" >> "$LOGFILE" 2>&1 &
|
|
||||||
log "started dev-agent PID $! for stuck PR #${PR_NUM} (agent-merge)"
|
|
||||||
fi
|
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -728,27 +457,13 @@ for i in $(seq 0 $(($(echo "$OPEN_PRS" | jq 'length') - 1))); do
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Stuck: REQUEST_CHANGES or CI failure → spawn agent
|
# Stuck: REQUEST_CHANGES or CI failure -> spawn agent
|
||||||
# Do NOT gate REQUEST_CHANGES on ci_passed: if a reviewer leaves REQUEST_CHANGES
|
|
||||||
# while CI is still pending/unknown, we must act immediately rather than wait for
|
|
||||||
# CI to settle. Definitive CI failure (non-pending, non-unknown) is handled by
|
|
||||||
# the elif below, so we only spawn here when CI has not definitively failed.
|
|
||||||
if [ "${HAS_CHANGES:-0}" -gt 0 ] && { ci_passed "$CI_STATE" || [ "$CI_STATE" = "pending" ] || [ "$CI_STATE" = "unknown" ] || [ -z "$CI_STATE" ]; }; then
|
if [ "${HAS_CHANGES:-0}" -gt 0 ] && { ci_passed "$CI_STATE" || [ "$CI_STATE" = "pending" ] || [ "$CI_STATE" = "unknown" ] || [ -z "$CI_STATE" ]; }; then
|
||||||
SESSION_NAME="dev-${PROJECT_NAME}-${STUCK_ISSUE}"
|
|
||||||
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
|
|
||||||
handle_active_session "$SESSION_NAME" "$STUCK_ISSUE" "$PR_NUM"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
log "PR #${PR_NUM} (issue #${STUCK_ISSUE}) has REQUEST_CHANGES — fixing first"
|
log "PR #${PR_NUM} (issue #${STUCK_ISSUE}) has REQUEST_CHANGES — fixing first"
|
||||||
nohup "${SCRIPT_DIR}/dev-agent.sh" "$STUCK_ISSUE" >> "$LOGFILE" 2>&1 &
|
nohup "${SCRIPT_DIR}/dev-agent.sh" "$STUCK_ISSUE" >> "$LOGFILE" 2>&1 &
|
||||||
log "started dev-agent PID $! for stuck PR #${PR_NUM}"
|
log "started dev-agent PID $! for stuck PR #${PR_NUM}"
|
||||||
exit 0
|
exit 0
|
||||||
elif ci_failed "$CI_STATE"; then
|
elif ci_failed "$CI_STATE"; then
|
||||||
SESSION_NAME="dev-${PROJECT_NAME}-${STUCK_ISSUE}"
|
|
||||||
if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then
|
|
||||||
handle_active_session "$SESSION_NAME" "$STUCK_ISSUE" "$PR_NUM"
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
if handle_ci_exhaustion "$PR_NUM" "$STUCK_ISSUE" "check_only"; then
|
if handle_ci_exhaustion "$PR_NUM" "$STUCK_ISSUE" "check_only"; then
|
||||||
continue # skip this PR, check next stuck PR or fall through to backlog
|
continue # skip this PR, check next stuck PR or fall through to backlog
|
||||||
fi
|
fi
|
||||||
|
|
@ -800,12 +515,13 @@ log "found ${BACKLOG_COUNT} backlog issues (${PRIORITY_COUNT} priority)"
|
||||||
|
|
||||||
# Check each for readiness
|
# Check each for readiness
|
||||||
READY_ISSUE=""
|
READY_ISSUE=""
|
||||||
|
READY_PR_FOR_INCREMENT=""
|
||||||
|
WAITING_PRS=""
|
||||||
for i in $(seq 0 $((BACKLOG_COUNT - 1))); do
|
for i in $(seq 0 $((BACKLOG_COUNT - 1))); do
|
||||||
ISSUE_NUM=$(echo "$BACKLOG_JSON" | jq -r ".[$i].number")
|
ISSUE_NUM=$(echo "$BACKLOG_JSON" | jq -r ".[$i].number")
|
||||||
ISSUE_BODY=$(echo "$BACKLOG_JSON" | jq -r ".[$i].body // \"\"")
|
ISSUE_BODY=$(echo "$BACKLOG_JSON" | jq -r ".[$i].body // \"\"")
|
||||||
|
|
||||||
# Formula guard: formula-labeled issues must not be picked up by dev-agent.
|
# Formula guard: formula-labeled issues must not be picked up by dev-agent.
|
||||||
# A formula issue that accidentally acquires the backlog label should be skipped.
|
|
||||||
ISSUE_LABELS=$(echo "$BACKLOG_JSON" | jq -r ".[$i].labels[].name" 2>/dev/null) || true
|
ISSUE_LABELS=$(echo "$BACKLOG_JSON" | jq -r ".[$i].labels[].name" 2>/dev/null) || true
|
||||||
SKIP_LABEL=$(echo "$ISSUE_LABELS" | grep -oE '^(formula|action|prediction/dismissed|prediction/unreviewed)$' | head -1) || true
|
SKIP_LABEL=$(echo "$ISSUE_LABELS" | grep -oE '^(formula|action|prediction/dismissed|prediction/unreviewed)$' | head -1) || true
|
||||||
if [ -n "$SKIP_LABEL" ]; then
|
if [ -n "$SKIP_LABEL" ]; then
|
||||||
|
|
@ -895,9 +611,6 @@ fi
|
||||||
# LAUNCH: start dev-agent for the ready issue
|
# LAUNCH: start dev-agent for the ready issue
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Deferred CI fix increment — only now that we're certain we are launching.
|
# Deferred CI fix increment — only now that we're certain we are launching.
|
||||||
# Uses the atomic ci_fix_check_and_increment (inside handle_ci_exhaustion) so
|
|
||||||
# the counter is bumped exactly once even under concurrent poll invocations,
|
|
||||||
# and a WAITING_PRS exit above cannot silently consume a fix attempt.
|
|
||||||
if [ -n "${READY_PR_FOR_INCREMENT:-}" ]; then
|
if [ -n "${READY_PR_FOR_INCREMENT:-}" ]; then
|
||||||
if handle_ci_exhaustion "$READY_PR_FOR_INCREMENT" "$READY_ISSUE"; then
|
if handle_ci_exhaustion "$READY_PR_FOR_INCREMENT" "$READY_ISSUE"; then
|
||||||
# exhausted (another poller incremented between scan and launch) — bail out
|
# exhausted (another poller incremented between scan and launch) — bail out
|
||||||
|
|
@ -906,52 +619,5 @@ if [ -n "${READY_PR_FOR_INCREMENT:-}" ]; then
|
||||||
fi
|
fi
|
||||||
|
|
||||||
log "launching dev-agent for #${READY_ISSUE}"
|
log "launching dev-agent for #${READY_ISSUE}"
|
||||||
rm -f "$PREFLIGHT_RESULT"
|
|
||||||
|
|
||||||
nohup "${SCRIPT_DIR}/dev-agent.sh" "$READY_ISSUE" >> "$LOGFILE" 2>&1 &
|
nohup "${SCRIPT_DIR}/dev-agent.sh" "$READY_ISSUE" >> "$LOGFILE" 2>&1 &
|
||||||
AGENT_PID=$!
|
log "started dev-agent PID $! for issue #${READY_ISSUE}"
|
||||||
|
|
||||||
# Wait briefly for preflight (agent writes result before claiming)
|
|
||||||
for _w in $(seq 1 30); do
|
|
||||||
if [ -f "$PREFLIGHT_RESULT" ]; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
if ! kill -0 "$AGENT_PID" 2>/dev/null; then
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ -f "$PREFLIGHT_RESULT" ]; then
|
|
||||||
PREFLIGHT_STATUS=$(jq -r '.status // "unknown"' < "$PREFLIGHT_RESULT")
|
|
||||||
rm -f "$PREFLIGHT_RESULT"
|
|
||||||
|
|
||||||
case "$PREFLIGHT_STATUS" in
|
|
||||||
ready)
|
|
||||||
log "dev-agent running for #${READY_ISSUE}"
|
|
||||||
;;
|
|
||||||
unmet_dependency)
|
|
||||||
log "#${READY_ISSUE} has code-level dependency (preflight blocked)"
|
|
||||||
wait "$AGENT_PID" 2>/dev/null || true
|
|
||||||
;;
|
|
||||||
too_large)
|
|
||||||
REASON=$(jq -r '.reason // "unspecified"' < "$PREFLIGHT_RESULT" 2>/dev/null || echo "unspecified")
|
|
||||||
log "#${READY_ISSUE} too large: ${REASON}"
|
|
||||||
# Label as underspecified
|
|
||||||
curl -sf -X POST -H "Authorization: token ${FORGE_TOKEN}" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
"${API}/issues/${READY_ISSUE}/labels" \
|
|
||||||
-d "{\"labels\":[${UNDERSPECIFIED_LABEL_ID}]}" >/dev/null 2>&1 || true
|
|
||||||
;;
|
|
||||||
already_done)
|
|
||||||
log "#${READY_ISSUE} already done"
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
log "#${READY_ISSUE} unknown preflight: ${PREFLIGHT_STATUS}"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
elif kill -0 "$AGENT_PID" 2>/dev/null; then
|
|
||||||
log "dev-agent running for #${READY_ISSUE} (passed preflight)"
|
|
||||||
else
|
|
||||||
log "dev-agent exited for #${READY_ISSUE} without preflight result"
|
|
||||||
fi
|
|
||||||
|
|
|
||||||
38
docker/index.html
Normal file
38
docker/index.html
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Nothing shipped yet</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
h1 {
|
||||||
|
font-size: 3rem;
|
||||||
|
margin: 0 0 1rem 0;
|
||||||
|
}
|
||||||
|
p {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<h1>Nothing shipped yet</h1>
|
||||||
|
<p>CI pipelines will update this page with your staging artifacts.</p>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
|
@ -1,10 +1,18 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# gardener-run.sh — Cron wrapper: gardener execution via Claude + formula
|
# gardener-run.sh — Cron wrapper: gardener execution via SDK + formula
|
||||||
#
|
#
|
||||||
# Runs 4x/day (or on-demand). Guards against concurrent runs and low memory.
|
# Synchronous bash loop using claude -p (one-shot invocation).
|
||||||
# Creates a tmux session with Claude (sonnet) reading formulas/run-gardener.toml.
|
# No tmux sessions, no phase files — the bash script IS the state machine.
|
||||||
# No action issues — the gardener is a nervous system component, not work (AD-001).
|
#
|
||||||
|
# Flow:
|
||||||
|
# 1. Guards: cron lock, memory check
|
||||||
|
# 2. Load formula (formulas/run-gardener.toml)
|
||||||
|
# 3. Build context: AGENTS.md, scratch file, prompt footer
|
||||||
|
# 4. agent_run(worktree, prompt) → Claude does maintenance, pushes if needed
|
||||||
|
# 5. If pushed: pr_walk_to_merge() from lib/pr-lifecycle.sh
|
||||||
|
# 6. Post-merge: execute pending actions manifest (gardener/pending-actions.json)
|
||||||
|
# 7. Mirror push
|
||||||
#
|
#
|
||||||
# Usage:
|
# Usage:
|
||||||
# gardener-run.sh [projects/disinto.toml] # project config (default: disinto)
|
# gardener-run.sh [projects/disinto.toml] # project config (default: disinto)
|
||||||
|
|
@ -22,8 +30,6 @@ export PROJECT_TOML="${1:-$FACTORY_ROOT/projects/disinto.toml}"
|
||||||
source "$FACTORY_ROOT/lib/env.sh"
|
source "$FACTORY_ROOT/lib/env.sh"
|
||||||
# Use gardener-bot's own Forgejo identity (#747)
|
# Use gardener-bot's own Forgejo identity (#747)
|
||||||
FORGE_TOKEN="${FORGE_GARDENER_TOKEN:-${FORGE_TOKEN}}"
|
FORGE_TOKEN="${FORGE_GARDENER_TOKEN:-${FORGE_TOKEN}}"
|
||||||
# shellcheck source=../lib/agent-session.sh
|
|
||||||
source "$FACTORY_ROOT/lib/agent-session.sh"
|
|
||||||
# shellcheck source=../lib/formula-session.sh
|
# shellcheck source=../lib/formula-session.sh
|
||||||
source "$FACTORY_ROOT/lib/formula-session.sh"
|
source "$FACTORY_ROOT/lib/formula-session.sh"
|
||||||
# shellcheck source=../lib/worktree.sh
|
# shellcheck source=../lib/worktree.sh
|
||||||
|
|
@ -34,26 +40,20 @@ source "$FACTORY_ROOT/lib/ci-helpers.sh"
|
||||||
source "$FACTORY_ROOT/lib/mirrors.sh"
|
source "$FACTORY_ROOT/lib/mirrors.sh"
|
||||||
# shellcheck source=../lib/guard.sh
|
# shellcheck source=../lib/guard.sh
|
||||||
source "$FACTORY_ROOT/lib/guard.sh"
|
source "$FACTORY_ROOT/lib/guard.sh"
|
||||||
|
# shellcheck source=../lib/agent-sdk.sh
|
||||||
|
source "$FACTORY_ROOT/lib/agent-sdk.sh"
|
||||||
|
# shellcheck source=../lib/pr-lifecycle.sh
|
||||||
|
source "$FACTORY_ROOT/lib/pr-lifecycle.sh"
|
||||||
|
|
||||||
LOG_FILE="$SCRIPT_DIR/gardener.log"
|
LOG_FILE="$SCRIPT_DIR/gardener.log"
|
||||||
# shellcheck disable=SC2034 # consumed by run_formula_and_monitor
|
# shellcheck disable=SC2034 # consumed by agent-sdk.sh
|
||||||
SESSION_NAME="gardener-${PROJECT_NAME}"
|
LOGFILE="$LOG_FILE"
|
||||||
PHASE_FILE="/tmp/gardener-session-${PROJECT_NAME}.phase"
|
# shellcheck disable=SC2034 # consumed by agent-sdk.sh
|
||||||
|
SID_FILE="/tmp/gardener-session-${PROJECT_NAME}.sid"
|
||||||
# shellcheck disable=SC2034 # read by monitor_phase_loop in lib/agent-session.sh
|
|
||||||
PHASE_POLL_INTERVAL=15
|
|
||||||
|
|
||||||
SCRATCH_FILE="/tmp/gardener-${PROJECT_NAME}-scratch.md"
|
SCRATCH_FILE="/tmp/gardener-${PROJECT_NAME}-scratch.md"
|
||||||
RESULT_FILE="/tmp/gardener-result-${PROJECT_NAME}.txt"
|
RESULT_FILE="/tmp/gardener-result-${PROJECT_NAME}.txt"
|
||||||
GARDENER_PR_FILE="/tmp/gardener-pr-${PROJECT_NAME}.txt"
|
GARDENER_PR_FILE="/tmp/gardener-pr-${PROJECT_NAME}.txt"
|
||||||
|
WORKTREE="/tmp/${PROJECT_NAME}-gardener-run"
|
||||||
# Merge-through state (used by _gardener_on_phase_change callback)
|
|
||||||
_GARDENER_PR=""
|
|
||||||
_GARDENER_MERGE_START=0
|
|
||||||
_GARDENER_MERGE_TIMEOUT=1800 # 30 min
|
|
||||||
_GARDENER_CI_FIX_COUNT=0
|
|
||||||
_GARDENER_REVIEW_ROUND=0
|
|
||||||
_GARDENER_CRASH_COUNT=0
|
|
||||||
|
|
||||||
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; }
|
log() { echo "[$(date -u +%Y-%m-%dT%H:%M:%S)Z] $*" >> "$LOG_FILE"; }
|
||||||
|
|
||||||
|
|
@ -72,7 +72,7 @@ build_context_block AGENTS.md
|
||||||
SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
|
SCRATCH_CONTEXT=$(read_scratch_context "$SCRATCH_FILE")
|
||||||
SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
|
SCRATCH_INSTRUCTION=$(build_scratch_instruction "$SCRATCH_FILE")
|
||||||
|
|
||||||
# ── Build prompt (manifest format reference for deferred actions) ─────────
|
# ── Build prompt ─────────────────────────────────────────────────────────
|
||||||
GARDENER_API_EXTRA="
|
GARDENER_API_EXTRA="
|
||||||
|
|
||||||
## Pending-actions manifest (REQUIRED)
|
## Pending-actions manifest (REQUIRED)
|
||||||
|
|
@ -91,28 +91,18 @@ Supported actions:
|
||||||
|
|
||||||
The commit-and-pr step converts JSONL to JSON array. The orchestrator executes
|
The commit-and-pr step converts JSONL to JSON array. The orchestrator executes
|
||||||
actions after the PR merges. Do NOT call mutation APIs directly during the run."
|
actions after the PR merges. Do NOT call mutation APIs directly during the run."
|
||||||
|
|
||||||
|
# Reuse shared footer (API reference + environment), replace phase protocol
|
||||||
|
# shellcheck disable=SC2034 # consumed by build_prompt_footer
|
||||||
|
PHASE_FILE="" # not used in SDK mode
|
||||||
build_prompt_footer "$GARDENER_API_EXTRA"
|
build_prompt_footer "$GARDENER_API_EXTRA"
|
||||||
|
PROMPT_FOOTER="${PROMPT_FOOTER%%## Phase protocol*}## Completion protocol (REQUIRED)
|
||||||
# Extend phase protocol with merge-through instructions for compaction survival
|
When the commit-and-pr step creates a PR, write the PR number and stop:
|
||||||
PROMPT_FOOTER="${PROMPT_FOOTER}
|
|
||||||
|
|
||||||
## Merge-through protocol (commit-and-pr step)
|
|
||||||
After creating the PR, write the PR number and signal CI:
|
|
||||||
echo \"\$PR_NUMBER\" > '${GARDENER_PR_FILE}'
|
echo \"\$PR_NUMBER\" > '${GARDENER_PR_FILE}'
|
||||||
echo 'PHASE:awaiting_ci' > '${PHASE_FILE}'
|
Then STOP. Do NOT write PHASE: signals — the orchestrator handles CI, review, and merge.
|
||||||
Then STOP and WAIT for CI results.
|
If no file changes exist (empty commit-and-pr), just stop — no PR needed."
|
||||||
When 'CI passed' is injected:
|
|
||||||
echo 'PHASE:awaiting_review' > '${PHASE_FILE}'
|
|
||||||
Then STOP and WAIT.
|
|
||||||
When 'CI failed' is injected:
|
|
||||||
Fix, commit, push, then: echo 'PHASE:awaiting_ci' > '${PHASE_FILE}'
|
|
||||||
When review feedback is injected:
|
|
||||||
Address all feedback, commit, push, then: echo 'PHASE:awaiting_ci' > '${PHASE_FILE}'
|
|
||||||
If no file changes in commit-and-pr:
|
|
||||||
echo 'PHASE:done' > '${PHASE_FILE}'"
|
|
||||||
|
|
||||||
# shellcheck disable=SC2034 # consumed by run_formula_and_monitor
|
PROMPT="You are the issue gardener for ${FORGE_REPO}. Work through the formula below.
|
||||||
PROMPT="You are the issue gardener for ${FORGE_REPO}. Work through the formula below. Follow the phase protocol: if the commit-and-pr step creates a PR, write PHASE:awaiting_ci and wait for orchestrator CI/review/merge handling. If no file changes, write PHASE:done. The orchestrator will time you out if you return to the prompt without signalling.
|
|
||||||
|
|
||||||
You have full shell access and --dangerously-skip-permissions.
|
You have full shell access and --dangerously-skip-permissions.
|
||||||
Fix what you can. File vault items for what you cannot. Do NOT ask permission — act first, report after.
|
Fix what you can. File vault items for what you cannot. Do NOT ask permission — act first, report after.
|
||||||
|
|
@ -130,14 +120,21 @@ ${FORMULA_CONTENT}
|
||||||
${SCRATCH_INSTRUCTION}
|
${SCRATCH_INSTRUCTION}
|
||||||
${PROMPT_FOOTER}"
|
${PROMPT_FOOTER}"
|
||||||
|
|
||||||
# ── Phase callback for merge-through ─────────────────────────────────────
|
# ── Create worktree ──────────────────────────────────────────────────────
|
||||||
# Handles CI polling, review injection, merge, and cleanup after PR creation.
|
cd "$PROJECT_REPO_ROOT"
|
||||||
# Lighter than dev/phase-handler.sh — tailored for gardener doc-only PRs.
|
git fetch origin "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||||
|
worktree_cleanup "$WORKTREE"
|
||||||
|
git worktree add "$WORKTREE" "origin/${PRIMARY_BRANCH}" --detach 2>/dev/null
|
||||||
|
|
||||||
# ── Post-merge manifest execution ─────────────────────────────────────
|
cleanup() {
|
||||||
|
worktree_cleanup "$WORKTREE"
|
||||||
|
rm -f "$GARDENER_PR_FILE"
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# ── Post-merge manifest execution ────────────────────────────────────────
|
||||||
# Reads gardener/pending-actions.json and executes each action via API.
|
# Reads gardener/pending-actions.json and executes each action via API.
|
||||||
# Failed actions are logged but do not block completion.
|
# Failed actions are logged but do not block completion.
|
||||||
# shellcheck disable=SC2317 # called indirectly via _gardener_merge
|
|
||||||
_gardener_execute_manifest() {
|
_gardener_execute_manifest() {
|
||||||
local manifest_file="$PROJECT_REPO_ROOT/gardener/pending-actions.json"
|
local manifest_file="$PROJECT_REPO_ROOT/gardener/pending-actions.json"
|
||||||
if [ ! -f "$manifest_file" ]; then
|
if [ ! -f "$manifest_file" ]; then
|
||||||
|
|
@ -295,387 +292,50 @@ _gardener_execute_manifest() {
|
||||||
log "manifest: execution complete (${count} actions processed)"
|
log "manifest: execution complete (${count} actions processed)"
|
||||||
}
|
}
|
||||||
|
|
||||||
# shellcheck disable=SC2317 # called indirectly by monitor_phase_loop
|
# ── Reset result file ────────────────────────────────────────────────────
|
||||||
_gardener_merge() {
|
rm -f "$RESULT_FILE" "$GARDENER_PR_FILE"
|
||||||
local merge_response merge_http_code
|
touch "$RESULT_FILE"
|
||||||
merge_response=$(curl -s -w "\n%{http_code}" -X POST \
|
|
||||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
"${FORGE_API}/pulls/${_GARDENER_PR}/merge" \
|
|
||||||
-d '{"Do":"merge","delete_branch_after_merge":true}') || true
|
|
||||||
merge_http_code=$(echo "$merge_response" | tail -1)
|
|
||||||
|
|
||||||
if [ "$merge_http_code" = "200" ] || [ "$merge_http_code" = "204" ]; then
|
# ── Run agent ─────────────────────────────────────────────────────────────
|
||||||
log "gardener PR #${_GARDENER_PR} merged"
|
export CLAUDE_MODEL="sonnet"
|
||||||
# Pull merged primary branch and push to mirrors
|
|
||||||
|
agent_run --worktree "$WORKTREE" "$PROMPT"
|
||||||
|
log "agent_run complete"
|
||||||
|
|
||||||
|
# ── Detect PR ─────────────────────────────────────────────────────────────
|
||||||
|
PR_NUMBER=""
|
||||||
|
if [ -f "$GARDENER_PR_FILE" ]; then
|
||||||
|
PR_NUMBER=$(tr -d '[:space:]' < "$GARDENER_PR_FILE")
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Fallback: search for open gardener PRs
|
||||||
|
if [ -z "$PR_NUMBER" ]; then
|
||||||
|
PR_NUMBER=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
||||||
|
"${FORGE_API}/pulls?state=open&limit=10" | \
|
||||||
|
jq -r '[.[] | select(.head.ref | startswith("chore/gardener-"))] | .[0].number // empty') || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ── Walk PR to merge ──────────────────────────────────────────────────────
|
||||||
|
if [ -n "$PR_NUMBER" ]; then
|
||||||
|
log "walking PR #${PR_NUMBER} to merge"
|
||||||
|
pr_walk_to_merge "$PR_NUMBER" "$_AGENT_SESSION_ID" "$WORKTREE" || true
|
||||||
|
|
||||||
|
if [ "$_PR_WALK_EXIT_REASON" = "merged" ]; then
|
||||||
|
# Post-merge: pull primary, mirror push, execute manifest
|
||||||
git -C "$PROJECT_REPO_ROOT" fetch origin "$PRIMARY_BRANCH" 2>/dev/null || true
|
git -C "$PROJECT_REPO_ROOT" fetch origin "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||||
git -C "$PROJECT_REPO_ROOT" checkout "$PRIMARY_BRANCH" 2>/dev/null || true
|
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
|
git -C "$PROJECT_REPO_ROOT" pull --ff-only origin "$PRIMARY_BRANCH" 2>/dev/null || true
|
||||||
mirror_push
|
mirror_push
|
||||||
_gardener_execute_manifest
|
_gardener_execute_manifest
|
||||||
printf 'PHASE:done\n' > "$PHASE_FILE"
|
rm -f "$SCRATCH_FILE"
|
||||||
return 0
|
log "gardener PR #${PR_NUMBER} merged — manifest executed"
|
||||||
fi
|
|
||||||
|
|
||||||
# Already merged (race)?
|
|
||||||
if [ "$merge_http_code" = "405" ]; then
|
|
||||||
local pr_merged
|
|
||||||
pr_merged=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
|
||||||
"${FORGE_API}/pulls/${_GARDENER_PR}" | jq -r '.merged // false') || true
|
|
||||||
if [ "$pr_merged" = "true" ]; then
|
|
||||||
log "gardener PR #${_GARDENER_PR} already merged"
|
|
||||||
# Pull merged primary branch and push to mirrors
|
|
||||||
git -C "$PROJECT_REPO_ROOT" fetch origin "$PRIMARY_BRANCH" 2>/dev/null || true
|
|
||||||
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
|
|
||||||
_gardener_execute_manifest
|
|
||||||
printf 'PHASE:done\n' > "$PHASE_FILE"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
log "gardener merge blocked (HTTP 405)"
|
|
||||||
printf 'PHASE:failed\nReason: gardener PR #%s merge blocked (HTTP 405)\n' \
|
|
||||||
"$_GARDENER_PR" > "$PHASE_FILE"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Other failure (likely conflicts) — tell Claude to rebase
|
|
||||||
log "gardener merge failed (HTTP ${merge_http_code}) — requesting rebase"
|
|
||||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
|
|
||||||
"Merge failed for PR #${_GARDENER_PR} (likely conflicts). Rebase and push:
|
|
||||||
git fetch origin ${PRIMARY_BRANCH} && git rebase origin/${PRIMARY_BRANCH}
|
|
||||||
git push --force-with-lease origin HEAD
|
|
||||||
echo \"PHASE:awaiting_ci\" > \"${PHASE_FILE}\"
|
|
||||||
If rebase fails, write PHASE:failed with a reason."
|
|
||||||
}
|
|
||||||
|
|
||||||
# shellcheck disable=SC2317 # called indirectly by monitor_phase_loop
|
|
||||||
_gardener_timeout_cleanup() {
|
|
||||||
log "gardener merge-through timed out (${_GARDENER_MERGE_TIMEOUT}s) — closing PR"
|
|
||||||
if [ -n "$_GARDENER_PR" ]; then
|
|
||||||
curl -sf -X PATCH \
|
|
||||||
-H "Authorization: token ${FORGE_TOKEN}" \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
"${FORGE_API}/pulls/${_GARDENER_PR}" \
|
|
||||||
-d '{"state":"closed"}' >/dev/null 2>&1 || true
|
|
||||||
fi
|
|
||||||
printf 'PHASE:failed\nReason: merge-through timeout (%ss)\n' \
|
|
||||||
"$_GARDENER_MERGE_TIMEOUT" > "$PHASE_FILE"
|
|
||||||
}
|
|
||||||
|
|
||||||
# shellcheck disable=SC2317 # called indirectly by monitor_phase_loop
|
|
||||||
_gardener_handle_ci() {
|
|
||||||
# Start merge-through timer on first CI phase
|
|
||||||
if [ "$_GARDENER_MERGE_START" -eq 0 ]; then
|
|
||||||
_GARDENER_MERGE_START=$(date +%s)
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check merge-through timeout
|
|
||||||
local elapsed
|
|
||||||
elapsed=$(( $(date +%s) - _GARDENER_MERGE_START ))
|
|
||||||
if [ "$elapsed" -ge "$_GARDENER_MERGE_TIMEOUT" ]; then
|
|
||||||
_gardener_timeout_cleanup
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Discover PR number if unknown
|
|
||||||
if [ -z "$_GARDENER_PR" ]; then
|
|
||||||
if [ -f "$GARDENER_PR_FILE" ]; then
|
|
||||||
_GARDENER_PR=$(tr -d '[:space:]' < "$GARDENER_PR_FILE")
|
|
||||||
fi
|
|
||||||
# Fallback: search for open gardener PRs
|
|
||||||
if [ -z "$_GARDENER_PR" ]; then
|
|
||||||
_GARDENER_PR=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
|
||||||
"${FORGE_API}/pulls?state=open&limit=10" | \
|
|
||||||
jq -r '[.[] | select(.head.ref | startswith("chore/gardener-"))] | .[0].number // empty') || true
|
|
||||||
fi
|
|
||||||
if [ -z "$_GARDENER_PR" ]; then
|
|
||||||
log "ERROR: cannot find gardener PR"
|
|
||||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
|
|
||||||
"ERROR: Could not find the gardener PR. Verify branch was pushed and PR created. Write the PR number to ${GARDENER_PR_FILE}, then write PHASE:awaiting_ci again."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
log "tracking gardener PR #${_GARDENER_PR}"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Skip CI for doc-only PRs
|
|
||||||
if ! ci_required_for_pr "$_GARDENER_PR" 2>/dev/null; then
|
|
||||||
log "CI not required (doc-only) — treating as passed"
|
|
||||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
|
|
||||||
"CI passed on PR #${_GARDENER_PR} (doc-only changes, CI not required).
|
|
||||||
Write PHASE:awaiting_review to the phase file, then stop and wait:
|
|
||||||
echo \"PHASE:awaiting_review\" > \"${PHASE_FILE}\""
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# No CI configured?
|
|
||||||
if [ "${WOODPECKER_REPO_ID:-2}" = "0" ]; then
|
|
||||||
log "no CI configured — treating as passed"
|
|
||||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
|
|
||||||
"CI passed on PR #${_GARDENER_PR} (no CI configured).
|
|
||||||
Write PHASE:awaiting_review to the phase file, then stop and wait:
|
|
||||||
echo \"PHASE:awaiting_review\" > \"${PHASE_FILE}\""
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Get HEAD SHA from PR
|
|
||||||
local head_sha
|
|
||||||
head_sha=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
|
||||||
"${FORGE_API}/pulls/${_GARDENER_PR}" | jq -r '.head.sha // empty') || true
|
|
||||||
|
|
||||||
if [ -z "$head_sha" ]; then
|
|
||||||
log "WARNING: could not get HEAD SHA for PR #${_GARDENER_PR}"
|
|
||||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
|
|
||||||
"WARNING: Could not read HEAD SHA for PR #${_GARDENER_PR}. Verify push succeeded. Then write PHASE:awaiting_ci again."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Poll CI (15 min max within this phase)
|
|
||||||
local ci_done=false ci_state="unknown" ci_elapsed=0 ci_timeout=900
|
|
||||||
while [ "$ci_elapsed" -lt "$ci_timeout" ]; do
|
|
||||||
sleep 30
|
|
||||||
ci_elapsed=$((ci_elapsed + 30))
|
|
||||||
|
|
||||||
# Session health check
|
|
||||||
if [ -f "/tmp/claude-exited-${_MONITOR_SESSION:-$SESSION_NAME}.ts" ] || \
|
|
||||||
! tmux has-session -t "${_MONITOR_SESSION:-$SESSION_NAME}" 2>/dev/null; then
|
|
||||||
log "session died during CI wait"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Merge-through timeout check
|
|
||||||
elapsed=$(( $(date +%s) - _GARDENER_MERGE_START ))
|
|
||||||
if [ "$elapsed" -ge "$_GARDENER_MERGE_TIMEOUT" ]; then
|
|
||||||
_gardener_timeout_cleanup
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Re-fetch HEAD in case Claude pushed new commits
|
|
||||||
head_sha=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
|
||||||
"${FORGE_API}/pulls/${_GARDENER_PR}" | jq -r '.head.sha // empty') || true
|
|
||||||
|
|
||||||
ci_state=$(ci_commit_status "$head_sha") || ci_state="unknown"
|
|
||||||
|
|
||||||
case "$ci_state" in
|
|
||||||
success|failure|error) ci_done=true; break ;;
|
|
||||||
esac
|
|
||||||
done
|
|
||||||
|
|
||||||
if ! $ci_done; then
|
|
||||||
log "CI timeout for PR #${_GARDENER_PR}"
|
|
||||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
|
|
||||||
"CI TIMEOUT: CI did not complete within 15 minutes for PR #${_GARDENER_PR}. Write PHASE:failed with a reason if you cannot proceed."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "CI: ${ci_state} for PR #${_GARDENER_PR}"
|
|
||||||
|
|
||||||
if [ "$ci_state" = "success" ]; then
|
|
||||||
_GARDENER_CI_FIX_COUNT=0
|
|
||||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
|
|
||||||
"CI passed on PR #${_GARDENER_PR}.
|
|
||||||
Write PHASE:awaiting_review to the phase file, then stop and wait:
|
|
||||||
echo \"PHASE:awaiting_review\" > \"${PHASE_FILE}\""
|
|
||||||
else
|
else
|
||||||
_GARDENER_CI_FIX_COUNT=$(( _GARDENER_CI_FIX_COUNT + 1 ))
|
log "PR #${PR_NUMBER} not merged (reason: ${_PR_WALK_EXIT_REASON:-unknown})"
|
||||||
if [ "$_GARDENER_CI_FIX_COUNT" -gt 3 ]; then
|
|
||||||
log "CI exhausted after ${_GARDENER_CI_FIX_COUNT} attempts"
|
|
||||||
printf 'PHASE:failed\nReason: gardener CI exhausted after %d attempts\n' \
|
|
||||||
"$_GARDENER_CI_FIX_COUNT" > "$PHASE_FILE"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Get error details
|
|
||||||
local pipeline_num ci_error_log
|
|
||||||
pipeline_num=$(ci_pipeline_number "$head_sha")
|
|
||||||
|
|
||||||
ci_error_log=""
|
|
||||||
if [ -n "$pipeline_num" ]; then
|
|
||||||
ci_error_log=$(bash "${FACTORY_ROOT}/lib/ci-debug.sh" failures "$pipeline_num" 2>/dev/null \
|
|
||||||
| tail -80 | head -c 8000 || true)
|
|
||||||
fi
|
|
||||||
|
|
||||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
|
|
||||||
"CI failed on PR #${_GARDENER_PR} (attempt ${_GARDENER_CI_FIX_COUNT}/3).
|
|
||||||
${ci_error_log:+Error output:
|
|
||||||
${ci_error_log}
|
|
||||||
}Fix the issue, commit, push, then write:
|
|
||||||
echo \"PHASE:awaiting_ci\" > \"${PHASE_FILE}\"
|
|
||||||
Then stop and wait."
|
|
||||||
fi
|
fi
|
||||||
}
|
else
|
||||||
|
log "no PR created — gardener run complete"
|
||||||
# shellcheck disable=SC2317 # called indirectly by monitor_phase_loop
|
|
||||||
_gardener_handle_review() {
|
|
||||||
log "waiting for review on PR #${_GARDENER_PR:-?}"
|
|
||||||
_GARDENER_CI_FIX_COUNT=0 # Reset CI fix budget for next review cycle
|
|
||||||
|
|
||||||
local review_elapsed=0 review_timeout=1800
|
|
||||||
while [ "$review_elapsed" -lt "$review_timeout" ]; do
|
|
||||||
sleep 60 # 1 min between review checks (gardener PRs are fast-tracked)
|
|
||||||
review_elapsed=$((review_elapsed + 60))
|
|
||||||
|
|
||||||
# Session health check
|
|
||||||
if [ -f "/tmp/claude-exited-${_MONITOR_SESSION:-$SESSION_NAME}.ts" ] || \
|
|
||||||
! tmux has-session -t "${_MONITOR_SESSION:-$SESSION_NAME}" 2>/dev/null; then
|
|
||||||
log "session died during review wait"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Merge-through timeout check
|
|
||||||
local elapsed
|
|
||||||
elapsed=$(( $(date +%s) - _GARDENER_MERGE_START ))
|
|
||||||
if [ "$elapsed" -ge "$_GARDENER_MERGE_TIMEOUT" ]; then
|
|
||||||
_gardener_timeout_cleanup
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if phase changed while we wait (e.g. review-poll injected feedback)
|
|
||||||
local new_mtime
|
|
||||||
new_mtime=$(stat -c %Y "$PHASE_FILE" 2>/dev/null || echo 0)
|
|
||||||
if [ "$new_mtime" -gt "${LAST_PHASE_MTIME:-0}" ]; then
|
|
||||||
log "phase changed during review wait — returning to monitor loop"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check for review on current HEAD
|
|
||||||
local review_sha review_comment
|
|
||||||
review_sha=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
|
||||||
"${FORGE_API}/pulls/${_GARDENER_PR}" | jq -r '.head.sha // empty') || true
|
|
||||||
|
|
||||||
review_comment=$(forge_api_all "/issues/${_GARDENER_PR}/comments" 2>/dev/null | \
|
|
||||||
jq -r --arg sha "${review_sha:-none}" \
|
|
||||||
'[.[] | select(.body | contains("<!-- reviewed: " + $sha))] | last // empty') || true
|
|
||||||
|
|
||||||
if [ -n "$review_comment" ] && [ "$review_comment" != "null" ]; then
|
|
||||||
local review_text verdict
|
|
||||||
review_text=$(echo "$review_comment" | jq -r '.body')
|
|
||||||
|
|
||||||
# Skip error reviews
|
|
||||||
if echo "$review_text" | grep -q "review-error\|Review — Error"; then
|
|
||||||
continue
|
|
||||||
fi
|
|
||||||
|
|
||||||
verdict=$(echo "$review_text" | grep -oP '\*\*(APPROVE|REQUEST_CHANGES|DISCUSS)\*\*' | head -1 | tr -d '*' || true)
|
|
||||||
|
|
||||||
# Check formal forge reviews as fallback
|
|
||||||
if [ -z "$verdict" ]; then
|
|
||||||
verdict=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
|
||||||
"${FORGE_API}/pulls/${_GARDENER_PR}/reviews" | \
|
|
||||||
jq -r '[.[] | select(.stale == false)] | last | .state // empty' || true)
|
|
||||||
[ "$verdict" = "APPROVED" ] && verdict="APPROVE"
|
|
||||||
[[ "$verdict" != "REQUEST_CHANGES" && "$verdict" != "APPROVE" ]] && verdict=""
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check review-poll sentinel to avoid double injection
|
|
||||||
local review_sentinel="/tmp/review-injected-${PROJECT_NAME}-${_GARDENER_PR}"
|
|
||||||
if [ -n "$verdict" ] && [ -f "$review_sentinel" ] && [ "$verdict" != "APPROVE" ]; then
|
|
||||||
log "review already injected by review-poll — skipping"
|
|
||||||
rm -f "$review_sentinel"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
rm -f "$review_sentinel"
|
|
||||||
|
|
||||||
if [ "$verdict" = "APPROVE" ]; then
|
|
||||||
log "gardener PR #${_GARDENER_PR} approved — merging"
|
|
||||||
_gardener_merge
|
|
||||||
return 0
|
|
||||||
|
|
||||||
elif [ "$verdict" = "REQUEST_CHANGES" ] || [ "$verdict" = "DISCUSS" ]; then
|
|
||||||
_GARDENER_REVIEW_ROUND=$(( _GARDENER_REVIEW_ROUND + 1 ))
|
|
||||||
log "review REQUEST_CHANGES on PR #${_GARDENER_PR} (round ${_GARDENER_REVIEW_ROUND})"
|
|
||||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
|
|
||||||
"Review feedback on PR #${_GARDENER_PR} (round ${_GARDENER_REVIEW_ROUND}):
|
|
||||||
|
|
||||||
${review_text}
|
|
||||||
|
|
||||||
Address all feedback, commit, push, then write:
|
|
||||||
echo \"PHASE:awaiting_ci\" > \"${PHASE_FILE}\"
|
|
||||||
Then stop and wait."
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Check if PR was merged or closed externally
|
|
||||||
local pr_json pr_state pr_merged
|
|
||||||
pr_json=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" \
|
|
||||||
"${FORGE_API}/pulls/${_GARDENER_PR}") || true
|
|
||||||
pr_state=$(echo "$pr_json" | jq -r '.state // "unknown"')
|
|
||||||
pr_merged=$(echo "$pr_json" | jq -r '.merged // false')
|
|
||||||
|
|
||||||
if [ "$pr_merged" = "true" ]; then
|
|
||||||
log "gardener PR #${_GARDENER_PR} merged externally"
|
|
||||||
_gardener_execute_manifest
|
|
||||||
printf 'PHASE:done\n' > "$PHASE_FILE"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
if [ "$pr_state" != "open" ]; then
|
|
||||||
log "gardener PR #${_GARDENER_PR} closed without merge"
|
|
||||||
printf 'PHASE:failed\nReason: PR closed without merge\n' > "$PHASE_FILE"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
log "waiting for review on PR #${_GARDENER_PR} (${review_elapsed}s)"
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$review_elapsed" -ge "$review_timeout" ]; then
|
|
||||||
log "review wait timed out for PR #${_GARDENER_PR}"
|
|
||||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
|
|
||||||
"No review received after ${review_timeout}s for PR #${_GARDENER_PR}. Write PHASE:failed with a reason if you cannot proceed."
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# shellcheck disable=SC2317 # called indirectly by monitor_phase_loop
|
|
||||||
_gardener_on_phase_change() {
|
|
||||||
local phase="$1"
|
|
||||||
log "phase: ${phase}"
|
|
||||||
|
|
||||||
case "$phase" in
|
|
||||||
PHASE:awaiting_ci)
|
|
||||||
_gardener_handle_ci
|
|
||||||
;;
|
|
||||||
PHASE:awaiting_review)
|
|
||||||
_gardener_handle_review
|
|
||||||
;;
|
|
||||||
PHASE:done|PHASE:merged)
|
|
||||||
agent_kill_session "${_MONITOR_SESSION:-$SESSION_NAME}"
|
|
||||||
;;
|
|
||||||
PHASE:failed|PHASE:escalate)
|
|
||||||
agent_kill_session "${_MONITOR_SESSION:-$SESSION_NAME}"
|
|
||||||
;;
|
|
||||||
PHASE:crashed)
|
|
||||||
if [ "${_GARDENER_CRASH_COUNT:-0}" -gt 0 ]; then
|
|
||||||
log "ERROR: session crashed again — giving up"
|
|
||||||
return 0
|
|
||||||
fi
|
|
||||||
_GARDENER_CRASH_COUNT=$(( _GARDENER_CRASH_COUNT + 1 ))
|
|
||||||
log "WARNING: session crashed — attempting recovery"
|
|
||||||
if create_agent_session "${_MONITOR_SESSION:-$SESSION_NAME}" \
|
|
||||||
"${_FORMULA_SESSION_WORKDIR:-$PROJECT_REPO_ROOT}" "$PHASE_FILE" 2>/dev/null; then
|
|
||||||
agent_inject_into_session "${_MONITOR_SESSION:-$SESSION_NAME}" "$PROMPT"
|
|
||||||
log "recovery session started"
|
|
||||||
else
|
|
||||||
log "ERROR: could not restart session after crash"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
*)
|
|
||||||
log "WARNING: unknown phase: ${phase}"
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Reset result file ────────────────────────────────────────────────────
|
|
||||||
rm -f "$RESULT_FILE"
|
|
||||||
touch "$RESULT_FILE"
|
|
||||||
|
|
||||||
# ── Run session ──────────────────────────────────────────────────────────
|
|
||||||
export CLAUDE_MODEL="sonnet"
|
|
||||||
run_formula_and_monitor "gardener" 7200 "_gardener_on_phase_change"
|
|
||||||
|
|
||||||
# ── Cleanup on exit ──────────────────────────────────────────────────────
|
|
||||||
# FINAL_PHASE already set by run_formula_and_monitor
|
|
||||||
if [ "${FINAL_PHASE:-}" = "PHASE:done" ]; then
|
|
||||||
rm -f "$SCRATCH_FILE"
|
rm -f "$SCRATCH_FILE"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
rm -f "$GARDENER_PR_FILE"
|
rm -f "$GARDENER_PR_FILE"
|
||||||
[ -n "$_GARDENER_PR" ] && rm -f "/tmp/review-injected-${PROJECT_NAME}-${_GARDENER_PR}"
|
log "--- Gardener run done ---"
|
||||||
|
|
|
||||||
61
lib/agent-sdk.sh
Normal file
61
lib/agent-sdk.sh
Normal file
|
|
@ -0,0 +1,61 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# agent-sdk.sh — Shared SDK for synchronous Claude agent invocations
|
||||||
|
#
|
||||||
|
# Provides agent_run(): one-shot `claude -p` with session persistence.
|
||||||
|
# Source this from any agent script after defining:
|
||||||
|
# SID_FILE — path to persist session ID (e.g. /tmp/dev-session-proj-123.sid)
|
||||||
|
# LOGFILE — path for log output
|
||||||
|
# log() — logging function
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# source "$(dirname "$0")/../lib/agent-sdk.sh"
|
||||||
|
# agent_run [--resume SESSION_ID] [--worktree DIR] PROMPT
|
||||||
|
#
|
||||||
|
# After each call, _AGENT_SESSION_ID holds the session ID (also saved to SID_FILE).
|
||||||
|
# Call agent_recover_session() on startup to restore a previous session.
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
_AGENT_SESSION_ID=""
|
||||||
|
|
||||||
|
# agent_recover_session — restore session_id from SID_FILE if it exists.
|
||||||
|
# Call this before agent_run --resume to enable session continuity.
|
||||||
|
agent_recover_session() {
|
||||||
|
if [ -f "$SID_FILE" ]; then
|
||||||
|
_AGENT_SESSION_ID=$(cat "$SID_FILE")
|
||||||
|
log "agent_recover_session: ${_AGENT_SESSION_ID:0:12}..."
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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_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
|
||||||
|
}
|
||||||
|
|
@ -1,41 +1,79 @@
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# shellcheck disable=SC2015,SC2016
|
# shellcheck disable=SC2015,SC2016
|
||||||
# review-pr.sh — Thin orchestrator for AI PR review (formula: formulas/review-pr.toml)
|
# review-pr.sh — Synchronous reviewer agent for a single PR
|
||||||
|
#
|
||||||
# Usage: ./review-pr.sh <pr-number> [--force]
|
# Usage: ./review-pr.sh <pr-number> [--force]
|
||||||
|
#
|
||||||
|
# Architecture:
|
||||||
|
# Synchronous bash loop using claude -p (one-shot invocations).
|
||||||
|
# Session continuity via --resume and .sid file.
|
||||||
|
# Re-review resumes the original session — Claude remembers its prior review.
|
||||||
|
#
|
||||||
|
# Flow:
|
||||||
|
# 1. Fetch PR metadata (title, body, head, base, SHA, CI state)
|
||||||
|
# 2. Detect re-review (previous review at different SHA, incremental diff)
|
||||||
|
# 3. Create review worktree, checkout PR head
|
||||||
|
# 4. Build structural analysis graph
|
||||||
|
# 5. Load review formula
|
||||||
|
# 6. agent_run(worktree, prompt) → Claude reviews, writes verdict JSON
|
||||||
|
# 7. Parse verdict, post as Forge review (APPROVE / REQUEST_CHANGES / COMMENT)
|
||||||
|
# 8. Save session ID to .sid file for re-review continuity
|
||||||
|
#
|
||||||
|
# Session file: /tmp/review-session-{project}-{pr}.sid
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Load shared environment and libraries
|
||||||
source "$(dirname "$0")/../lib/env.sh"
|
source "$(dirname "$0")/../lib/env.sh"
|
||||||
source "$(dirname "$0")/../lib/ci-helpers.sh"
|
source "$(dirname "$0")/../lib/ci-helpers.sh"
|
||||||
source "$(dirname "$0")/../lib/agent-session.sh"
|
source "$(dirname "$0")/../lib/worktree.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
|
git -C "$FACTORY_ROOT" pull --ff-only origin main 2>/dev/null || true
|
||||||
|
|
||||||
|
# --- Config ---
|
||||||
PR_NUMBER="${1:?Usage: review-pr.sh <pr-number> [--force]}"
|
PR_NUMBER="${1:?Usage: review-pr.sh <pr-number> [--force]}"
|
||||||
FORCE="${2:-}"
|
FORCE="${2:-}"
|
||||||
API="${FORGE_API}"
|
API="${FORGE_API}"
|
||||||
LOGFILE="${DISINTO_LOG_DIR}/review/review.log"
|
LOGFILE="${DISINTO_LOG_DIR}/review/review.log"
|
||||||
SESSION="review-${PROJECT_NAME}-${PR_NUMBER}"
|
|
||||||
PHASE_FILE="/tmp/review-session-${PROJECT_NAME}-${PR_NUMBER}.phase"
|
|
||||||
OUTPUT_FILE="/tmp/${PROJECT_NAME}-review-output-${PR_NUMBER}.json"
|
|
||||||
WORKTREE="/tmp/${PROJECT_NAME}-review-${PR_NUMBER}"
|
WORKTREE="/tmp/${PROJECT_NAME}-review-${PR_NUMBER}"
|
||||||
|
SID_FILE="/tmp/review-session-${PROJECT_NAME}-${PR_NUMBER}.sid"
|
||||||
|
OUTPUT_FILE="/tmp/${PROJECT_NAME}-review-output-${PR_NUMBER}.json"
|
||||||
LOCKFILE="/tmp/${PROJECT_NAME}-review.lock"
|
LOCKFILE="/tmp/${PROJECT_NAME}-review.lock"
|
||||||
STATUSFILE="/tmp/${PROJECT_NAME}-review-status"
|
STATUSFILE="/tmp/${PROJECT_NAME}-review-status"
|
||||||
MAX_DIFF=25000
|
MAX_DIFF=25000
|
||||||
REVIEW_TMPDIR=$(mktemp -d)
|
REVIEW_TMPDIR=$(mktemp -d)
|
||||||
|
|
||||||
log() { printf '[%s] PR#%s %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$PR_NUMBER" "$*" >> "$LOGFILE"; }
|
log() { printf '[%s] PR#%s %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$PR_NUMBER" "$*" >> "$LOGFILE"; }
|
||||||
status() { printf '[%s] PR #%s: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$PR_NUMBER" "$*" > "$STATUSFILE"; log "$*"; }
|
status() { printf '[%s] PR #%s: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$PR_NUMBER" "$*" > "$STATUSFILE"; log "$*"; }
|
||||||
cleanup() { rm -rf "$REVIEW_TMPDIR" "$LOCKFILE" "$STATUSFILE" "/tmp/${PROJECT_NAME}-review-graph-${PR_NUMBER}.json"; }
|
cleanup() { rm -rf "$REVIEW_TMPDIR" "$LOCKFILE" "$STATUSFILE" "/tmp/${PROJECT_NAME}-review-graph-${PR_NUMBER}.json"; }
|
||||||
trap cleanup EXIT
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# LOG ROTATION
|
||||||
|
# =============================================================================
|
||||||
if [ -f "$LOGFILE" ] && [ "$(stat -c%s "$LOGFILE" 2>/dev/null || echo 0)" -gt 102400 ]; then
|
if [ -f "$LOGFILE" ] && [ "$(stat -c%s "$LOGFILE" 2>/dev/null || echo 0)" -gt 102400 ]; then
|
||||||
mv "$LOGFILE" "$LOGFILE.old"
|
mv "$LOGFILE" "$LOGFILE.old"
|
||||||
fi
|
fi
|
||||||
AVAIL=$(awk '/MemAvailable/{printf "%d", $2/1024}' /proc/meminfo)
|
|
||||||
[ "$AVAIL" -lt 1500 ] && { log "SKIP: ${AVAIL}MB available"; exit 0; }
|
# =============================================================================
|
||||||
|
# MEMORY GUARD
|
||||||
|
# =============================================================================
|
||||||
|
memory_guard 1500
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CONCURRENCY LOCK
|
||||||
|
# =============================================================================
|
||||||
if [ -f "$LOCKFILE" ]; then
|
if [ -f "$LOCKFILE" ]; then
|
||||||
LPID=$(cat "$LOCKFILE" 2>/dev/null || true)
|
LPID=$(cat "$LOCKFILE" 2>/dev/null || true)
|
||||||
[ -n "$LPID" ] && kill -0 "$LPID" 2>/dev/null && { log "SKIP: locked"; exit 0; }
|
[ -n "$LPID" ] && kill -0 "$LPID" 2>/dev/null && { log "SKIP: locked"; exit 0; }
|
||||||
rm -f "$LOCKFILE"
|
rm -f "$LOCKFILE"
|
||||||
fi
|
fi
|
||||||
echo $$ > "$LOCKFILE"
|
echo $$ > "$LOCKFILE"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FETCH PR METADATA
|
||||||
|
# =============================================================================
|
||||||
status "fetching metadata"
|
status "fetching metadata"
|
||||||
PR_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" "${API}/pulls/${PR_NUMBER}")
|
PR_JSON=$(curl -sf -H "Authorization: token ${FORGE_TOKEN}" "${API}/pulls/${PR_NUMBER}")
|
||||||
PR_TITLE=$(printf '%s' "$PR_JSON" | jq -r '.title')
|
PR_TITLE=$(printf '%s' "$PR_JSON" | jq -r '.title')
|
||||||
|
|
@ -45,15 +83,27 @@ PR_BASE=$(printf '%s' "$PR_JSON" | jq -r '.base.ref')
|
||||||
PR_SHA=$(printf '%s' "$PR_JSON" | jq -r '.head.sha')
|
PR_SHA=$(printf '%s' "$PR_JSON" | jq -r '.head.sha')
|
||||||
PR_STATE=$(printf '%s' "$PR_JSON" | jq -r '.state')
|
PR_STATE=$(printf '%s' "$PR_JSON" | jq -r '.state')
|
||||||
log "${PR_TITLE} (${PR_HEAD}→${PR_BASE} ${PR_SHA:0:7})"
|
log "${PR_TITLE} (${PR_HEAD}→${PR_BASE} ${PR_SHA:0:7})"
|
||||||
|
|
||||||
if [ "$PR_STATE" != "open" ]; then
|
if [ "$PR_STATE" != "open" ]; then
|
||||||
log "SKIP: state=${PR_STATE}"; agent_kill_session "$SESSION"
|
log "SKIP: state=${PR_STATE}"
|
||||||
cd "${PROJECT_REPO_ROOT}"; git worktree remove "$WORKTREE" --force 2>/dev/null || true
|
worktree_cleanup "$WORKTREE"
|
||||||
rm -rf "$WORKTREE" "$PHASE_FILE" "$OUTPUT_FILE" 2>/dev/null || true; exit 0
|
rm -f "$OUTPUT_FILE" "$SID_FILE" 2>/dev/null || true
|
||||||
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# CI CHECK
|
||||||
|
# =============================================================================
|
||||||
CI_STATE=$(ci_commit_status "$PR_SHA")
|
CI_STATE=$(ci_commit_status "$PR_SHA")
|
||||||
CI_NOTE=""; if ! ci_passed "$CI_STATE"; then
|
CI_NOTE=""
|
||||||
|
if ! ci_passed "$CI_STATE"; then
|
||||||
ci_required_for_pr "$PR_NUMBER" && { log "SKIP: CI=${CI_STATE}"; exit 0; }
|
ci_required_for_pr "$PR_NUMBER" && { log "SKIP: CI=${CI_STATE}"; exit 0; }
|
||||||
CI_NOTE=" (not required — non-code PR)"; fi
|
CI_NOTE=" (not required — non-code PR)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# DUPLICATE CHECK — skip if already reviewed at this SHA
|
||||||
|
# =============================================================================
|
||||||
ALL_COMMENTS=$(forge_api_all "/issues/${PR_NUMBER}/comments")
|
ALL_COMMENTS=$(forge_api_all "/issues/${PR_NUMBER}/comments")
|
||||||
HAS_CMT=$(printf '%s' "$ALL_COMMENTS" | jq --arg s "$PR_SHA" \
|
HAS_CMT=$(printf '%s' "$ALL_COMMENTS" | jq --arg s "$PR_SHA" \
|
||||||
'[.[]|select(.body|contains("<!-- reviewed: "+$s+" -->"))]|length')
|
'[.[]|select(.body|contains("<!-- reviewed: "+$s+" -->"))]|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" \
|
HAS_FML=$(forge_api_all "/pulls/${PR_NUMBER}/reviews" | jq --arg s "$PR_SHA" \
|
||||||
'[.[]|select(.commit_id==$s)|select(.state!="COMMENT")]|length')
|
'[.[]|select(.commit_id==$s)|select(.state!="COMMENT")]|length')
|
||||||
[ "${HAS_FML:-0}" -gt 0 ] && [ "$FORCE" != "--force" ] && { log "SKIP: formal review"; exit 0; }
|
[ "${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_CONTEXT="" IS_RE_REVIEW=false PREV_SHA=""
|
||||||
PREV_REV=$(printf '%s' "$ALL_COMMENTS" | jq -r --arg s "$PR_SHA" \
|
PREV_REV=$(printf '%s' "$ALL_COMMENTS" | jq -r --arg s "$PR_SHA" \
|
||||||
'[.[]|select(.body|contains("<!-- reviewed:"))|select(.body|contains($s)|not)]|last // empty')
|
'[.[]|select(.body|contains("<!-- reviewed:"))|select(.body|contains($s)|not)]|last // empty')
|
||||||
|
|
@ -79,6 +133,13 @@ if [ -n "$PREV_REV" ] && [ "$PREV_REV" != "null" ]; then
|
||||||
"${PREV_SHA:0:7}" "$PREV_BODY" "$DEV_SEC" "${PREV_SHA:0:7}" "${PR_SHA:0:7}" "$INCR")
|
"${PREV_SHA:0:7}" "$PREV_BODY" "$DEV_SEC" "${PREV_SHA:0:7}" "${PR_SHA:0:7}" "$INCR")
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Recover session_id from .sid file (re-review continuity)
|
||||||
|
agent_recover_session
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FETCH DIFF
|
||||||
|
# =============================================================================
|
||||||
status "fetching diff"
|
status "fetching diff"
|
||||||
curl -s -H "Authorization: token ${FORGE_TOKEN}" \
|
curl -s -H "Authorization: token ${FORGE_TOKEN}" \
|
||||||
"${API}/pulls/${PR_NUMBER}.diff" > "${REVIEW_TMPDIR}/full.diff"
|
"${API}/pulls/${PR_NUMBER}.diff" > "${REVIEW_TMPDIR}/full.diff"
|
||||||
|
|
@ -86,15 +147,25 @@ FSIZE=$(stat -c%s "${REVIEW_TMPDIR}/full.diff" 2>/dev/null || echo 0)
|
||||||
DIFF=$(head -c "$MAX_DIFF" "${REVIEW_TMPDIR}/full.diff")
|
DIFF=$(head -c "$MAX_DIFF" "${REVIEW_TMPDIR}/full.diff")
|
||||||
FILES=$(grep -E '^\+\+\+ b/' "${REVIEW_TMPDIR}/full.diff" | sed 's|^+++ b/||' | grep -v '/dev/null' | sort -u || true)
|
FILES=$(grep -E '^\+\+\+ b/' "${REVIEW_TMPDIR}/full.diff" | sed 's|^+++ b/||' | grep -v '/dev/null' | sort -u || true)
|
||||||
DNOTE=""; [ "$FSIZE" -gt "$MAX_DIFF" ] && DNOTE=" (truncated from ${FSIZE} bytes)"
|
DNOTE=""; [ "$FSIZE" -gt "$MAX_DIFF" ] && DNOTE=" (truncated from ${FSIZE} bytes)"
|
||||||
cd "${PROJECT_REPO_ROOT}"; git fetch origin "$PR_HEAD" 2>/dev/null || true
|
|
||||||
|
# =============================================================================
|
||||||
|
# WORKTREE SETUP
|
||||||
|
# =============================================================================
|
||||||
|
cd "${PROJECT_REPO_ROOT}"
|
||||||
|
git fetch origin "$PR_HEAD" 2>/dev/null || true
|
||||||
|
|
||||||
if [ -d "$WORKTREE" ]; then
|
if [ -d "$WORKTREE" ]; then
|
||||||
cd "$WORKTREE"; git checkout --detach "$PR_SHA" 2>/dev/null || {
|
cd "$WORKTREE"; git checkout --detach "$PR_SHA" 2>/dev/null || {
|
||||||
cd "${PROJECT_REPO_ROOT}"; git worktree remove "$WORKTREE" --force 2>/dev/null || true
|
cd "${PROJECT_REPO_ROOT}"; worktree_cleanup "$WORKTREE"
|
||||||
rm -rf "$WORKTREE"; git worktree add "$WORKTREE" "$PR_SHA" --detach 2>/dev/null; }
|
git worktree add "$WORKTREE" "$PR_SHA" --detach 2>/dev/null; }
|
||||||
else git worktree add "$WORKTREE" "$PR_SHA" --detach 2>/dev/null; fi
|
else
|
||||||
status "preparing review session"
|
git worktree add "$WORKTREE" "$PR_SHA" --detach 2>/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Build structural analysis graph for changed files ────────────────────
|
# =============================================================================
|
||||||
|
# BUILD STRUCTURAL ANALYSIS GRAPH
|
||||||
|
# =============================================================================
|
||||||
|
status "preparing review"
|
||||||
GRAPH_REPORT="/tmp/${PROJECT_NAME}-review-graph-${PR_NUMBER}.json"
|
GRAPH_REPORT="/tmp/${PROJECT_NAME}-review-graph-${PR_NUMBER}.json"
|
||||||
GRAPH_SECTION=""
|
GRAPH_SECTION=""
|
||||||
# shellcheck disable=SC2086
|
# shellcheck disable=SC2086
|
||||||
|
|
@ -109,42 +180,43 @@ else
|
||||||
log "WARN: build-graph.py failed — continuing without structural analysis"
|
log "WARN: build-graph.py failed — continuing without structural analysis"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# BUILD PROMPT
|
||||||
|
# =============================================================================
|
||||||
FORMULA=$(cat "${FACTORY_ROOT}/formulas/review-pr.toml")
|
FORMULA=$(cat "${FACTORY_ROOT}/formulas/review-pr.toml")
|
||||||
{
|
{
|
||||||
printf 'You are the review agent for %s. Follow the formula to review PR #%s.\nYou MUST write PHASE:done to '\''%s'\'' when finished.\n\n' \
|
printf 'You are the review agent for %s. Follow the formula to review PR #%s.\n\n' \
|
||||||
"${FORGE_REPO}" "${PR_NUMBER}" "${PHASE_FILE}"
|
"${FORGE_REPO}" "${PR_NUMBER}"
|
||||||
printf '## PR Context\n**%s** (%s → %s) | SHA: %s | CI: %s%s\nRe-review: %s\n\n' \
|
printf '## PR Context\n**%s** (%s → %s) | SHA: %s | CI: %s%s\nRe-review: %s\n\n' \
|
||||||
"$PR_TITLE" "$PR_HEAD" "$PR_BASE" "$PR_SHA" "$CI_STATE" "$CI_NOTE" "$IS_RE_REVIEW"
|
"$PR_TITLE" "$PR_HEAD" "$PR_BASE" "$PR_SHA" "$CI_STATE" "$CI_NOTE" "$IS_RE_REVIEW"
|
||||||
printf '### Description\n%s\n\n### Changed Files\n%s\n\n### Diff%s\n```diff\n%s\n```\n' \
|
printf '### Description\n%s\n\n### Changed Files\n%s\n\n### Diff%s\n```diff\n%s\n```\n' \
|
||||||
"$PR_BODY" "$FILES" "$DNOTE" "$DIFF"
|
"$PR_BODY" "$FILES" "$DNOTE" "$DIFF"
|
||||||
[ -n "$PREV_CONTEXT" ] && printf '%s\n' "$PREV_CONTEXT"
|
[ -n "$PREV_CONTEXT" ] && printf '%s\n' "$PREV_CONTEXT"
|
||||||
[ -n "$GRAPH_SECTION" ] && printf '%s\n' "$GRAPH_SECTION"
|
[ -n "$GRAPH_SECTION" ] && printf '%s\n' "$GRAPH_SECTION"
|
||||||
printf '\n## Formula\n%s\n\n## Environment\nREVIEW_OUTPUT_FILE=%s\nPHASE_FILE=%s\nFORGE_API=%s\nPR_NUMBER=%s\nFACTORY_ROOT=%s\n' \
|
printf '\n## Formula\n%s\n\n## Environment\nREVIEW_OUTPUT_FILE=%s\nFORGE_API=%s\nPR_NUMBER=%s\nFACTORY_ROOT=%s\n' \
|
||||||
"$FORMULA" "$OUTPUT_FILE" "$PHASE_FILE" "$API" "$PR_NUMBER" "$FACTORY_ROOT"
|
"$FORMULA" "$OUTPUT_FILE" "$API" "$PR_NUMBER" "$FACTORY_ROOT"
|
||||||
printf 'NEVER echo the actual token — always reference ${FORGE_TOKEN} or ${FORGE_REVIEW_TOKEN}.\n'
|
printf 'NEVER echo the actual token — always reference ${FORGE_TOKEN} or ${FORGE_REVIEW_TOKEN}.\n'
|
||||||
|
printf '\n## Completion\nAfter writing the JSON file to REVIEW_OUTPUT_FILE, stop.\nDo NOT write to any phase file — completion is automatic.\n'
|
||||||
} > "${REVIEW_TMPDIR}/prompt.md"
|
} > "${REVIEW_TMPDIR}/prompt.md"
|
||||||
PROMPT=$(cat "${REVIEW_TMPDIR}/prompt.md")
|
PROMPT=$(cat "${REVIEW_TMPDIR}/prompt.md")
|
||||||
|
|
||||||
rm -f "$OUTPUT_FILE" "$PHASE_FILE"; agent_kill_session "$SESSION"
|
# =============================================================================
|
||||||
|
# RUN REVIEW AGENT
|
||||||
|
# =============================================================================
|
||||||
|
status "running review"
|
||||||
|
rm -f "$OUTPUT_FILE"
|
||||||
export CLAUDE_MODEL="sonnet"
|
export CLAUDE_MODEL="sonnet"
|
||||||
create_agent_session "$SESSION" "$WORKTREE" "$PHASE_FILE" || { log "ERROR: session failed"; exit 1; }
|
|
||||||
agent_inject_into_session "$SESSION" "$PROMPT"
|
|
||||||
log "prompt injected (${#PROMPT} bytes, re-review: ${IS_RE_REVIEW})"
|
|
||||||
|
|
||||||
status "waiting for review"
|
if [ "$IS_RE_REVIEW" = true ] && [ -n "$_AGENT_SESSION_ID" ]; then
|
||||||
_REVIEW_CRASH=0
|
agent_run --resume "$_AGENT_SESSION_ID" --worktree "$WORKTREE" "$PROMPT"
|
||||||
review_cb() {
|
else
|
||||||
log "phase: $1"
|
agent_run --worktree "$WORKTREE" "$PROMPT"
|
||||||
case "$1" in
|
fi
|
||||||
PHASE:crashed)
|
log "agent_run complete (re-review: ${IS_RE_REVIEW})"
|
||||||
[ "$_REVIEW_CRASH" -gt 0 ] && return 0; _REVIEW_CRASH=$((_REVIEW_CRASH + 1))
|
|
||||||
create_agent_session "${_MONITOR_SESSION}" "$WORKTREE" "$PHASE_FILE" 2>/dev/null && \
|
|
||||||
agent_inject_into_session "${_MONITOR_SESSION}" "$PROMPT" ;;
|
|
||||||
PHASE:done|PHASE:failed|PHASE:escalate) agent_kill_session "${_MONITOR_SESSION}" ;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
monitor_phase_loop "$PHASE_FILE" 600 "review_cb" "$SESSION"
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# PARSE REVIEW OUTPUT
|
||||||
|
# =============================================================================
|
||||||
REVIEW_JSON=""
|
REVIEW_JSON=""
|
||||||
if [ -f "$OUTPUT_FILE" ]; then
|
if [ -f "$OUTPUT_FILE" ]; then
|
||||||
RAW=$(cat "$OUTPUT_FILE")
|
RAW=$(cat "$OUTPUT_FILE")
|
||||||
|
|
@ -155,6 +227,7 @@ if [ -f "$OUTPUT_FILE" ]; then
|
||||||
[ -n "${EXT:-}" ] && printf '%s' "$EXT" | jq -e '.verdict' >/dev/null 2>&1 && REVIEW_JSON="$EXT"
|
[ -n "${EXT:-}" ] && printf '%s' "$EXT" | jq -e '.verdict' >/dev/null 2>&1 && REVIEW_JSON="$EXT"
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ -z "$REVIEW_JSON" ]; then
|
if [ -z "$REVIEW_JSON" ]; then
|
||||||
log "ERROR: no valid review output"
|
log "ERROR: no valid review output"
|
||||||
jq -n --arg b "## AI Review — Error\n<!-- review-error: ${PR_SHA} -->\nReview failed.\n---\n*${PR_SHA:0:7}*" \
|
jq -n --arg b "## AI Review — Error\n<!-- review-error: ${PR_SHA} -->\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
|
-H "Content-Type: application/json" "${API}/issues/${PR_NUMBER}/comments" -d @- || true
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
VERDICT=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict' | tr '[:lower:]' '[:upper:]' | tr '-' '_')
|
VERDICT=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict' | tr '[:lower:]' '[:upper:]' | tr '-' '_')
|
||||||
REASON=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict_reason // ""')
|
REASON=$(printf '%s' "$REVIEW_JSON" | jq -r '.verdict_reason // ""')
|
||||||
REVIEW_MD=$(printf '%s' "$REVIEW_JSON" | jq -r '.review_markdown // ""')
|
REVIEW_MD=$(printf '%s' "$REVIEW_JSON" | jq -r '.review_markdown // ""')
|
||||||
log "verdict: ${VERDICT}"
|
log "verdict: ${VERDICT}"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# POST REVIEW
|
||||||
|
# =============================================================================
|
||||||
status "posting review"
|
status "posting review"
|
||||||
RTYPE="Review"
|
RTYPE="Review"
|
||||||
if [ "$IS_RE_REVIEW" = true ]; then
|
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; }
|
[ "$POST_RC" != "201" ] && { log "ERROR: comment HTTP ${POST_RC}"; exit 1; }
|
||||||
log "posted review comment"
|
log "posted review comment"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# POST FORMAL REVIEW
|
||||||
|
# =============================================================================
|
||||||
REVENT="COMMENT"
|
REVENT="COMMENT"
|
||||||
case "$VERDICT" in APPROVE) REVENT="APPROVED" ;; REQUEST_CHANGES|DISCUSS) REVENT="REQUEST_CHANGES" ;; esac
|
case "$VERDICT" in APPROVE) REVENT="APPROVED" ;; REQUEST_CHANGES|DISCUSS) REVENT="REQUEST_CHANGES" ;; esac
|
||||||
if [ "$REVENT" = "APPROVED" ]; then
|
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
|
--data-binary @"${REVIEW_TMPDIR}/formal.json" >/dev/null 2>&1 || true
|
||||||
log "formal ${REVENT} submitted"
|
log "formal ${REVENT} submitted"
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# FINAL CLEANUP
|
||||||
|
# =============================================================================
|
||||||
case "$VERDICT" in
|
case "$VERDICT" in
|
||||||
REQUEST_CHANGES|DISCUSS) printf 'PHASE:awaiting_changes\nSHA:%s\n' "$PR_SHA" > "$PHASE_FILE" ;;
|
REQUEST_CHANGES|DISCUSS)
|
||||||
*) rm -f "$PHASE_FILE" "$OUTPUT_FILE"; cd "${PROJECT_REPO_ROOT}"
|
# Keep session and worktree for re-review continuity
|
||||||
git worktree remove "$WORKTREE" --force 2>/dev/null || true
|
log "keeping session for re-review (SID: ${_AGENT_SESSION_ID:0:12}...)"
|
||||||
rm -rf "$WORKTREE" 2>/dev/null || true ;;
|
;;
|
||||||
|
*)
|
||||||
|
rm -f "$SID_FILE" "$OUTPUT_FILE"
|
||||||
|
worktree_cleanup "$WORKTREE"
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
log "DONE: ${VERDICT} (re-review: ${IS_RE_REVIEW})"
|
log "DONE: ${VERDICT} (re-review: ${IS_RE_REVIEW})"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue