fix: fix: guard blocks merge injection — Claude closes issue without merging (#344)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
openhands 2026-03-20 07:37:32 +00:00
parent b78b22d830
commit 6f30614dda
3 changed files with 76 additions and 27 deletions

View file

@ -150,6 +150,52 @@ handle_ci_exhaustion() {
return 0
}
# =============================================================================
# HELPER: merge an approved PR directly (no Claude needed)
#
# Merging an approved, CI-green PR is a single API call. Spawning dev-agent
# for this fails when the issue is already closed (Codeberg auto-closes issues
# on PR creation when body contains "Fixes #N"), causing a respawn loop (#344).
# =============================================================================
try_direct_merge() {
local pr_num="$1" issue_num="$2"
log "PR #${pr_num} (issue #${issue_num}) approved + CI green → attempting direct merge"
local merge_resp merge_http
merge_resp=$(curl -sf -w '\n%{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}' 2>/dev/null) || true
merge_http=$(echo "$merge_resp" | tail -1)
if [ "${merge_http:-0}" = "200" ] || [ "${merge_http:-0}" = "204" ]; then
log "PR #${pr_num} merged successfully"
# Close the issue (may already be closed by Codeberg auto-close)
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
# Remove in-progress label
curl -sf -X DELETE \
-H "Authorization: token ${CODEBERG_TOKEN}" \
"${API}/issues/${issue_num}/labels/in-progress" >/dev/null 2>&1 || true
# Clean up CI fix tracker
ci_fix_reset "$pr_num"
# Clean up phase/session artifacts
rm -f "/tmp/dev-session-${PROJECT_NAME}-${issue_num}.phase" \
"/tmp/dev-impl-summary-${PROJECT_NAME}-${issue_num}.txt"
matrix_send "dev" "✅ PR #${pr_num} (issue #${issue_num}) merged directly by dev-poll" 2>/dev/null || true
return 0
fi
log "PR #${pr_num} direct merge failed (HTTP ${merge_http:-?}) — falling back to dev-agent"
return 1
}
# shellcheck disable=SC2034
REPO="${CODEBERG_REPO}"
@ -284,7 +330,11 @@ if [ "$ORPHAN_COUNT" -gt 0 ]; then
jq -r '[.[] | select(.state == "REQUEST_CHANGES")] | length') || true
if ci_passed "$CI_STATE" && [ "${HAS_APPROVE:-0}" -gt 0 ]; then
log "PR #${HAS_PR} approved + CI green → spawning dev-agent to merge"
if try_direct_merge "$HAS_PR" "$ISSUE_NUM"; then
exit 0
fi
# Direct merge failed (conflicts?) — fall back to dev-agent
log "falling back to dev-agent for PR #${HAS_PR} merge"
nohup "${SCRIPT_DIR}/dev-agent.sh" "$ISSUE_NUM" >> "$LOGFILE" 2>&1 &
log "started dev-agent PID $! for issue #${ISSUE_NUM} (agent-merge)"
exit 0
@ -363,9 +413,13 @@ for i in $(seq 0 $(($(echo "$OPEN_PRS" | jq 'length') - 1))); do
HAS_APPROVE=$(echo "$REVIEWS_JSON" | \
jq -r '[.[] | select(.state == "APPROVED") | select(.stale == false)] | length') || true
# Spawn agent to merge if approved + CI green
# Merge directly if approved + CI green (no Claude needed — single API call)
if ci_passed "$CI_STATE" && [ "${HAS_APPROVE:-0}" -gt 0 ]; then
log "PR #${PR_NUM} (issue #${STUCK_ISSUE}) approved + CI green → spawning dev-agent to merge"
if try_direct_merge "$PR_NUM" "$STUCK_ISSUE"; then
exit 0
fi
# Direct merge failed (conflicts?) — fall back to dev-agent
log "falling back to dev-agent for PR #${PR_NUM} merge"
nohup "${SCRIPT_DIR}/dev-agent.sh" "$STUCK_ISSUE" >> "$LOGFILE" 2>&1 &
log "started dev-agent PID $! for stuck PR #${PR_NUM} (agent-merge)"
exit 0
@ -453,7 +507,11 @@ for i in $(seq 0 $((BACKLOG_COUNT - 1))); do
jq -r '[.[] | select(.state == "REQUEST_CHANGES")] | length') || true
if ci_passed "$CI_STATE" && [ "${HAS_APPROVE:-0}" -gt 0 ]; then
log "#${ISSUE_NUM} PR #${EXISTING_PR} approved + CI green → spawning dev-agent to merge"
if try_direct_merge "$EXISTING_PR" "$ISSUE_NUM"; then
exit 0
fi
# Direct merge failed (conflicts?) — fall back to dev-agent
log "falling back to dev-agent for PR #${EXISTING_PR} 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

View file

@ -392,33 +392,15 @@ Instructions:
printf 'PHASE:done\n' > "$PHASE_FILE"
elif [ "$_merge_rc" -ne 2 ]; then
# Other merge failure (conflict, etc.) — delegate to Claude for rebase + retry
agent_inject_into_session "$SESSION_NAME" "Approved! PR #${PR_NUMBER} has been approved.
agent_inject_into_session "$SESSION_NAME" "Approved! PR #${PR_NUMBER} has been approved, but the merge failed (likely conflicts).
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:
Rebase onto ${PRIMARY_BRANCH} and push:
git fetch origin ${PRIMARY_BRANCH} && git rebase origin/${PRIMARY_BRANCH}
git push --force-with-lease origin ${BRANCH}
# Then retry the merge curl above.
echo \"PHASE:awaiting_ci\" > \"${PHASE_FILE}\"
After a successful merge write PHASE:done:
echo \"PHASE:done\" > \"${PHASE_FILE}\"
If merge repeatedly fails, write PHASE:needs_human with a reason."
Do NOT merge or close the issue — the orchestrator handles that after CI passes.
If rebase repeatedly fails, write PHASE:needs_human with a reason."
fi
# _merge_rc=2: PHASE:needs_human already written by do_merge()
break

View file

@ -6,6 +6,7 @@
# - git push --force / -f to primary branch
# - rm -rf targeting paths outside the worktree
# - Direct Codeberg API merge calls (should go through phase protocol)
# - Direct issue close calls (should go through phase protocol)
# - git checkout / git switch to primary branch (stay on feature branch)
#
# Usage (in .claude/settings.json):
@ -68,6 +69,14 @@ if printf '%s' "$command_str" | grep -qE '/pulls/[0-9]+/merge'; then
exit 2
fi
# --- Guard 5: Direct issue close calls ---
# Defense in depth: Claude should never close issues directly — the orchestrator
# closes issues after a successful merge via the phase protocol.
if printf '%s' "$command_str" | grep -qE '/issues/[0-9]+.*state.*closed'; then
printf 'BLOCKED: Closing issues must go through the phase protocol. Write PHASE:done — the orchestrator closes issues after merge.\n'
exit 2
fi
# --- Guard 4: checkout/switch to primary branch ---
# Blocks: git checkout main, git switch main, git switch --detach main, etc.
# Allows: git checkout -b branch main, git checkout -- file