diff --git a/.env.example b/.env.example index 8f5f149..6347e96 100644 --- a/.env.example +++ b/.env.example @@ -9,10 +9,14 @@ PRIMARY_BRANCH=main # main or master # PROJECT_NAME=yourproject # optional — auto-derived from CODEBERG_REPO # ── Auth tokens ─────────────────────────────────────────────────────────── -# Codeberg API token (read from ~/.netrc by default, override here if needed) -# CODEBERG_TOKEN= +# Dev-agent token: push branches, create PRs, merge PRs. +# Use the dedicated bot account (e.g. factory_bot / disinto_dev). +# Branch protection: this account must be in the merge whitelist. +CODEBERG_TOKEN= -# Codeberg review bot token (separate account for formal reviews) +# Review-agent token: post review comments and submit formal approvals. +# Use the human/admin account (e.g. johba). +# Branch protection: this account must be in the approvals whitelist. REVIEW_BOT_TOKEN= # ── Woodpecker CI ───────────────────────────────────────────────────────── diff --git a/AGENTS.md b/AGENTS.md index 939109c..f985882 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -76,7 +76,7 @@ backlog issues (all deps closed) or orphaned in-progress issues and spawns - `dev/phase-test.sh` — Integration test for the phase protocol **Environment variables consumed** (via `lib/env.sh` + project TOML): -- `CODEBERG_TOKEN` — API auth for issue/PR operations +- `CODEBERG_TOKEN` — Dev-agent token (push, PR creation, merge) — use the dedicated bot account - `CODEBERG_REPO`, `CODEBERG_API` — Target repository - `PROJECT_NAME`, `PROJECT_REPO_ROOT` — Local checkout path - `PRIMARY_BRANCH` — Branch to merge into (e.g. `main`, `master`) @@ -101,7 +101,8 @@ spawns `review-pr.sh `. - `review/review-pr.sh` — Creates/reuses a tmux session (`review-{project}-{pr}`), injects PR diff, waits for Claude to write structured JSON output, posts markdown review + formal Codeberg review, auto-creates follow-up issues for pre-existing tech debt **Environment variables consumed**: -- `CODEBERG_TOKEN`, `REVIEW_BOT_TOKEN` — Separate tokens for posting reviews (branch protection requires a different user) +- `CODEBERG_TOKEN` — Dev-agent token (must not be the same account as REVIEW_BOT_TOKEN) +- `REVIEW_BOT_TOKEN` — Review-agent token for approvals (use human/admin account; branch protection: in approvals whitelist) - `CODEBERG_REPO`, `CODEBERG_API`, `PROJECT_NAME`, `PROJECT_REPO_ROOT` - `PRIMARY_BRANCH`, `WOODPECKER_REPO_ID` - `MATRIX_TOKEN`, `MATRIX_ROOM_ID`, `MATRIX_HOMESERVER` diff --git a/dev/dev-agent.sh b/dev/dev-agent.sh index ae24e06..23c2489 100755 --- a/dev/dev-agent.sh +++ b/dev/dev-agent.sh @@ -523,10 +523,27 @@ echo \"PHASE:awaiting_ci\" > \"${PHASE_FILE}\" (CI runs again after each push — always write awaiting_ci, not awaiting_review) **When you receive an \"Approved\" injection:** +The injection includes exact API commands. Merge the PR and close the issue directly: \`\`\`bash +# Merge (replace NNN with the actual PR number from the injection): +curl -sf -X POST \\ + -H \"Authorization: token \${CODEBERG_TOKEN}\" \\ + -H 'Content-Type: application/json' \\ + \"${API}/pulls/NNN/merge\" \\ + -d '{\"Do\":\"merge\",\"delete_branch_after_merge\":true}' + +# Close the issue: +curl -sf -X PATCH \\ + -H \"Authorization: token \${CODEBERG_TOKEN}\" \\ + -H 'Content-Type: application/json' \\ + \"${API}/issues/${ISSUE}\" \\ + -d '{\"state\":\"closed\"}' + +# Signal done: echo \"PHASE:done\" > \"${PHASE_FILE}\" \`\`\` -The orchestrator handles the merge. You are done. +If merge fails due to conflicts, rebase first then retry the merge. +If merge repeatedly fails, write PHASE:needs_human. **If refusing (too large, unmet dep, already done):** \`\`\`bash diff --git a/dev/dev-poll.sh b/dev/dev-poll.sh index 99520fc..991c5a0 100755 --- a/dev/dev-poll.sh +++ b/dev/dev-poll.sh @@ -163,54 +163,6 @@ log() { printf '[%s] poll: %s\n' "$(date -u '+%Y-%m-%d %H:%M:%S UTC')" "$*" >> "$LOGFILE" } -# HELPER: try merge, rebase if mergeable=false, then retry once -try_merge_or_rebase() { - local pr_num="$1" issue_num="$2" branch="$3" - local merge_code - - merge_code=$(curl -s -o /dev/null -w "%{http_code}" -X POST \ - -H "Authorization: token ${CODEBERG_TOKEN}" \ - -H "Content-Type: application/json" \ - "${API}/pulls/${pr_num}/merge" \ - -d '{"Do":"merge","delete_branch_after_merge":true}') - - if [ "$merge_code" = "200" ] || [ "$merge_code" = "204" ]; then - log "PR #${pr_num} merged! Closing #${issue_num}" - curl -sf -X PATCH -H "Authorization: token ${CODEBERG_TOKEN}" \ - -H "Content-Type: application/json" \ - "${API}/issues/${issue_num}" -d '{"state":"closed"}' >/dev/null 2>&1 || true - curl -sf -X DELETE -H "Authorization: token ${CODEBERG_TOKEN}" \ - "${API}/issues/${issue_num}/labels/in-progress" >/dev/null 2>&1 || true - matrix_send "dev" "✅ PR #${pr_num} merged! Issue #${issue_num} done." 2>/dev/null || true - ci_fix_reset "$pr_num" - return 0 - fi - - # Merge failed — always try rebase (Codeberg mergeable field is unreliable) - log "PR #${pr_num} merge failed (HTTP ${merge_code}) — attempting rebase" - matrix_send "dev" "🔀 PR #${pr_num} merge failed (${merge_code}) — auto-rebasing" 2>/dev/null || true - local worktree="/tmp/rebase-pr-${pr_num}" - rm -rf "$worktree" - git -C "${PROJECT_REPO_ROOT}" fetch origin 2>/dev/null || true - if git -C "${PROJECT_REPO_ROOT}" worktree add "$worktree" "$branch" 2>/dev/null && - git -C "$worktree" rebase "origin/${PRIMARY_BRANCH}" 2>/dev/null && - git -C "$worktree" push --force-with-lease origin "$branch" 2>/dev/null; then - log "PR #${pr_num} rebased — CI will re-run, merge on next poll" - git -C "${PROJECT_REPO_ROOT}" worktree remove "$worktree" 2>/dev/null || true - else - git -C "${PROJECT_REPO_ROOT}" worktree remove --force "$worktree" 2>/dev/null || true - if handle_ci_exhaustion "$pr_num" "$issue_num"; then - log "PR #${pr_num} rebase failed — CI exhausted, not spawning" - return 1 - fi - log "PR #${pr_num} rebase failed — spawning dev-agent to fix (attempt ${CI_FIX_ATTEMPTS}/3)" - matrix_send "dev" "❌ PR #${pr_num} rebase failed — spawning dev-agent (attempt ${CI_FIX_ATTEMPTS}/3)" 2>/dev/null || true - nohup "${SCRIPT_DIR}/dev-agent.sh" "$issue_num" >> "$LOGFILE" 2>&1 & - log "started dev-agent PID $! for PR #${pr_num} (rebase fix)" - fi - return 1 -} - # --- Check if dev-agent already running --- if [ -f "$LOCKFILE" ]; then LOCK_PID=$(cat "$LOCKFILE" 2>/dev/null || echo "") @@ -326,10 +278,9 @@ if [ "$ORPHAN_COUNT" -gt 0 ]; then jq -r '[.[] | select(.state == "REQUEST_CHANGES") | select(.stale == false)] | length') || true if ci_passed "$CI_STATE" && [ "${HAS_APPROVE:-0}" -gt 0 ]; then - PR_BRANCH=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ - "${API}/pulls/${HAS_PR}" | jq -r '.head.ref') || true - log "PR #${HAS_PR} approved + CI green → merging" - try_merge_or_rebase "$HAS_PR" "$ISSUE_NUM" "$PR_BRANCH" + log "PR #${HAS_PR} approved + CI green → spawning dev-agent to merge" + nohup "${SCRIPT_DIR}/dev-agent.sh" "$ISSUE_NUM" >> "$LOGFILE" 2>&1 & + log "started dev-agent PID $! for issue #${ISSUE_NUM} (agent-merge)" exit 0 elif ci_passed "$CI_STATE" && [ "${HAS_CHANGES:-0}" -gt 0 ]; then @@ -396,11 +347,12 @@ for i in $(seq 0 $(($(echo "$OPEN_PRS" | jq 'length') - 1))); do "${API}/pulls/${PR_NUM}/reviews" | \ jq -r '[.[] | select(.state == "APPROVED") | select(.stale == false)] | length') || true - # Try merge if approved + CI green + # Spawn agent to merge if approved + CI green if ci_passed "$CI_STATE" && [ "${HAS_APPROVE:-0}" -gt 0 ]; then - log "PR #${PR_NUM} (issue #${STUCK_ISSUE}) approved + CI green → merging" - try_merge_or_rebase "$PR_NUM" "$STUCK_ISSUE" "$PR_BRANCH" - continue + log "PR #${PR_NUM} (issue #${STUCK_ISSUE}) approved + CI green → spawning dev-agent to merge" + nohup "${SCRIPT_DIR}/dev-agent.sh" "$STUCK_ISSUE" >> "$LOGFILE" 2>&1 & + log "started dev-agent PID $! for stuck PR #${PR_NUM} (agent-merge)" + exit 0 fi # Stuck: REQUEST_CHANGES or CI failure → spawn agent @@ -473,11 +425,10 @@ for i in $(seq 0 $((BACKLOG_COUNT - 1))); do jq -r '[.[] | select(.state == "REQUEST_CHANGES") | select(.stale == false)] | length') || true if ci_passed "$CI_STATE" && [ "${HAS_APPROVE:-0}" -gt 0 ]; then - EXISTING_BRANCH=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ - "${API}/pulls/${EXISTING_PR}" | jq -r '.head.ref') || true - log "#${ISSUE_NUM} PR #${EXISTING_PR} approved + CI green → merging" - try_merge_or_rebase "$EXISTING_PR" "$ISSUE_NUM" "$EXISTING_BRANCH" - continue + log "#${ISSUE_NUM} PR #${EXISTING_PR} approved + CI green → spawning dev-agent to merge" + nohup "${SCRIPT_DIR}/dev-agent.sh" "$ISSUE_NUM" >> "$LOGFILE" 2>&1 & + log "started dev-agent PID $! for issue #${ISSUE_NUM} (agent-merge)" + exit 0 elif [ "${HAS_CHANGES:-0}" -gt 0 ]; then log "#${ISSUE_NUM} PR #${EXISTING_PR} has REQUEST_CHANGES — picking up" diff --git a/dev/phase-handler.sh b/dev/phase-handler.sh index 919fec3..b1480d8 100644 --- a/dev/phase-handler.sh +++ b/dev/phase-handler.sh @@ -2,7 +2,7 @@ # dev/phase-handler.sh — Phase callback functions for dev-agent.sh # # Source this file from dev-agent.sh after lib/agent-session.sh is loaded. -# Defines: post_refusal_comment(), do_merge(), _on_phase_change() +# Defines: post_refusal_comment(), _on_phase_change() # # Required globals from dev-agent.sh: # ISSUE, CODEBERG_TOKEN, API, CODEBERG_WEB, PROJECT_NAME, FACTORY_ROOT @@ -10,7 +10,7 @@ # PRIMARY_BRANCH, SESSION_NAME, LOGFILE, ISSUE_TITLE # CI_POLL_TIMEOUT, MAX_CI_FIXES, MAX_REVIEW_ROUNDS, REVIEW_POLL_TIMEOUT # CI_RETRY_COUNT, CI_FIX_COUNT, REVIEW_ROUND, CLAIMED -# WOODPECKER_REPO_ID, WOODPECKER_TOKEN, WOODPECKER_SERVER, REVIEW_BOT_TOKEN +# WOODPECKER_REPO_ID, WOODPECKER_TOKEN, WOODPECKER_SERVER # # Calls back to dev-agent.sh-defined helpers: # cleanup_worktree(), cleanup_labels() @@ -48,138 +48,6 @@ ${body} rm -f "/tmp/refusal-comment.txt" "/tmp/refusal-comment.json" } -# ============================================================================= -# MERGE HELPER -# ============================================================================= -do_merge() { - local sha="$1" - local pr="${PR_NUMBER}" - - 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 - if [ "$ci" = "failure" ] || [ "$ci" = "error" ]; then - log "CI is red before merge attempt — aborting" - notify "PR #${pr} CI is failing; cannot merge." - return 1 - fi - sleep 30 - done - - # Pre-emptive rebase to avoid merge conflicts - local mergeable - mergeable=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ - "${API}/pulls/${pr}" | jq -r '.mergeable // true') - if [ "$mergeable" = "false" ]; then - log "PR #${pr} 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 - sha=$(cd "$work_dir" && git rev-parse HEAD) - log "waiting for CI on rebased commit ${sha:0:7}" - local r_ci - for _r in $(seq 1 20); do - r_ci=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ - "${API}/commits/${sha}/status" | jq -r '.state // "unknown"') - [ "$r_ci" = "success" ] && break - if [ "$r_ci" = "failure" ] || [ "$r_ci" = "error" ]; then - log "CI failed after rebase" - notify "PR #${pr} CI failed after rebase. Needs manual fix." - return 1 - fi - sleep 30 - done - else - log "rebase failed — aborting and escalating" - (cd "$work_dir" && git rebase --abort 2>/dev/null) || true - notify "PR #${pr} has merge conflicts that need manual resolution." - return 1 - 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}/merge" \ - -d '{"Do":"merge","delete_branch_after_merge":true}') - - if [ "$http_code" = "200" ] || [ "$http_code" = "204" ]; then - log "PR #${pr} 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_ctx \ - "✅ PR #${pr} merged! Issue #${ISSUE} done." \ - "✅ PR #${pr} merged! Issue #${ISSUE} done." - agent_kill_session "$SESSION_NAME" - cleanup_worktree - rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$THREAD_FILE" - 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}" - local r2_ci - for _r2 in $(seq 1 20); do - r2_ci=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ - "${API}/commits/${sha}/status" | jq -r '.state // "unknown"') - [ "$r2_ci" = "success" ] && break - if [ "$r2_ci" = "failure" ] || [ "$r2_ci" = "error" ]; then - log "CI failed after merge-retry rebase" - notify "PR #${pr} CI failed after rebase. Needs manual fix." - return 1 - fi - sleep 30 - done - # Re-approve (force push dismisses stale approvals) - curl -sf -X POST \ - -H "Authorization: token ${REVIEW_BOT_TOKEN:-${CODEBERG_TOKEN}}" \ - -H "Content-Type: application/json" \ - "${API}/pulls/${pr}/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}/merge" \ - -d '{"Do":"merge","delete_branch_after_merge":true}') - if [ "$http_code" = "200" ] || [ "$http_code" = "204" ]; then - log "PR #${pr} merged after rebase!" - notify_ctx \ - "✅ PR #${pr} merged! Issue #${ISSUE} done." \ - "✅ PR #${pr} 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 - agent_kill_session "$SESSION_NAME" - cleanup_worktree - rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$THREAD_FILE" - 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} merge failed after rebase (HTTP ${http_code}). Needs human attention." - return 1 - fi -} - # ============================================================================= # PHASE DISPATCH CALLBACK # ============================================================================= @@ -473,9 +341,33 @@ Instructions: if [ "$VERDICT" = "APPROVE" ]; then REVIEW_FOUND=true - agent_inject_into_session "$SESSION_NAME" "Approved! PR #${PR_NUMBER} has been approved by the reviewer. -Write PHASE:done to the phase file — the orchestrator will handle the merge: - echo \"PHASE:done\" > \"${PHASE_FILE}\"" + agent_inject_into_session "$SESSION_NAME" "Approved! PR #${PR_NUMBER} has been approved. + +Merge the PR and close the issue directly — do NOT wait for the orchestrator: + + # Merge the PR: + curl -sf -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}' + + # Close the issue: + curl -sf -X PATCH \\ + -H \"Authorization: token \${CODEBERG_TOKEN}\" \\ + -H 'Content-Type: application/json' \\ + \"${API}/issues/${ISSUE}\" \\ + -d '{\"state\":\"closed\"}' + +If merge fails due to conflicts, rebase first: + git fetch origin ${PRIMARY_BRANCH} && git rebase origin/${PRIMARY_BRANCH} + git push --force-with-lease origin ${BRANCH} + # Then retry the merge curl above. + +After a successful merge write PHASE:done: + echo \"PHASE:done\" > \"${PHASE_FILE}\" + +If merge repeatedly fails, write PHASE:needs_human with a reason." break elif [ "$VERDICT" = "REQUEST_CHANGES" ] || [ "$VERDICT" = "DISCUSS" ]; then @@ -557,26 +449,23 @@ Instructions: # Don't inject anything — supervisor-poll.sh (#81) injects human replies, gardener-poll.sh as backup # ── PHASE: done ───────────────────────────────────────────────────────────── + # The agent already merged the PR and closed the issue. Just clean up local state. elif [ "$phase" = "PHASE:done" ]; then - status "phase done — merging PR #${PR_NUMBER:-?}" + status "phase done — agent merged PR #${PR_NUMBER:-?}, cleaning up" - if [ -z "${PR_NUMBER:-}" ]; then - log "ERROR: PHASE:done but no PR_NUMBER — cannot merge" - notify "PHASE:done but no PR known — needs human attention" - agent_kill_session "$SESSION_NAME" - cleanup_labels - return 1 - fi + # Notify Matrix (agent already closed the issue and removed labels via API) + notify_ctx \ + "✅ PR #${PR_NUMBER:-?} merged! Issue #${ISSUE} done." \ + "✅ PR #${PR_NUMBER:-?} merged! Issue #${ISSUE} done." - MERGE_SHA=$(curl -sf -H "Authorization: token ${CODEBERG_TOKEN}" \ - "${API}/pulls/${PR_NUMBER}" | jq -r '.head.sha') || true + # Belt-and-suspenders: ensure in-progress label removed (idempotent) + cleanup_labels - # do_merge exits 0 on success; returns 1 on failure - do_merge "$MERGE_SHA" || true - - # If we reach here, merge failed (do_merge returned 1) - log "merge failed — injecting error into session" - agent_inject_into_session "$SESSION_NAME" "Merge failed for PR #${PR_NUMBER}. The orchestrator could not merge automatically. This may be due to merge conflicts or CI. Investigate the PR state and write PHASE:needs_human if human intervention is required." + # Local cleanup + agent_kill_session "$SESSION_NAME" + cleanup_worktree + rm -f "$PHASE_FILE" "$IMPL_SUMMARY_FILE" "$THREAD_FILE" + CLAIMED=false # Don't unclaim again in cleanup() # ── PHASE: failed ─────────────────────────────────────────────────────────── elif [ "$phase" = "PHASE:failed" ]; then