When PR has merge conflicts (mergeable=false), attempt git rebase before merge. If rebase fails, abort and escalate via notify. Flow: approval → check mergeable → rebase if needed → wait CI → merge Resolves the serial seed PR bottleneck where append-only files (manifest.jsonl) create trivial conflicts that block the pipeline.
1286 lines
48 KiB
Bash
Executable file
1286 lines
48 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 ~/scripts/harb-dev/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="${HARB_REPO_ROOT}"
|
|
|
|
API="${CODEBERG_API}"
|
|
LOCKFILE="/tmp/dev-agent.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/harb-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() {
|
|
openclaw system event --text "🔧 dev-agent #${ISSUE}: $*" --mode now 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
|
|
# Formats supported:
|
|
# - Depends on #315
|
|
# - depends on #315, #316
|
|
# - Blocked by #315
|
|
# - Requires #315
|
|
# - ## Dependencies\n- #315\n- #316
|
|
DEP_NUMBERS=$(echo "$ISSUE_BODY" | \
|
|
grep -ioP '(?:depends on|blocked by|requires|after)\s+#\K[0-9]+|(?:^|\n)\s*-\s*#\K[0-9]+' | \
|
|
sort -un || true)
|
|
|
|
# Also extract from a ## Dependencies section (lines starting with - #NNN or - Depends on #NNN)
|
|
DEP_SECTION=$(echo "$ISSUE_BODY" | sed -n '/^## Dependencies/,/^## /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 harb DeFi protocol.
|
|
|
|
## 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
|
|
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 master)
|
|
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" != "master" ]; then
|
|
log "WARNING: main repo on '$CURRENT_BRANCH' instead of master — switching"
|
|
git checkout master 2>/dev/null || true
|
|
fi
|
|
|
|
git fetch origin master 2>/dev/null
|
|
git pull --ff-only origin master 2>/dev/null || true
|
|
cleanup_worktree
|
|
git worktree add "$WORKTREE" origin/master -B "$BRANCH" 2>&1 || {
|
|
log "ERROR: worktree creation failed"
|
|
git worktree add "$WORKTREE" origin/master -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/master 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 harb DeFi protocol.
|
|
|
|
## 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/master..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" > "${TMPDIR}/refusal-comment.txt"
|
|
jq -Rs '{body: .}' < "${TMPDIR}/refusal-comment.txt" > "${TMPDIR}/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 @"${TMPDIR}/refusal-comment.json" 2>/dev/null || \
|
|
log "WARNING: failed to post refusal comment"
|
|
rm -f "${TMPDIR}/refusal-comment.txt" "${TMPDIR}/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}
|
|
|
|
This issue may be ready to close."
|
|
;;
|
|
*)
|
|
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 "master" \
|
|
'{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
|
|
|
|
# =============================================================================
|
|
# STATE.MD APPEND
|
|
# =============================================================================
|
|
# After each merge, append one line to STATE.md describing what now exists.
|
|
# Format: - [YYYY-MM-DD] what is now true about harb (#PR)
|
|
# The planner will collapse this into a compact snapshot later.
|
|
append_state_log() {
|
|
local state_file="${REPO_ROOT}/STATE.md"
|
|
local today
|
|
today=$(date -u +%Y-%m-%d)
|
|
|
|
# Derive a "what now exists" description from the issue title.
|
|
# Strip prefixes (feat:/fix:/refactor:) to get the essence.
|
|
local description
|
|
description=$(echo "$ISSUE_TITLE" | sed 's/^feat:\s*//i;s/^fix:\s*//i;s/^refactor:\s*//i')
|
|
|
|
local line="- [${today}] ${description} (#${PR_NUMBER})"
|
|
|
|
# Create file with header if it doesn't exist
|
|
if [ ! -f "$state_file" ]; then
|
|
echo "# STATE.md — What harb currently is and does" > "$state_file"
|
|
echo "" >> "$state_file"
|
|
fi
|
|
|
|
echo "$line" >> "$state_file"
|
|
|
|
# Append to STATE.md on the PR branch, push before merge
|
|
local worktree="${WORKTREE:-}"
|
|
local target="${worktree:-$REPO_ROOT}"
|
|
local state_file="${target}/STATE.md"
|
|
|
|
if [ ! -f "$state_file" ]; then
|
|
printf '# STATE.md — What harb currently is and does\n\n' > "$state_file"
|
|
fi
|
|
echo "$line" >> "$state_file"
|
|
|
|
cd "$target"
|
|
git add STATE.md 2>/dev/null || true
|
|
git diff --cached --quiet && return 0
|
|
git commit -m "state: ${description} (#${PR_NUMBER})" --no-verify 2>/dev/null || true
|
|
git push origin "${BRANCH}" 2>/dev/null || log "STATE.md push failed — will be missing from this merge"
|
|
log "STATE.md updated: ${line}"
|
|
}
|
|
|
|
# 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
|
|
|
|
# Check if PR is mergeable — rebase if not
|
|
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 master && git rebase origin/master 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})"
|
|
notify "PR #${PR_NUMBER} approved but merge failed (HTTP ${http_code}). Please merge manually."
|
|
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"')
|
|
|
|
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}" \
|
|
"http://localhost:8000/api/repos/2/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 factory-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}/factory/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
|
|
# NOTE: STATE.md append moved to AFTER merge.
|
|
# Pushing before merge creates a new commit that dismisses
|
|
# the stale approval (dismiss_stale_approvals=true), causing
|
|
# 405 "not enough approvals" on merge.
|
|
do_merge "$CURRENT_SHA"
|
|
# If merge succeeded, append_state_log was already called inside do_merge
|
|
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}"
|