The wait-for-CI loop sleeps 30s × 60 iterations waiting for CI to report. Projects with WOODPECKER_REPO_ID=0 never get a status, so the agent times out after 30min without merging approved PRs. Now detects no-CI early and treats as success immediately.
1372 lines
53 KiB
Bash
Executable file
1372 lines
53 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
# dev-agent.sh — Autonomous developer agent for a single issue
|
|
#
|
|
# Usage: ./dev-agent.sh <issue-number>
|
|
#
|
|
# Lifecycle:
|
|
# 1. Fetch issue, check dependencies (preflight)
|
|
# 2. Claim issue (label: in-progress, remove backlog)
|
|
# 3. Create worktree + branch
|
|
# 4. Run claude -p with implementation prompt
|
|
# 5. Commit + push + create PR
|
|
# 6. Wait for CI + AI review
|
|
# 7. Feed review back via claude -p -c (continues session)
|
|
# 8. On APPROVE → merge, delete branch, clean labels, close issue
|
|
#
|
|
# Preflight JSON output:
|
|
# {"status": "ready"}
|
|
# {"status": "unmet_dependency", "blocked_by": [315, 316], "suggestion": 317}
|
|
# {"status": "too_large", "reason": "..."}
|
|
# {"status": "already_done", "reason": "..."}
|
|
#
|
|
# Peek: cat /tmp/dev-agent-status
|
|
# Log: tail -f dev-agent.log
|
|
|
|
set -euo pipefail
|
|
|
|
# Load shared environment
|
|
source "$(dirname "$0")/../lib/env.sh"
|
|
|
|
|
|
# --- Config ---
|
|
ISSUE="${1:?Usage: dev-agent.sh <issue-number>}"
|
|
REPO="${CODEBERG_REPO}"
|
|
REPO_ROOT="${PROJECT_REPO_ROOT}"
|
|
|
|
API="${CODEBERG_API}"
|
|
LOCKFILE="/tmp/dev-agent-${PROJECT_NAME:-harb}.lock"
|
|
STATUSFILE="/tmp/dev-agent-status"
|
|
LOGFILE="${FACTORY_ROOT}/dev/dev-agent.log"
|
|
PREFLIGHT_RESULT="/tmp/dev-agent-preflight.json"
|
|
BRANCH="fix/issue-${ISSUE}"
|
|
WORKTREE="/tmp/${PROJECT_NAME}-worktree-${ISSUE}"
|
|
REVIEW_POLL_INTERVAL=300 # 5 min between review checks
|
|
MAX_REVIEW_ROUNDS=5
|
|
CLAUDE_TIMEOUT=7200
|
|
|
|
# --- Logging ---
|
|
log() {
|
|
printf '[%s] #%s %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$ISSUE" "$*" >> "$LOGFILE"
|
|
}
|
|
|
|
status() {
|
|
printf '[%s] dev-agent #%s: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$ISSUE" "$*" > "$STATUSFILE"
|
|
log "$*"
|
|
}
|
|
|
|
notify() {
|
|
matrix_send "dev" "🔧 #${ISSUE}: $*" 2>/dev/null || true
|
|
}
|
|
|
|
cleanup_worktree() {
|
|
cd "$REPO_ROOT"
|
|
git worktree remove "$WORKTREE" --force 2>/dev/null || true
|
|
rm -rf "$WORKTREE"
|
|
# Clear Claude Code session history for this worktree to prevent hallucinated "already done"
|
|
CLAUDE_PROJECT_DIR="$HOME/.claude/projects/$(echo "$WORKTREE" | sed 's|/|-|g; s|^-||')"
|
|
rm -rf "$CLAUDE_PROJECT_DIR" 2>/dev/null || true
|
|
}
|
|
|
|
cleanup_labels() {
|
|
curl -sf -X DELETE \
|
|
-H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/issues/${ISSUE}/labels/in-progress" >/dev/null 2>&1 || true
|
|
}
|
|
|
|
CLAIMED=false
|
|
cleanup() {
|
|
rm -f "$LOCKFILE" "$STATUSFILE"
|
|
# If we claimed the issue but never created a PR, unclaim it
|
|
if [ "$CLAIMED" = true ] && [ -z "${PR_NUMBER:-}" ]; then
|
|
log "cleanup: unclaiming issue (no PR created)"
|
|
curl -sf -X DELETE \
|
|
-H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/issues/${ISSUE}/labels/in-progress" >/dev/null 2>&1 || true
|
|
curl -sf -X POST \
|
|
-H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
"${API}/issues/${ISSUE}/labels" \
|
|
-d '{"labels":["backlog"]}' >/dev/null 2>&1 || true
|
|
fi
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
|
|
# --- Log rotation ---
|
|
if [ -f "$LOGFILE" ] && [ "$(stat -c%s "$LOGFILE" 2>/dev/null || echo 0)" -gt 102400 ]; then
|
|
mv "$LOGFILE" "$LOGFILE.old"
|
|
log "Log rotated"
|
|
fi
|
|
|
|
# --- Memory guard ---
|
|
AVAIL_MB=$(awk '/MemAvailable/ {printf "%d", $2/1024}' /proc/meminfo)
|
|
if [ "$AVAIL_MB" -lt 2000 ]; then
|
|
log "SKIP: only ${AVAIL_MB}MB available (need 2000MB)"
|
|
exit 0
|
|
fi
|
|
|
|
# --- Concurrency lock ---
|
|
if [ -f "$LOCKFILE" ]; then
|
|
LOCK_PID=$(cat "$LOCKFILE" 2>/dev/null || echo "")
|
|
if [ -n "$LOCK_PID" ] && kill -0 "$LOCK_PID" 2>/dev/null; then
|
|
log "SKIP: another dev-agent running (PID ${LOCK_PID})"
|
|
exit 0
|
|
fi
|
|
log "Removing stale lock (PID ${LOCK_PID:-?})"
|
|
rm -f "$LOCKFILE"
|
|
fi
|
|
echo $$ > "$LOCKFILE"
|
|
|
|
# --- Fetch issue ---
|
|
status "fetching issue"
|
|
ISSUE_JSON=$(curl -s -H "Authorization: token ${CODEBERG_TOKEN}" "${API}/issues/${ISSUE}") || true
|
|
if [ -z "$ISSUE_JSON" ] || ! echo "$ISSUE_JSON" | jq -e '.id' >/dev/null 2>&1; then
|
|
log "ERROR: failed to fetch issue #${ISSUE} (API down or invalid response)"
|
|
exit 1
|
|
fi
|
|
ISSUE_TITLE=$(echo "$ISSUE_JSON" | jq -r '.title')
|
|
ISSUE_BODY=$(echo "$ISSUE_JSON" | jq -r '.body // ""')
|
|
ISSUE_STATE=$(echo "$ISSUE_JSON" | jq -r '.state')
|
|
|
|
if [ "$ISSUE_STATE" != "open" ]; then
|
|
log "SKIP: issue #${ISSUE} is ${ISSUE_STATE}"
|
|
echo '{"status":"already_done","reason":"issue is closed"}' > "$PREFLIGHT_RESULT"
|
|
exit 0
|
|
fi
|
|
|
|
log "Issue: ${ISSUE_TITLE}"
|
|
|
|
# =============================================================================
|
|
# PREFLIGHT: Check dependencies before doing any work
|
|
# =============================================================================
|
|
status "preflight check"
|
|
|
|
# Extract dependency references from issue body
|
|
# Only from ## Dependencies / ## Depends on / ## Blocked by sections
|
|
# and inline "depends on #NNN" / "blocked by #NNN" phrases.
|
|
# NEVER extract from ## Related or other sections.
|
|
DEP_NUMBERS=""
|
|
|
|
# 1. Inline phrases anywhere in body (explicit dep language only)
|
|
INLINE_DEPS=$(echo "$ISSUE_BODY" | \
|
|
grep -ioP '(?:depends on|blocked by)\s+#\K[0-9]+' | \
|
|
sort -un || true)
|
|
[ -n "$INLINE_DEPS" ] && DEP_NUMBERS="$INLINE_DEPS"
|
|
|
|
# 2. ## Dependencies / ## Depends on / ## Blocked by section (bullet items)
|
|
DEP_SECTION=$(echo "$ISSUE_BODY" | sed -n '/^##\?\s*\(Dependencies\|Depends on\|Blocked by\)/I,/^##/p' | sed '1d;$d')
|
|
if [ -n "$DEP_SECTION" ]; then
|
|
SECTION_DEPS=$(echo "$DEP_SECTION" | grep -oP '#\K[0-9]+' | sort -un || true)
|
|
DEP_NUMBERS=$(printf '%s\n%s' "$DEP_NUMBERS" "$SECTION_DEPS" | sort -un | grep -v '^$' || true)
|
|
fi
|
|
|
|
BLOCKED_BY=()
|
|
if [ -n "$DEP_NUMBERS" ]; then
|
|
while IFS= read -r dep_num; do
|
|
[ -z "$dep_num" ] && continue
|
|
# Check if dependency issue is closed (= satisfied)
|
|
DEP_STATE=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/issues/${dep_num}" | jq -r '.state // "unknown"')
|
|
|
|
if [ "$DEP_STATE" != "closed" ]; then
|
|
BLOCKED_BY+=("$dep_num")
|
|
log "dependency #${dep_num} is ${DEP_STATE} (not satisfied)"
|
|
else
|
|
log "dependency #${dep_num} is closed (satisfied)"
|
|
fi
|
|
done <<< "$DEP_NUMBERS"
|
|
fi
|
|
|
|
if [ "${#BLOCKED_BY[@]}" -gt 0 ]; then
|
|
# Find a suggestion: look for the first blocker that itself has no unmet deps
|
|
SUGGESTION=""
|
|
for blocker in "${BLOCKED_BY[@]}"; do
|
|
BLOCKER_BODY=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/issues/${blocker}" | jq -r '.body // ""')
|
|
BLOCKER_STATE=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/issues/${blocker}" | jq -r '.state')
|
|
|
|
if [ "$BLOCKER_STATE" != "open" ]; then
|
|
continue
|
|
fi
|
|
|
|
# Check if this blocker has its own unmet deps
|
|
BLOCKER_DEPS=$(echo "$BLOCKER_BODY" | \
|
|
grep -ioP '(?:depends on|blocked by|requires|after)\s+#\K[0-9]+' | sort -un || true)
|
|
BLOCKER_SECTION=$(echo "$BLOCKER_BODY" | sed -n '/^## Dependencies/,/^## /p' | sed '1d;$d')
|
|
if [ -n "$BLOCKER_SECTION" ]; then
|
|
BLOCKER_SECTION_DEPS=$(echo "$BLOCKER_SECTION" | grep -oP '#\K[0-9]+' | sort -un || true)
|
|
BLOCKER_DEPS=$(printf '%s\n%s' "$BLOCKER_DEPS" "$BLOCKER_SECTION_DEPS" | sort -un | grep -v '^$' || true)
|
|
fi
|
|
|
|
BLOCKER_BLOCKED=false
|
|
if [ -n "$BLOCKER_DEPS" ]; then
|
|
while IFS= read -r bd; do
|
|
[ -z "$bd" ] && continue
|
|
BD_STATE=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/issues/${bd}" | jq -r '.state // "unknown"')
|
|
if [ "$BD_STATE" != "closed" ]; then
|
|
BLOCKER_BLOCKED=true
|
|
break
|
|
fi
|
|
done <<< "$BLOCKER_DEPS"
|
|
fi
|
|
|
|
if [ "$BLOCKER_BLOCKED" = false ]; then
|
|
SUGGESTION="$blocker"
|
|
break
|
|
fi
|
|
done
|
|
|
|
# Write preflight result
|
|
BLOCKED_JSON=$(printf '%s\n' "${BLOCKED_BY[@]}" | jq -R 'tonumber' | jq -sc '.')
|
|
if [ -n "$SUGGESTION" ]; then
|
|
jq -n --argjson blocked "$BLOCKED_JSON" --argjson suggestion "$SUGGESTION" \
|
|
'{"status":"unmet_dependency","blocked_by":$blocked,"suggestion":$suggestion}' > "$PREFLIGHT_RESULT"
|
|
else
|
|
jq -n --argjson blocked "$BLOCKED_JSON" \
|
|
'{"status":"unmet_dependency","blocked_by":$blocked,"suggestion":null}' > "$PREFLIGHT_RESULT"
|
|
fi
|
|
|
|
# Post comment ONLY if last comment isn't already an unmet dependency notice
|
|
BLOCKED_LIST=$(printf '#%s, ' "${BLOCKED_BY[@]}" | sed 's/, $//')
|
|
LAST_COMMENT_IS_BLOCK=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/issues/${ISSUE}/comments?limit=1" | \
|
|
jq -r '.[0].body // ""' | grep -c 'Dev-agent: Unmet dependency' || true)
|
|
|
|
if [ "$LAST_COMMENT_IS_BLOCK" -eq 0 ]; then
|
|
BLOCK_COMMENT="🚧 **Dev-agent: Unmet dependency**
|
|
|
|
### Blocked by open issues
|
|
|
|
This issue depends on ${BLOCKED_LIST}, which $(if [ "${#BLOCKED_BY[@]}" -eq 1 ]; then echo "is"; else echo "are"; fi) not yet closed."
|
|
if [ -n "$SUGGESTION" ]; then
|
|
BLOCK_COMMENT="${BLOCK_COMMENT}
|
|
|
|
**Suggestion:** Work on #${SUGGESTION} first."
|
|
fi
|
|
BLOCK_COMMENT="${BLOCK_COMMENT}
|
|
|
|
---
|
|
*Automated assessment by dev-agent · $(date -u '+%Y-%m-%d %H:%M UTC')*"
|
|
|
|
printf '%s' "$BLOCK_COMMENT" > /tmp/block-comment.txt
|
|
jq -Rs '{body: .}' < /tmp/block-comment.txt > /tmp/block-comment.json
|
|
curl -sf -o /dev/null -X POST \
|
|
-H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
"${API}/issues/${ISSUE}/comments" \
|
|
--data-binary @/tmp/block-comment.json 2>/dev/null || true
|
|
rm -f /tmp/block-comment.txt /tmp/block-comment.json
|
|
else
|
|
log "skipping duplicate dependency comment"
|
|
fi
|
|
|
|
log "BLOCKED: unmet dependencies: ${BLOCKED_BY[*]}$(if [ -n "$SUGGESTION" ]; then echo ", suggest #${SUGGESTION}"; fi)"
|
|
notify "blocked by unmet dependencies: ${BLOCKED_BY[*]}"
|
|
exit 0
|
|
fi
|
|
|
|
# Bash preflight passed (no explicit unmet deps)
|
|
log "bash preflight passed — no explicit unmet dependencies"
|
|
|
|
# =============================================================================
|
|
# CLAIM ISSUE (tentative — will unclaim if claude refuses)
|
|
# =============================================================================
|
|
|
|
curl -sf -X POST \
|
|
-H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
"${API}/issues/${ISSUE}/labels" \
|
|
-d '{"labels":["in-progress"]}' >/dev/null 2>&1 || true
|
|
|
|
curl -sf -X DELETE \
|
|
-H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/issues/${ISSUE}/labels/backlog" >/dev/null 2>&1 || true
|
|
|
|
curl -sf -X DELETE \
|
|
-H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/issues/${ISSUE}/labels/backlog" >/dev/null 2>&1 || true
|
|
|
|
CLAIMED=true
|
|
|
|
# =============================================================================
|
|
# CHECK FOR EXISTING PR (recovery mode)
|
|
# =============================================================================
|
|
EXISTING_PR=""
|
|
EXISTING_BRANCH=""
|
|
RECOVERY_MODE=false
|
|
|
|
BODY_PR=$(echo "$ISSUE_BODY" | grep -oP 'Existing PR:\s*#\K[0-9]+' | head -1) || true
|
|
if [ -n "$BODY_PR" ]; then
|
|
PR_CHECK=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/pulls/${BODY_PR}" | jq -r '{state, head_ref: .head.ref}')
|
|
PR_CHECK_STATE=$(echo "$PR_CHECK" | jq -r '.state')
|
|
if [ "$PR_CHECK_STATE" = "open" ]; then
|
|
EXISTING_PR="$BODY_PR"
|
|
EXISTING_BRANCH=$(echo "$PR_CHECK" | jq -r '.head_ref')
|
|
log "found existing PR #${EXISTING_PR} on branch ${EXISTING_BRANCH} (from issue body)"
|
|
fi
|
|
fi
|
|
|
|
if [ -z "$EXISTING_PR" ]; then
|
|
# Priority 1: match by branch name (most reliable)
|
|
FOUND_PR=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/pulls?state=open&limit=20" | \
|
|
jq -r --arg branch "$BRANCH" \
|
|
'.[] | select(.head.ref == $branch) | "\(.number) \(.head.ref)"' | head -1) || true
|
|
if [ -n "$FOUND_PR" ]; then
|
|
EXISTING_PR=$(echo "$FOUND_PR" | awk '{print $1}')
|
|
EXISTING_BRANCH=$(echo "$FOUND_PR" | awk '{print $2}')
|
|
log "found existing PR #${EXISTING_PR} on branch ${EXISTING_BRANCH} (from branch match)"
|
|
fi
|
|
fi
|
|
|
|
if [ -z "$EXISTING_PR" ]; then
|
|
# Priority 2: match "Fixes #NNN" or "fixes #NNN" in PR body (stricter: word boundary)
|
|
FOUND_PR=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/pulls?state=open&limit=20" | \
|
|
jq -r --arg issue "ixes #${ISSUE}\\b" \
|
|
'.[] | select(.body | test($issue; "i")) | "\(.number) \(.head.ref)"' | head -1) || true
|
|
if [ -n "$FOUND_PR" ]; then
|
|
EXISTING_PR=$(echo "$FOUND_PR" | awk '{print $1}')
|
|
EXISTING_BRANCH=$(echo "$FOUND_PR" | awk '{print $2}')
|
|
log "found existing PR #${EXISTING_PR} on branch ${EXISTING_BRANCH} (from body match)"
|
|
fi
|
|
fi
|
|
|
|
# Priority 3: check CLOSED PRs for prior art (don't redo work from scratch)
|
|
PRIOR_ART_DIFF=""
|
|
if [ -z "$EXISTING_PR" ]; then
|
|
CLOSED_PR=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/pulls?state=closed&limit=30" | \
|
|
jq -r --arg issue "#${ISSUE}" \
|
|
'.[] | select(.merged != true) | select((.title | contains($issue)) or (.body // "" | test("ixes " + $issue + "\\b"; "i"))) | "\(.number) \(.head.ref)"' | head -1) || true
|
|
if [ -n "$CLOSED_PR" ]; then
|
|
CLOSED_PR_NUM=$(echo "$CLOSED_PR" | awk '{print $1}')
|
|
log "found closed (unmerged) PR #${CLOSED_PR_NUM} as prior art"
|
|
# Fetch the diff for claude to reference
|
|
PRIOR_ART_DIFF=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/pulls/${CLOSED_PR_NUM}.diff" | head -500) || true
|
|
if [ -n "$PRIOR_ART_DIFF" ]; then
|
|
log "captured prior art diff from PR #${CLOSED_PR_NUM} ($(echo "$PRIOR_ART_DIFF" | wc -l) lines)"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
if [ -n "$EXISTING_PR" ]; then
|
|
RECOVERY_MODE=true
|
|
PR_NUMBER="$EXISTING_PR"
|
|
BRANCH="$EXISTING_BRANCH"
|
|
log "RECOVERY MODE: adopting PR #${PR_NUMBER} on branch ${BRANCH}"
|
|
|
|
PR_SHA=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/pulls/${PR_NUMBER}" | jq -r '.head.sha')
|
|
|
|
PENDING_REVIEW=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/issues/${PR_NUMBER}/comments?limit=50" | \
|
|
jq -r --arg sha "$PR_SHA" \
|
|
'[.[] | select(.body | contains("<!-- reviewed: " + $sha)) | select((.body | contains("REQUEST_CHANGES")) or (.body | contains("DISCUSS")))] | last // empty')
|
|
|
|
if [ -n "$PENDING_REVIEW" ] && [ "$PENDING_REVIEW" != "null" ]; then
|
|
PENDING_REVIEW_TEXT=$(echo "$PENDING_REVIEW" | jq -r '.body')
|
|
log "found unaddressed REQUEST_CHANGES review at ${PR_SHA:0:7}"
|
|
|
|
status "setting up worktree for recovery"
|
|
cd "$REPO_ROOT"
|
|
git fetch origin "$BRANCH" 2>/dev/null
|
|
|
|
# Reuse existing worktree if it's on the right branch (preserves .claude session)
|
|
REUSE_WORKTREE=false
|
|
if [ -d "$WORKTREE/.git" ] || [ -f "$WORKTREE/.git" ]; then
|
|
WT_BRANCH=$(cd "$WORKTREE" && git rev-parse --abbrev-ref HEAD 2>/dev/null || true)
|
|
if [ "$WT_BRANCH" = "$BRANCH" ]; then
|
|
log "reusing existing worktree (preserves claude session)"
|
|
cd "$WORKTREE"
|
|
git pull --ff-only origin "$BRANCH" 2>/dev/null || git reset --hard "origin/${BRANCH}" 2>/dev/null || true
|
|
REUSE_WORKTREE=true
|
|
fi
|
|
fi
|
|
|
|
if [ "$REUSE_WORKTREE" = false ]; then
|
|
cleanup_worktree
|
|
git worktree add "$WORKTREE" "origin/${BRANCH}" -B "$BRANCH" 2>&1 || {
|
|
log "ERROR: worktree creation failed for recovery"
|
|
cleanup_labels
|
|
exit 1
|
|
}
|
|
cd "$WORKTREE"
|
|
git submodule update --init --recursive 2>/dev/null || true
|
|
fi
|
|
|
|
REVIEW_ROUND=1
|
|
status "claude addressing review (recovery)"
|
|
|
|
# Use -c (continue) if session exists, fresh -p otherwise
|
|
CLAUDE_CONTINUE=""
|
|
if [ -d "$WORKTREE/.claude" ] && [ "$REUSE_WORKTREE" = true ]; then
|
|
CLAUDE_CONTINUE="-c"
|
|
log "continuing previous claude session"
|
|
fi
|
|
|
|
REVIEW_PROMPT="You are working in a git worktree at ${WORKTREE} on branch ${BRANCH}.
|
|
This is issue #${ISSUE} for the ${CODEBERG_REPO} project.
|
|
|
|
## Issue: ${ISSUE_TITLE}
|
|
|
|
${ISSUE_BODY}
|
|
|
|
## Instructions
|
|
The AI reviewer has reviewed the PR and requests changes.
|
|
Read AGENTS.md for project context. Address each finding below.
|
|
Run lint and tests when done. Commit your fixes.
|
|
|
|
When you're done, output a SHORT summary of what you changed, formatted as a bullet list.
|
|
|
|
## Review Feedback:
|
|
${PENDING_REVIEW_TEXT}"
|
|
|
|
REVIEW_OUTPUT=$(cd "$WORKTREE" && timeout "$CLAUDE_TIMEOUT" \
|
|
claude -p $CLAUDE_CONTINUE --model sonnet --dangerously-skip-permissions "$REVIEW_PROMPT" 2>&1) || {
|
|
EXIT_CODE=$?
|
|
if [ -n "$CLAUDE_CONTINUE" ]; then
|
|
log "claude -c recovery failed (exit ${EXIT_CODE}), retrying without continue"
|
|
REVIEW_OUTPUT=$(cd "$WORKTREE" && timeout "$CLAUDE_TIMEOUT" \
|
|
claude -p --model sonnet --dangerously-skip-permissions "$REVIEW_PROMPT" 2>&1) || {
|
|
log "claude recovery failed completely (exit $?)"
|
|
exit 1
|
|
}
|
|
else
|
|
log "claude recovery failed (exit ${EXIT_CODE})"
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
log "claude finished recovery review addressing"
|
|
|
|
cd "$WORKTREE"
|
|
if [ -n "$(git status --porcelain)" ]; then
|
|
git add -A
|
|
git commit --no-verify -m "fix: address review findings (#${ISSUE})" 2>&1 | tail -2
|
|
fi
|
|
|
|
REMOTE_SHA=$(git ls-remote origin "$BRANCH" 2>/dev/null | awk '{print $1}')
|
|
LOCAL_SHA=$(git rev-parse HEAD)
|
|
PUSHED=false
|
|
if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then
|
|
git push origin "$BRANCH" --force 2>&1 | tail -3
|
|
log "pushed recovery fixes"
|
|
PUSHED=true
|
|
notify "PR #${PR_NUMBER}: pushed recovery fixes for issue #${ISSUE}"
|
|
else
|
|
log "no changes after recovery review addressing"
|
|
fi
|
|
|
|
if [ "$PUSHED" = true ]; then
|
|
CHANGE_SUMMARY=$(echo "$REVIEW_OUTPUT" | grep '^\s*-' | tail -20)
|
|
[ -z "$CHANGE_SUMMARY" ] && CHANGE_SUMMARY="Changes pushed (see diff for details)."
|
|
|
|
DEV_COMMENT="## 🔧 Dev-agent response (recovery)
|
|
<!-- dev-response: $(git rev-parse HEAD) round:recovery -->
|
|
|
|
### Changes made:
|
|
${CHANGE_SUMMARY}
|
|
|
|
---
|
|
*Addressed at \`$(git rev-parse HEAD | head -c 7)\` · automated by dev-agent (recovery mode)*"
|
|
|
|
printf '%s' "$DEV_COMMENT" > /tmp/dev-comment-body.txt
|
|
jq -Rs '{body: .}' < /tmp/dev-comment-body.txt > /tmp/dev-comment.json
|
|
curl -sf -o /dev/null -X POST \
|
|
-H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
"${API}/issues/${PR_NUMBER}/comments" \
|
|
--data-binary @/tmp/dev-comment.json 2>/dev/null || \
|
|
log "WARNING: failed to post dev-response comment"
|
|
rm -f /tmp/dev-comment-body.txt /tmp/dev-comment.json
|
|
fi
|
|
else
|
|
# Check if PR already has approval — try merge immediately
|
|
EXISTING_APPROVAL=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/pulls/${PR_NUMBER}/reviews" | \
|
|
jq -r '[.[] | select(.stale == false and .state == "APPROVED")] | length')
|
|
CI_NOW=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/commits/$(git -C "$REPO_ROOT" rev-parse "origin/${BRANCH}" 2>/dev/null || echo HEAD)/status" | jq -r '.state // "unknown"')
|
|
CI_PASS=false
|
|
if [ "$CI_NOW" = "success" ]; then
|
|
CI_PASS=true
|
|
elif [ "${WOODPECKER_REPO_ID:-2}" = "0" ] && { [ -z "$CI_NOW" ] || [ "$CI_NOW" = "pending" ] || [ "$CI_NOW" = "unknown" ]; }; then
|
|
CI_PASS=true # no CI configured for this project
|
|
fi
|
|
if [ "${EXISTING_APPROVAL:-0}" -gt 0 ] && [ "$CI_PASS" = true ]; then
|
|
log "PR already approved + CI green — attempting merge"
|
|
MERGE_HTTP=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
|
-H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
"${API}/pulls/${PR_NUMBER}/merge" \
|
|
-d '{"Do":"merge","delete_branch_after_merge":true}')
|
|
if [ "$MERGE_HTTP" = "200" ] || [ "$MERGE_HTTP" = "204" ]; then
|
|
log "PR #${PR_NUMBER} merged!"
|
|
notify "✅ PR #${PR_NUMBER} merged! Issue #${ISSUE} done."
|
|
curl -sf -X PATCH -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
"${API}/issues/${ISSUE}" -d '{"state":"closed"}' >/dev/null 2>&1 || true
|
|
cleanup_labels
|
|
cleanup_worktree
|
|
exit 0
|
|
fi
|
|
# Merge failed — rebase and retry
|
|
log "merge failed (HTTP ${MERGE_HTTP}) — rebasing"
|
|
cd "$REPO_ROOT"
|
|
git fetch origin "${PRIMARY_BRANCH}" "$BRANCH" 2>/dev/null
|
|
TMP_WT="/tmp/rebase-pr-${PR_NUMBER}"
|
|
rm -rf "$TMP_WT"
|
|
if git worktree add "$TMP_WT" "$BRANCH" 2>/dev/null && \
|
|
(cd "$TMP_WT" && git rebase "origin/${PRIMARY_BRANCH}" 2>&1) && \
|
|
(cd "$TMP_WT" && git push --force-with-lease origin "$BRANCH" 2>&1); then
|
|
log "rebased — waiting for CI + re-approval"
|
|
git worktree remove "$TMP_WT" 2>/dev/null || true
|
|
NEW_SHA=$(git rev-parse "origin/${BRANCH}" 2>/dev/null || true)
|
|
# Wait for CI
|
|
for _r in $(seq 1 20); do
|
|
_ci=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/commits/${NEW_SHA}/status" | jq -r '.state // "unknown"')
|
|
[ "$_ci" = "success" ] && break
|
|
sleep 30
|
|
done
|
|
# Re-approve (force push dismissed stale approval)
|
|
curl -sf -X POST -H "Authorization: token ${REVIEW_BOT_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
"${API}/pulls/${PR_NUMBER}/reviews" \
|
|
-d '{"event":"APPROVED","body":"Auto-approved after rebase."}' >/dev/null 2>&1 || true
|
|
# Retry merge
|
|
MERGE_HTTP=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
|
-H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
"${API}/pulls/${PR_NUMBER}/merge" \
|
|
-d '{"Do":"merge","delete_branch_after_merge":true}')
|
|
if [ "$MERGE_HTTP" = "200" ] || [ "$MERGE_HTTP" = "204" ]; then
|
|
log "PR #${PR_NUMBER} merged after rebase!"
|
|
notify "✅ PR #${PR_NUMBER} merged! Issue #${ISSUE} done."
|
|
curl -sf -X PATCH -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
"${API}/issues/${ISSUE}" -d '{"state":"closed"}' >/dev/null 2>&1 || true
|
|
cleanup_labels
|
|
cleanup_worktree
|
|
exit 0
|
|
fi
|
|
notify "PR #${PR_NUMBER} merge failed after rebase (HTTP ${MERGE_HTTP}). Needs human attention."
|
|
else
|
|
git worktree remove --force "$TMP_WT" 2>/dev/null || true
|
|
notify "PR #${PR_NUMBER} rebase failed. Needs human attention."
|
|
fi
|
|
exit 0
|
|
fi
|
|
log "no unaddressed review found — PR exists, entering review loop to wait"
|
|
cd "$REPO_ROOT"
|
|
git fetch origin "$BRANCH" 2>/dev/null
|
|
|
|
# Reuse existing worktree if on the right branch (preserves .claude session)
|
|
if [ -d "$WORKTREE/.git" ] || [ -f "$WORKTREE/.git" ]; then
|
|
WT_BRANCH=$(cd "$WORKTREE" && git rev-parse --abbrev-ref HEAD 2>/dev/null || true)
|
|
if [ "$WT_BRANCH" = "$BRANCH" ]; then
|
|
log "reusing existing worktree (preserves claude session)"
|
|
cd "$WORKTREE"
|
|
git pull --ff-only origin "$BRANCH" 2>/dev/null || git reset --hard "origin/${BRANCH}" 2>/dev/null || true
|
|
else
|
|
cleanup_worktree
|
|
git worktree add "$WORKTREE" "origin/${BRANCH}" -B "$BRANCH" 2>&1 || {
|
|
log "ERROR: worktree setup failed for recovery"
|
|
exit 1
|
|
}
|
|
cd "$WORKTREE"
|
|
git submodule update --init --recursive 2>/dev/null || true
|
|
fi
|
|
else
|
|
cleanup_worktree
|
|
git worktree add "$WORKTREE" "origin/${BRANCH}" -B "$BRANCH" 2>&1 || {
|
|
log "ERROR: worktree setup failed for recovery"
|
|
exit 1
|
|
}
|
|
cd "$WORKTREE"
|
|
git submodule update --init --recursive 2>/dev/null || true
|
|
fi
|
|
fi
|
|
else
|
|
# =============================================================================
|
|
# NORMAL MODE: implement from scratch
|
|
# =============================================================================
|
|
|
|
status "creating worktree"
|
|
cd "$REPO_ROOT"
|
|
|
|
# Ensure repo is in clean state (abort stale rebases, checkout primary branch)
|
|
if [ -d "$REPO_ROOT/.git/rebase-merge" ] || [ -d "$REPO_ROOT/.git/rebase-apply" ]; then
|
|
log "WARNING: stale rebase detected in main repo — aborting"
|
|
git rebase --abort 2>/dev/null || true
|
|
fi
|
|
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "unknown")
|
|
if [ "$CURRENT_BRANCH" != "${PRIMARY_BRANCH}" ]; then
|
|
log "WARNING: main repo on '$CURRENT_BRANCH' instead of ${PRIMARY_BRANCH} — switching"
|
|
git checkout "${PRIMARY_BRANCH}" 2>/dev/null || true
|
|
fi
|
|
|
|
git fetch origin "${PRIMARY_BRANCH}" 2>/dev/null
|
|
git pull --ff-only origin "${PRIMARY_BRANCH}" 2>/dev/null || true
|
|
cleanup_worktree
|
|
git worktree add "$WORKTREE" "origin/${PRIMARY_BRANCH}" -B "$BRANCH" 2>&1 || {
|
|
log "ERROR: worktree creation failed"
|
|
git worktree add "$WORKTREE" "origin/${PRIMARY_BRANCH}" -B "$BRANCH" 2>&1 | while read -r wt_line; do log " $wt_line"; done || true
|
|
cleanup_labels
|
|
exit 1
|
|
}
|
|
cd "$WORKTREE"
|
|
git checkout -B "$BRANCH" "origin/${PRIMARY_BRANCH}" 2>/dev/null
|
|
git submodule update --init --recursive 2>/dev/null || true
|
|
|
|
|
|
# Symlink lib node_modules from main repo (submodule init doesn't run npm install)
|
|
for lib_dir in "$REPO_ROOT"/onchain/lib/*/; do
|
|
lib_name=$(basename "$lib_dir")
|
|
if [ -d "$lib_dir/node_modules" ] && [ ! -d "$WORKTREE/onchain/lib/$lib_name/node_modules" ]; then
|
|
ln -s "$lib_dir/node_modules" "$WORKTREE/onchain/lib/$lib_name/node_modules" 2>/dev/null || true
|
|
fi
|
|
done
|
|
|
|
# --- Build the unified prompt: implement OR refuse ---
|
|
# Gather open issue list for context (so claude can suggest alternatives)
|
|
OPEN_ISSUES_SUMMARY=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/issues?state=open&labels=backlog&limit=20&type=issues" | \
|
|
jq -r '.[] | "#\(.number) \(.title)"' 2>/dev/null || echo "(could not fetch)")
|
|
|
|
PROMPT="You are working in a git worktree at ${WORKTREE} on branch ${BRANCH}.
|
|
You have been assigned issue #${ISSUE} for the ${CODEBERG_REPO} project.
|
|
|
|
## Issue: ${ISSUE_TITLE}
|
|
|
|
${ISSUE_BODY}
|
|
|
|
## Other open issues labeled 'backlog' (for context if you need to suggest alternatives):
|
|
${OPEN_ISSUES_SUMMARY}
|
|
|
|
$(if [ -n "$PRIOR_ART_DIFF" ]; then echo "## Prior Art (closed PR — DO NOT start from scratch)
|
|
|
|
A previous PR attempted this issue but was closed without merging. Review the diff below and reuse as much as possible. Fix whatever caused it to fail (merge conflicts, CI errors, review findings).
|
|
|
|
\`\`\`diff
|
|
${PRIOR_ART_DIFF}
|
|
\`\`\`"; fi)
|
|
|
|
## Instructions
|
|
|
|
**Before implementing, assess whether you should proceed.** You have two options:
|
|
|
|
### Option A: Implement
|
|
If the issue is clear, dependencies are met, and scope is reasonable:
|
|
1. Read AGENTS.md in this repo for project context and coding conventions.
|
|
2. Implement the changes described in the issue.
|
|
3. Run lint and tests before you're done (see AGENTS.md for commands).
|
|
4. Commit your changes with message: fix: ${ISSUE_TITLE} (#${ISSUE})
|
|
5. Do NOT push or create PRs — the orchestrator handles that.
|
|
6. When finished, output a summary of what you changed and why.
|
|
|
|
### Option B: Refuse (output JSON only)
|
|
If you cannot or should not implement this issue, output ONLY a JSON object (no other text) with one of these structures:
|
|
|
|
**Unmet dependency** — required code/infrastructure doesn't exist in the repo yet:
|
|
\`\`\`
|
|
{\"status\": \"unmet_dependency\", \"blocked_by\": \"short explanation of what's missing\", \"suggestion\": <issue-number-to-work-on-first or null>}
|
|
\`\`\`
|
|
|
|
**Too large** — issue needs to be split, spec is too vague, or scope exceeds a single session:
|
|
\`\`\`
|
|
{\"status\": \"too_large\", \"reason\": \"what makes it too large and how to split it\"}
|
|
\`\`\`
|
|
|
|
**Already done** — the work described is already implemented in the codebase:
|
|
\`\`\`
|
|
{\"status\": \"already_done\", \"reason\": \"where the existing implementation is\"}
|
|
\`\`\`
|
|
|
|
### How to decide
|
|
- Read the issue carefully. Check if files/functions it references actually exist in the repo.
|
|
- If it depends on other issues, check if those issues' deliverables are present in the codebase.
|
|
- If the issue spec is vague or requires designing multiple new systems, refuse as too_large.
|
|
- If another open issue should be done first, suggest it.
|
|
- When in doubt, implement. Only refuse if there's a clear, specific reason.
|
|
|
|
**Do NOT invent dependencies that aren't real.** If the code compiles and tests pass, that's ready."
|
|
|
|
status "claude assessing + implementing"
|
|
IMPL_OUTPUT=$(cd "$WORKTREE" && timeout "$CLAUDE_TIMEOUT" \
|
|
claude -p --model sonnet --dangerously-skip-permissions "$PROMPT" 2>&1) || {
|
|
EXIT_CODE=$?
|
|
if [ "$EXIT_CODE" -eq 124 ]; then
|
|
log "TIMEOUT: claude took longer than ${CLAUDE_TIMEOUT}s"
|
|
notify "timed out during implementation"
|
|
else
|
|
log "ERROR: claude exited with code ${EXIT_CODE}"
|
|
notify "claude failed (exit ${EXIT_CODE})"
|
|
fi
|
|
cleanup_labels
|
|
cleanup_worktree
|
|
exit 1
|
|
}
|
|
|
|
log "claude finished ($(printf '%s' "$IMPL_OUTPUT" | wc -c) bytes)"
|
|
printf '%s' "$IMPL_OUTPUT" > /tmp/dev-agent-last-output.txt
|
|
|
|
# --- Check if claude refused (JSON response) vs implemented (commits) ---
|
|
REFUSAL_JSON=""
|
|
|
|
# Check for refusal: try to parse output as JSON with a status field
|
|
# First try raw output
|
|
if printf '%s' "$IMPL_OUTPUT" | jq -e '.status' > /dev/null 2>&1; then
|
|
REFUSAL_JSON="$IMPL_OUTPUT"
|
|
else
|
|
# Try extracting from code fence
|
|
EXTRACTED=$(printf '%s' "$IMPL_OUTPUT" | sed -n '/^```/,/^```$/p' | sed '1d;$d')
|
|
if [ -n "$EXTRACTED" ] && printf '%s' "$EXTRACTED" | jq -e '.status' > /dev/null 2>&1; then
|
|
REFUSAL_JSON="$EXTRACTED"
|
|
else
|
|
# Try extracting first { ... } block (handles preamble text before JSON)
|
|
EXTRACTED=$(printf '%s' "$IMPL_OUTPUT" | grep -Pzo '\{[^{}]*"status"[^{}]*\}' 2>/dev/null | tr '\0' '\n' | head -1 || true)
|
|
if [ -n "$EXTRACTED" ] && printf '%s' "$EXTRACTED" | jq -e '.status' > /dev/null 2>&1; then
|
|
REFUSAL_JSON="$EXTRACTED"
|
|
fi
|
|
fi
|
|
fi
|
|
|
|
# But only treat as refusal if there are NO commits (claude might output JSON-like text AND commit)
|
|
cd "$WORKTREE"
|
|
AHEAD=$(git rev-list "origin/${PRIMARY_BRANCH}..HEAD" --count 2>/dev/null || echo "0")
|
|
HAS_CHANGES=$(git status --porcelain)
|
|
|
|
if [ -n "$REFUSAL_JSON" ] && [ "$AHEAD" -eq 0 ] && [ -z "$HAS_CHANGES" ]; then
|
|
# Claude refused — parse and handle
|
|
REFUSAL_STATUS=$(printf '%s' "$REFUSAL_JSON" | jq -r '.status')
|
|
log "claude refused: ${REFUSAL_STATUS}"
|
|
|
|
# Write preflight result for dev-poll.sh
|
|
printf '%s' "$REFUSAL_JSON" > "$PREFLIGHT_RESULT"
|
|
|
|
# Unclaim issue (restore backlog label, remove in-progress)
|
|
cleanup_labels
|
|
curl -sf -X POST \
|
|
-H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
"${API}/issues/${ISSUE}/labels" \
|
|
-d '{"labels":["backlog"]}' >/dev/null 2>&1 || true
|
|
|
|
# --- Post refusal comment on the issue (deduplicated) ---
|
|
post_refusal_comment() {
|
|
local emoji="$1" title="$2" body="$3"
|
|
|
|
# Skip if last comment already has same title (prevent spam)
|
|
local last_has_title
|
|
last_has_title=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/issues/${ISSUE}/comments?limit=1" | \
|
|
jq -r --arg t "Dev-agent: ${title}" '.[0].body // "" | contains($t)') || true
|
|
if [ "$last_has_title" = "true" ]; then
|
|
log "skipping duplicate refusal comment: ${title}"
|
|
return 0
|
|
fi
|
|
|
|
local comment="${emoji} **Dev-agent: ${title}**
|
|
|
|
${body}
|
|
|
|
---
|
|
*Automated assessment by dev-agent · $(date -u '+%Y-%m-%d %H:%M UTC')*"
|
|
|
|
printf '%s' "$comment" > "/tmp/refusal-comment.txt"
|
|
jq -Rs '{body: .}' < "/tmp/refusal-comment.txt" > "/tmp/refusal-comment.json"
|
|
curl -sf -o /dev/null -X POST \
|
|
-H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
"${API}/issues/${ISSUE}/comments" \
|
|
--data-binary @"/tmp/refusal-comment.json" 2>/dev/null || \
|
|
log "WARNING: failed to post refusal comment"
|
|
rm -f "/tmp/refusal-comment.txt" "/tmp/refusal-comment.json"
|
|
}
|
|
|
|
case "$REFUSAL_STATUS" in
|
|
unmet_dependency)
|
|
BLOCKED_BY=$(printf '%s' "$REFUSAL_JSON" | jq -r '.blocked_by // "unknown"')
|
|
SUGGESTION=$(printf '%s' "$REFUSAL_JSON" | jq -r '.suggestion // empty')
|
|
log "unmet dependency: ${BLOCKED_BY}. suggestion: ${SUGGESTION:-none}"
|
|
notify "refused #${ISSUE}: unmet dependency — ${BLOCKED_BY}"
|
|
|
|
COMMENT_BODY="### Blocked by unmet dependency
|
|
|
|
${BLOCKED_BY}"
|
|
if [ -n "$SUGGESTION" ] && [ "$SUGGESTION" != "null" ]; then
|
|
COMMENT_BODY="${COMMENT_BODY}
|
|
|
|
**Suggestion:** Work on #${SUGGESTION} first."
|
|
fi
|
|
post_refusal_comment "🚧" "Unmet dependency" "$COMMENT_BODY"
|
|
;;
|
|
too_large)
|
|
REASON=$(printf '%s' "$REFUSAL_JSON" | jq -r '.reason // "unspecified"')
|
|
log "too large: ${REASON}"
|
|
notify "refused #${ISSUE}: too large — ${REASON}"
|
|
|
|
post_refusal_comment "📏" "Too large for single session" "### Why this can't be implemented as-is
|
|
|
|
${REASON}
|
|
|
|
### Next steps
|
|
A maintainer should split this issue or add more detail to the spec."
|
|
|
|
# Label as underspecified
|
|
curl -sf -X POST \
|
|
-H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
"${API}/issues/${ISSUE}/labels" \
|
|
-d '{"labels":["underspecified"]}' >/dev/null 2>&1 || true
|
|
curl -sf -X DELETE \
|
|
-H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/issues/${ISSUE}/labels/backlog" >/dev/null 2>&1 || true
|
|
;;
|
|
already_done)
|
|
REASON=$(printf '%s' "$REFUSAL_JSON" | jq -r '.reason // "unspecified"')
|
|
log "already done: ${REASON}"
|
|
notify "refused #${ISSUE}: already done — ${REASON}"
|
|
|
|
post_refusal_comment "✅" "Already implemented" "### Existing implementation
|
|
|
|
${REASON}
|
|
|
|
Closing as already implemented."
|
|
|
|
# Close the issue to prevent retry loops
|
|
curl -sf -X PATCH \
|
|
-H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
"${API}/issues/${ISSUE}" \
|
|
-d '{"state":"closed"}' >/dev/null 2>&1 || true
|
|
;;
|
|
*)
|
|
log "unknown refusal status: ${REFUSAL_STATUS}"
|
|
notify "refused #${ISSUE}: unknown reason"
|
|
|
|
post_refusal_comment "❓" "Unable to proceed" "The dev-agent could not process this issue.
|
|
|
|
Raw response:
|
|
\`\`\`json
|
|
$(printf '%s' "$REFUSAL_JSON" | head -c 2000)
|
|
\`\`\`"
|
|
;;
|
|
esac
|
|
|
|
cleanup_worktree
|
|
exit 0
|
|
fi
|
|
|
|
# --- Claude implemented (has commits or changes) ---
|
|
# Write ready status for dev-poll.sh
|
|
echo '{"status":"ready"}' > "$PREFLIGHT_RESULT"
|
|
|
|
if [ -z "$HAS_CHANGES" ] && [ "$AHEAD" -eq 0 ]; then
|
|
log "ERROR: no changes and no refusal JSON"
|
|
notify "no changes made, aborting"
|
|
cleanup_labels
|
|
cleanup_worktree
|
|
exit 1
|
|
fi
|
|
|
|
if [ -n "$HAS_CHANGES" ]; then
|
|
status "committing changes"
|
|
git add -A
|
|
git commit --no-verify -m "fix: ${ISSUE_TITLE} (#${ISSUE})" 2>&1 | tail -2
|
|
else
|
|
log "claude already committed (${AHEAD} commits ahead)"
|
|
fi
|
|
|
|
log "HEAD: $(git log --oneline -1)"
|
|
|
|
status "pushing branch"
|
|
if ! git push origin "$BRANCH" --force 2>&1 | tail -3; then
|
|
log "ERROR: git push failed"
|
|
notify "failed to push branch ${BRANCH}"
|
|
cleanup_labels
|
|
cleanup_worktree
|
|
exit 1
|
|
fi
|
|
log "pushed ${BRANCH}"
|
|
|
|
status "creating PR"
|
|
IMPL_SUMMARY=$(echo "$IMPL_OUTPUT" | tail -40 | head -c 4000)
|
|
|
|
# Build PR body safely via file (avoids command-line arg size limits)
|
|
printf 'Fixes #%s\n\n## Changes\n%s' "$ISSUE" "$IMPL_SUMMARY" > /tmp/pr-body-${ISSUE}.txt
|
|
jq -n \
|
|
--arg title "fix: ${ISSUE_TITLE} (#${ISSUE})" \
|
|
--rawfile body "/tmp/pr-body-${ISSUE}.txt" \
|
|
--arg head "$BRANCH" \
|
|
--arg base "${PRIMARY_BRANCH}" \
|
|
'{title: $title, body: $body, head: $head, base: $base}' > /tmp/pr-request-${ISSUE}.json
|
|
|
|
PR_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
|
-H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
"${API}/pulls" \
|
|
--data-binary @/tmp/pr-request-${ISSUE}.json)
|
|
|
|
PR_HTTP_CODE=$(echo "$PR_RESPONSE" | tail -1)
|
|
PR_RESPONSE=$(echo "$PR_RESPONSE" | sed '$d')
|
|
rm -f /tmp/pr-body-${ISSUE}.txt /tmp/pr-request-${ISSUE}.json
|
|
|
|
if [ "$PR_HTTP_CODE" != "201" ] && [ "$PR_HTTP_CODE" != "200" ]; then
|
|
log "ERROR: PR creation failed (HTTP ${PR_HTTP_CODE}): $(echo "$PR_RESPONSE" | head -3)"
|
|
notify "failed to create PR (HTTP ${PR_HTTP_CODE})"
|
|
cleanup_labels
|
|
cleanup_worktree
|
|
exit 1
|
|
fi
|
|
|
|
PR_NUMBER=$(echo "$PR_RESPONSE" | jq -r '.number')
|
|
|
|
if [ "$PR_NUMBER" = "null" ] || [ -z "$PR_NUMBER" ]; then
|
|
log "ERROR: failed to create PR: $(echo "$PR_RESPONSE" | head -5)"
|
|
notify "failed to create PR"
|
|
cleanup_labels
|
|
cleanup_worktree
|
|
exit 1
|
|
fi
|
|
|
|
log "created PR #${PR_NUMBER}"
|
|
notify "PR #${PR_NUMBER} created for issue #${ISSUE}: ${ISSUE_TITLE}"
|
|
fi
|
|
|
|
# MERGE HELPER
|
|
# =============================================================================
|
|
do_merge() {
|
|
local sha="$1"
|
|
|
|
for m in $(seq 1 20); do
|
|
local ci
|
|
ci=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/commits/${sha}/status" | jq -r '.state // "unknown"')
|
|
[ "$ci" = "success" ] && break
|
|
sleep 30
|
|
done
|
|
|
|
# Pre-emptive rebase to avoid merge conflicts
|
|
local mergeable
|
|
mergeable=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/pulls/${PR_NUMBER}" | jq -r '.mergeable // true')
|
|
if [ "$mergeable" = "false" ]; then
|
|
log "PR #${PR_NUMBER} has merge conflicts — attempting rebase"
|
|
local work_dir="${WORKTREE:-$REPO_ROOT}"
|
|
if (cd "$work_dir" && git fetch origin "${PRIMARY_BRANCH}" && git rebase "origin/${PRIMARY_BRANCH}" 2>&1); then
|
|
log "rebase succeeded — force pushing"
|
|
(cd "$work_dir" && git push origin "${BRANCH}" --force-with-lease 2>&1) || true
|
|
# Wait for CI on the new commit
|
|
sha=$(cd "$work_dir" && git rev-parse HEAD)
|
|
log "waiting for CI on rebased commit ${sha:0:7}"
|
|
for r in $(seq 1 20); do
|
|
ci=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/commits/${sha}/status" | jq -r '.state // "unknown"')
|
|
[ "$ci" = "success" ] && break
|
|
[ "$ci" = "failure" ] || [ "$ci" = "error" ] && { log "CI failed after rebase"; notify "PR #${PR_NUMBER} CI failed after rebase. Needs manual fix."; exit 0; }
|
|
sleep 30
|
|
done
|
|
else
|
|
log "rebase failed — aborting and escalating"
|
|
(cd "$work_dir" && git rebase --abort 2>/dev/null) || true
|
|
notify "PR #${PR_NUMBER} has merge conflicts that need manual resolution."
|
|
exit 0
|
|
fi
|
|
fi
|
|
|
|
local http_code
|
|
http_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
|
-H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
"${API}/pulls/${PR_NUMBER}/merge" \
|
|
-d '{"Do":"merge","delete_branch_after_merge":true}')
|
|
|
|
if [ "$http_code" = "200" ] || [ "$http_code" = "204" ]; then
|
|
log "PR #${PR_NUMBER} merged!"
|
|
|
|
|
|
curl -sf -X DELETE \
|
|
-H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/branches/${BRANCH}" >/dev/null 2>&1 || true
|
|
|
|
curl -sf -X PATCH \
|
|
-H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
"${API}/issues/${ISSUE}" \
|
|
-d '{"state":"closed"}' >/dev/null 2>&1 || true
|
|
cleanup_labels
|
|
|
|
notify "✅ PR #${PR_NUMBER} merged! Issue #${ISSUE} done."
|
|
cleanup_worktree
|
|
exit 0
|
|
else
|
|
log "merge failed (HTTP ${http_code}) — attempting rebase and retry"
|
|
local work_dir="${WORKTREE:-$REPO_ROOT}"
|
|
if (cd "$work_dir" && git fetch origin "${PRIMARY_BRANCH}" && git rebase "origin/${PRIMARY_BRANCH}" 2>&1); then
|
|
log "rebase succeeded — force pushing"
|
|
(cd "$work_dir" && git push origin "${BRANCH}" --force-with-lease 2>&1) || true
|
|
sha=$(cd "$work_dir" && git rev-parse HEAD)
|
|
log "waiting for CI on rebased commit ${sha:0:7}"
|
|
for r in $(seq 1 20); do
|
|
ci=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/commits/${sha}/status" | jq -r '.state // "unknown"')
|
|
[ "$ci" = "success" ] && break
|
|
[ "$ci" = "failure" ] || [ "$ci" = "error" ] && { log "CI failed after merge-retry rebase"; notify "PR #${PR_NUMBER} CI failed after rebase. Needs manual fix."; exit 0; }
|
|
sleep 30
|
|
done
|
|
# Re-approve (force push dismisses stale approvals)
|
|
curl -sf -X POST -H "Authorization: token ${REVIEW_BOT_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
"${API}/pulls/${PR_NUMBER}/reviews" \
|
|
-d "{\"event\":\"APPROVED\",\"body\":\"Auto-approved after rebase.\"}" >/dev/null 2>&1 || true
|
|
# Retry merge
|
|
http_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \
|
|
-H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
"${API}/pulls/${PR_NUMBER}/merge" \
|
|
-d '{"Do":"merge","delete_branch_after_merge":true}')
|
|
if [ "$http_code" = "200" ] || [ "$http_code" = "204" ]; then
|
|
log "PR #${PR_NUMBER} merged after rebase!"
|
|
notify "✅ PR #${PR_NUMBER} merged! Issue #${ISSUE} done."
|
|
curl -sf -X PATCH -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
"${API}/issues/${ISSUE}" -d '{"state":"closed"}' >/dev/null 2>&1 || true
|
|
cleanup_labels
|
|
cleanup_worktree
|
|
exit 0
|
|
fi
|
|
else
|
|
(cd "$work_dir" && git rebase --abort 2>/dev/null) || true
|
|
fi
|
|
log "merge still failing after rebase (HTTP ${http_code})"
|
|
notify "PR #${PR_NUMBER} merge failed after rebase (HTTP ${http_code}). Needs human attention."
|
|
exit 0
|
|
fi
|
|
}
|
|
|
|
# =============================================================================
|
|
# REVIEW LOOP
|
|
# =============================================================================
|
|
REVIEW_ROUND=0
|
|
CI_RETRY_COUNT=0
|
|
CI_FIX_COUNT=0
|
|
|
|
while [ "$REVIEW_ROUND" -lt "$MAX_REVIEW_ROUNDS" ]; do
|
|
status "waiting for CI + review on PR #${PR_NUMBER} (round $((REVIEW_ROUND + 1)))"
|
|
|
|
CI_DONE=false
|
|
for i in $(seq 1 60); do
|
|
sleep 30
|
|
CURRENT_SHA=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/pulls/${PR_NUMBER}" | jq -r '.head.sha')
|
|
CI_STATE=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/commits/${CURRENT_SHA}/status" | jq -r '.state // "unknown"')
|
|
|
|
# No CI configured — treat as success immediately
|
|
if [ "${WOODPECKER_REPO_ID:-2}" = "0" ] && { [ -z "$CI_STATE" ] || [ "$CI_STATE" = "pending" ] || [ "$CI_STATE" = "unknown" ]; }; then
|
|
log "no CI configured — skipping CI wait"
|
|
CI_STATE="success"
|
|
CI_DONE=true
|
|
CI_FIX_COUNT=0
|
|
break
|
|
fi
|
|
|
|
if [ "$CI_STATE" = "success" ] || [ "$CI_STATE" = "failure" ] || [ "$CI_STATE" = "error" ]; then
|
|
log "CI: ${CI_STATE}"
|
|
CI_DONE=true
|
|
# Reset CI fix budget on success — each phase gets fresh attempts
|
|
if [ "$CI_STATE" = "success" ]; then
|
|
CI_FIX_COUNT=0
|
|
fi
|
|
break
|
|
fi
|
|
done
|
|
|
|
if ! $CI_DONE; then
|
|
log "TIMEOUT: CI didn't complete in 30min"
|
|
notify "CI timeout on PR #${PR_NUMBER}"
|
|
exit 1
|
|
fi
|
|
|
|
# --- Handle CI failure ---
|
|
if [ "$CI_STATE" = "failure" ] || [ "$CI_STATE" = "error" ]; then
|
|
PIPELINE_NUM=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/commits/${CURRENT_SHA}/status" | jq -r '.statuses[0].target_url // ""' | grep -oP 'pipeline/\K[0-9]+' | head -1 || true)
|
|
|
|
FAILED_STEP=""
|
|
FAILED_EXIT=""
|
|
if [ -n "$PIPELINE_NUM" ]; then
|
|
FAILED_INFO=$(curl -sf \
|
|
-H "Authorization: Bearer ${WOODPECKER_TOKEN}" \
|
|
"${WOODPECKER_SERVER}/api/repos/${WOODPECKER_REPO_ID}/pipelines/${PIPELINE_NUM}" | \
|
|
jq -r '.workflows[]?.children[]? | select(.state=="failure") | "\(.name)|\(.exit_code)"' | head -1)
|
|
FAILED_STEP=$(echo "$FAILED_INFO" | cut -d'|' -f1)
|
|
FAILED_EXIT=$(echo "$FAILED_INFO" | cut -d'|' -f2)
|
|
fi
|
|
|
|
log "CI failed: step=${FAILED_STEP:-unknown} exit=${FAILED_EXIT:-?}"
|
|
|
|
IS_INFRA=false
|
|
case "${FAILED_STEP}" in git*) IS_INFRA=true ;; esac
|
|
case "${FAILED_EXIT}" in 128|137) IS_INFRA=true ;; esac
|
|
|
|
if [ "$IS_INFRA" = true ] && [ "${CI_RETRY_COUNT:-0}" -lt 1 ]; then
|
|
CI_RETRY_COUNT=$(( ${CI_RETRY_COUNT:-0} + 1 ))
|
|
log "infra failure — retrigger CI (retry ${CI_RETRY_COUNT})"
|
|
cd "$WORKTREE"
|
|
git commit --allow-empty -m "ci: retrigger after infra failure" --no-verify 2>&1 | tail -1
|
|
git push origin "$BRANCH" --force 2>&1 | tail -3
|
|
continue
|
|
fi
|
|
|
|
CI_FIX_COUNT=$(( ${CI_FIX_COUNT:-0} + 1 ))
|
|
if [ "$CI_FIX_COUNT" -gt 2 ]; then
|
|
log "CI failure not recoverable after ${CI_FIX_COUNT} fix attempts"
|
|
# Escalate to supervisor — write marker for supervisor-poll.sh to pick up
|
|
echo "{\"issue\":${ISSUE},\"pr\":${PR_NUMBER},\"reason\":\"ci_exhausted\",\"step\":\"${FAILED_STEP:-unknown}\",\"attempts\":${CI_FIX_COUNT},\"ts\":\"$(date -u +%Y-%m-%dT%H:%M:%SZ)\"}" \
|
|
>> "${FACTORY_ROOT}/supervisor/escalations.jsonl"
|
|
log "escalated to supervisor via escalations.jsonl"
|
|
break
|
|
fi
|
|
|
|
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 || echo "")
|
|
fi
|
|
|
|
log "CI code failure — feeding back to claude (attempt ${CI_FIX_COUNT})"
|
|
status "claude fixing CI failure (attempt ${CI_FIX_COUNT})"
|
|
|
|
CI_FIX_PROMPT="CI failed on your PR for issue #${ISSUE}: ${ISSUE_TITLE}
|
|
You are in worktree ${WORKTREE} on branch ${BRANCH}.
|
|
|
|
## CI Debug Tool
|
|
\`\`\`bash
|
|
bash "${FACTORY_ROOT}/lib/ci-debug.sh" status ${PIPELINE_NUM:-0}
|
|
bash "${FACTORY_ROOT}/lib/ci-debug.sh" logs ${PIPELINE_NUM:-0} <step-name>
|
|
bash "${FACTORY_ROOT}/lib/ci-debug.sh" failures ${PIPELINE_NUM:-0}
|
|
\`\`\`
|
|
|
|
## Failed step: ${FAILED_STEP:-unknown} (exit code ${FAILED_EXIT:-?}, pipeline #${PIPELINE_NUM:-?})
|
|
|
|
## Error snippet:
|
|
\`\`\`
|
|
${CI_ERROR_LOG:-No logs available. Use ci-debug.sh to query the pipeline.}
|
|
\`\`\`
|
|
|
|
## Instructions
|
|
1. Run ci-debug.sh failures to get full error output.
|
|
2. Read the failing test file(s) — understand what the tests EXPECT.
|
|
3. Read AGENTS.md for conventions.
|
|
4. Fix the root cause — do NOT weaken tests.
|
|
5. Run lint/typecheck if applicable. Commit your fix.
|
|
6. Output a SHORT bullet-list summary."
|
|
|
|
CI_FIX_OUTPUT=""
|
|
if [ "$CI_FIX_COUNT" -eq 1 ] && [ "$REVIEW_ROUND" -eq 0 ]; then
|
|
CI_FIX_OUTPUT=$(cd "$WORKTREE" && timeout "$CLAUDE_TIMEOUT" \
|
|
claude -p -c --model sonnet --dangerously-skip-permissions "$CI_FIX_PROMPT" 2>&1) || {
|
|
CI_FIX_OUTPUT=$(cd "$WORKTREE" && timeout "$CLAUDE_TIMEOUT" \
|
|
claude -p --model sonnet --dangerously-skip-permissions "$CI_FIX_PROMPT" 2>&1) || true
|
|
}
|
|
else
|
|
CI_FIX_OUTPUT=$(cd "$WORKTREE" && timeout "$CLAUDE_TIMEOUT" \
|
|
claude -p -c --model sonnet --dangerously-skip-permissions "$CI_FIX_PROMPT" 2>&1) || {
|
|
CI_FIX_OUTPUT=$(cd "$WORKTREE" && timeout "$CLAUDE_TIMEOUT" \
|
|
claude -p --model sonnet --dangerously-skip-permissions "$CI_FIX_PROMPT" 2>&1) || true
|
|
}
|
|
fi
|
|
|
|
log "claude finished CI fix attempt ${CI_FIX_COUNT}"
|
|
|
|
cd "$WORKTREE"
|
|
if [ -n "$(git status --porcelain)" ]; then
|
|
git add -A
|
|
git commit --no-verify -m "fix: CI failure in ${FAILED_STEP:-build} (#${ISSUE})" 2>&1 | tail -2
|
|
fi
|
|
|
|
REMOTE_SHA=$(git ls-remote origin "$BRANCH" 2>/dev/null | awk '{print $1}')
|
|
LOCAL_SHA=$(git rev-parse HEAD)
|
|
if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then
|
|
git push origin "$BRANCH" --force 2>&1 | tail -3
|
|
log "pushed CI fix (attempt ${CI_FIX_COUNT})"
|
|
notify "PR #${PR_NUMBER}: pushed CI fix attempt ${CI_FIX_COUNT} (${FAILED_STEP:-build})"
|
|
else
|
|
log "no changes after CI fix attempt — bailing"
|
|
notify "❌ PR #${PR_NUMBER}: claude couldn't fix CI failure in ${FAILED_STEP:-unknown}. Needs human attention."
|
|
break
|
|
fi
|
|
|
|
continue
|
|
fi
|
|
|
|
# --- Wait for review ---
|
|
REVIEW_TEXT=""
|
|
for i in $(seq 1 36); do
|
|
sleep "$REVIEW_POLL_INTERVAL"
|
|
|
|
CURRENT_SHA=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/pulls/${PR_NUMBER}" | jq -r '.head.sha')
|
|
|
|
REVIEW_COMMENT=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/issues/${PR_NUMBER}/comments?limit=50" | \
|
|
jq -r --arg sha "$CURRENT_SHA" \
|
|
'[.[] | select(.body | contains("<!-- reviewed: " + $sha))] | last // empty')
|
|
|
|
if [ -n "$REVIEW_COMMENT" ] && [ "$REVIEW_COMMENT" != "null" ]; then
|
|
REVIEW_TEXT=$(echo "$REVIEW_COMMENT" | jq -r '.body')
|
|
|
|
# Skip error reviews — they have no verdict
|
|
if echo "$REVIEW_TEXT" | grep -q "review-error\|Review — Error"; then
|
|
log "review was an error, waiting for re-review"
|
|
continue
|
|
fi
|
|
|
|
VERDICT=$(echo "$REVIEW_TEXT" | grep -oP '\*\*(APPROVE|REQUEST_CHANGES|DISCUSS)\*\*' | head -1 | tr -d '*' || true)
|
|
log "review received: ${VERDICT:-unknown}"
|
|
|
|
# Also check formal Codeberg reviews
|
|
if [ -z "$VERDICT" ]; then
|
|
VERDICT=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/pulls/${PR_NUMBER}/reviews" | \
|
|
jq -r '[.[] | select(.stale == false)] | last | .state // empty' || true)
|
|
if [ "$VERDICT" = "APPROVED" ]; then
|
|
VERDICT="APPROVE"
|
|
elif [ "$VERDICT" = "REQUEST_CHANGES" ]; then
|
|
VERDICT="REQUEST_CHANGES"
|
|
else
|
|
VERDICT=""
|
|
fi
|
|
if [ -n "$VERDICT" ]; then
|
|
log "verdict from formal review: $VERDICT"
|
|
fi
|
|
fi
|
|
|
|
if [ "$VERDICT" = "APPROVE" ]; then
|
|
do_merge "$CURRENT_SHA"
|
|
fi
|
|
|
|
[ -n "$VERDICT" ] && break
|
|
|
|
# No verdict found in comment or formal review — keep waiting
|
|
log "review comment found but no verdict, continuing to wait"
|
|
continue
|
|
fi
|
|
|
|
PR_JSON=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/pulls/${PR_NUMBER}")
|
|
PR_STATE=$(echo "$PR_JSON" | jq -r '.state')
|
|
PR_MERGED=$(echo "$PR_JSON" | jq -r '.merged')
|
|
if [ "$PR_STATE" != "open" ]; then
|
|
if [ "$PR_MERGED" = "true" ]; then
|
|
log "PR #${PR_NUMBER} was merged externally"
|
|
notify "✅ PR #${PR_NUMBER} merged! Issue #${ISSUE} done."
|
|
curl -sf -X PATCH -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
"${API}/issues/${ISSUE}" -d '{"state":"closed"}' >/dev/null 2>&1 || true
|
|
else
|
|
log "PR #${PR_NUMBER} was closed WITHOUT merge — NOT closing issue"
|
|
notify "⚠️ PR #${PR_NUMBER} closed without merge. Issue #${ISSUE} remains open."
|
|
fi
|
|
cleanup_labels
|
|
cleanup_worktree
|
|
exit 0
|
|
fi
|
|
|
|
status "waiting for review on PR #${PR_NUMBER} (${i}/36)"
|
|
done
|
|
|
|
if [ -z "$REVIEW_TEXT" ]; then
|
|
log "TIMEOUT: no review after 3h"
|
|
notify "no review received for PR #${PR_NUMBER} after 3h"
|
|
break
|
|
fi
|
|
|
|
# --- Address review ---
|
|
REVIEW_ROUND=$((REVIEW_ROUND + 1))
|
|
status "claude addressing review round ${REVIEW_ROUND}"
|
|
|
|
REVIEW_PROMPT="The AI reviewer has reviewed your PR and requests changes.
|
|
Address each finding below. Run lint and tests when done. Commit your fixes.
|
|
|
|
When you're done, output a SHORT summary of what you changed, formatted as a bullet list.
|
|
|
|
## Review Feedback (Round ${REVIEW_ROUND}):
|
|
${REVIEW_TEXT}"
|
|
|
|
REVIEW_OUTPUT=$(cd "$WORKTREE" && timeout "$CLAUDE_TIMEOUT" \
|
|
claude -p -c --model sonnet --dangerously-skip-permissions "$REVIEW_PROMPT" 2>&1) || {
|
|
EXIT_CODE=$?
|
|
log "claude -c failed (exit ${EXIT_CODE}), retrying without --continue"
|
|
REVIEW_OUTPUT=$(cd "$WORKTREE" && timeout "$CLAUDE_TIMEOUT" \
|
|
claude -p --model sonnet --dangerously-skip-permissions "$REVIEW_PROMPT" 2>&1) || true
|
|
}
|
|
|
|
log "claude finished review round ${REVIEW_ROUND}"
|
|
|
|
cd "$WORKTREE"
|
|
if [ -n "$(git status --porcelain)" ]; then
|
|
git add -A
|
|
git commit --no-verify -m "fix: address review round ${REVIEW_ROUND} (#${ISSUE})" 2>&1 | tail -2
|
|
fi
|
|
|
|
REMOTE_SHA=$(git ls-remote origin "$BRANCH" 2>/dev/null | awk '{print $1}')
|
|
LOCAL_SHA=$(git rev-parse HEAD)
|
|
PUSHED=false
|
|
if [ "$LOCAL_SHA" != "$REMOTE_SHA" ]; then
|
|
git push origin "$BRANCH" --force 2>&1 | tail -3
|
|
log "pushed review fixes (round ${REVIEW_ROUND})"
|
|
PUSHED=true
|
|
notify "PR #${PR_NUMBER}: pushed review round ${REVIEW_ROUND} fixes"
|
|
else
|
|
log "no changes after review round ${REVIEW_ROUND}"
|
|
fi
|
|
|
|
if [ "$PUSHED" = true ]; then
|
|
CHANGE_SUMMARY=$(echo "$REVIEW_OUTPUT" | grep '^\s*-' | tail -20)
|
|
[ -z "$CHANGE_SUMMARY" ] && CHANGE_SUMMARY="Changes pushed (see diff for details)."
|
|
|
|
DEV_COMMENT="## 🔧 Dev-agent response (round ${REVIEW_ROUND})
|
|
<!-- dev-response: $(git rev-parse HEAD) round:${REVIEW_ROUND} -->
|
|
|
|
### Changes made:
|
|
${CHANGE_SUMMARY}
|
|
|
|
---
|
|
*Addressed at \`$(git rev-parse HEAD | head -c 7)\` · automated by dev-agent*"
|
|
|
|
curl -sf -o /dev/null -X POST \
|
|
-H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
-H "Content-Type: application/json" \
|
|
"${API}/issues/${PR_NUMBER}/comments" \
|
|
-d "$(jq -n --arg body "$DEV_COMMENT" '{body: $body}')" 2>/dev/null || \
|
|
log "WARNING: failed to post dev-response comment"
|
|
fi
|
|
done
|
|
|
|
if [ "$REVIEW_ROUND" -ge "$MAX_REVIEW_ROUNDS" ]; then
|
|
log "hit max review rounds (${MAX_REVIEW_ROUNDS})"
|
|
notify "PR #${PR_NUMBER}: hit ${MAX_REVIEW_ROUNDS} review rounds, needs human attention"
|
|
fi
|
|
|
|
cleanup_labels
|
|
# Keep worktree if PR is still open (recovery can reuse session context)
|
|
if [ -n "${PR_NUMBER:-}" ]; then
|
|
PR_STATE=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \
|
|
"${API}/pulls/${PR_NUMBER}" | jq -r '.state // "unknown"') || true
|
|
if [ "$PR_STATE" = "open" ]; then
|
|
log "keeping worktree (PR #${PR_NUMBER} still open, session preserved for recovery)"
|
|
else
|
|
cleanup_worktree
|
|
fi
|
|
else
|
|
cleanup_worktree
|
|
fi
|
|
log "dev-agent finished for issue #${ISSUE}"
|