fix: feat: dev-agent merges its own PRs via non-admin Codeberg account (#172)

- phase-handler.sh: remove do_merge(); on APPROVAL inject exact API
  commands for agent to merge+close directly; PHASE:done now only
  does local cleanup (tmux, worktree, labels) — merge already done
- dev-agent.sh: update PHASE_PROTOCOL_INSTRUCTIONS — Approved means
  merge via API, close issue, then write PHASE:done
- dev-poll.sh: remove try_merge_or_rebase(); for approved+CI-green
  orphaned PRs, spawn dev-agent (recovery mode) to merge instead
- .env.example: document new token roles (CODEBERG_TOKEN = bot for
  push/PR/merge; REVIEW_BOT_TOKEN = human account for approvals)
- AGENTS.md: update token descriptions to match new roles

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
openhands 2026-03-18 17:59:36 +00:00
parent b38b2b13ae
commit f73d5f471e
5 changed files with 82 additions and 220 deletions

View file

@ -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 ─────────────────────────────────────────────────────────

View file

@ -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 <pr-number>`.
- `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`

View file

@ -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

View file

@ -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"

View file

@ -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 <a href='${CODEBERG_WEB}/pulls/${pr}'>#${pr}</a> merged! <a href='${CODEBERG_WEB}/issues/${ISSUE}'>Issue #${ISSUE}</a> 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 <a href='${CODEBERG_WEB}/pulls/${pr}'>#${pr}</a> merged! <a href='${CODEBERG_WEB}/issues/${ISSUE}'>Issue #${ISSUE}</a> 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 <a href='${CODEBERG_WEB}/pulls/${PR_NUMBER:-?}'>#${PR_NUMBER:-?}</a> merged! <a href='${CODEBERG_WEB}/issues/${ISSUE}'>Issue #${ISSUE}</a> 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