Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions .gaai/core/scripts/daemon-dispatch.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2549,6 +2549,39 @@ ${qa_snippet}"
fi
fi

# ── PR-state guard: never merge a CLOSED or MERGED PR (AC1-AC4) ──────────
# Guard 1/2 use --state all and may select a historical CLOSED or MERGED PR
# on a recreated story/<id> branch. Re-read the selected PR's state before
# proceeding to gh pr merge. Mirrors the pattern at L154-160 (reap_orphaned_worktrees).
if [[ "$_skip_pr_create" -eq 1 && -n "$pr_url" ]]; then
local _selected_pr_state
_selected_pr_state=$(gh pr view "$pr_url" --json state --jq .state 2>/dev/null || echo "OPEN")
case "$_selected_pr_state" in
OPEN)
: # nominal path — fall through to gh pr merge
;;
MERGED)
# AC3: PR already merged (including squash-merge, which Guard 1 misses)
echo "[INFO] ${story_id} handle_commit_phase: selected PR ($pr_url) is MERGED — reconciling to done without merge"
"$SCHEDULER" --set-phase-status "$story_id" done "$BACKLOG_FILE" 2>/dev/null || true
"$SCHEDULER" --set-status "$story_id" done "$BACKLOG_FILE" 2>/dev/null || true
_emit_commit_routing_record "$story_id" "$trace_id" "daemon-bash" "null" "0" "$pr_url" "false"
return 0
;;
CLOSED)
# AC2: CLOSED and not merged — clear guard, fall through to gh pr create fresh PR
echo "[INFO] ${story_id} handle_commit_phase: selected PR ($pr_url) is CLOSED (unmerged) — clearing guard, opening fresh PR"
pr_url=""
_skip_pr_create=0
;;
*)
# Unknown state (gh API evolution) — treat as OPEN; conservative, lets existing
# AUTO_MERGE_FAILED handling deal with it rather than silently dropping the merge.
echo "[WARN] ${story_id} handle_commit_phase: unknown PR state '${_selected_pr_state}' for $pr_url — treating as OPEN"
;;
esac
fi

# ── gh pr create with retry (AC3 + AC5-b/c/d fallback) ───────────────────
local pr_exit=1 pr_attempt=0 pr_max=3 pr_output
if [[ "$_skip_pr_create" -eq 0 ]]; then
Expand Down
267 changes: 267 additions & 0 deletions .gaai/core/scripts/tests/commit-pr-state.test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,267 @@
#!/usr/bin/env bash
# commit-pr-state.test.sh — AC5 regression for E222S02
#
# Asserts that handle_commit_phase guards the merge path by re-reading the
# selected PR's state (via `gh pr view --json state`) before calling
# `gh pr merge --auto`, when Guard 1 or 2 has selected a PR via --state all.
#
# T1: CLOSED PR selected by Guard 2 → gh pr create is called (fresh PR), gh pr
# merge is NOT called with the closed URL (AC2)
# T2: MERGED PR selected by Guard 2 → phase_status reconciled to done, gh pr
# merge NOT called (AC3)
# T3: OPEN PR selected by Guard 2 → gh pr merge IS called normally (AC1, regression)
#
# Run: bash .gaai/core/scripts/tests/commit-pr-state.test.sh
# Exit 0 = all pass.

set -uo pipefail
PASS_COUNT=0; FAIL_COUNT=0
pass() { echo " PASS: $1"; PASS_COUNT=$(( PASS_COUNT + 1 )); }
fail() { echo " FAIL: $1"; FAIL_COUNT=$(( FAIL_COUNT + 1 )); }

SCRIPTS="$(cd "$(dirname "$0")/.." && pwd)"
SCHEDULER="$SCRIPTS/backlog-scheduler.sh"
DISPATCH="$SCRIPTS/daemon-dispatch.sh"

SANDBOX="$(mktemp -d /tmp/gaai-pr-state-test-XXXXXX)"
cleanup() { rm -rf "$SANDBOX"; }
trap cleanup EXIT

# ── Shared git fixture ────────────────────────────────────────────────────────
REMOTE="$SANDBOX/remote.git"
PROJ="$SANDBOX/project"
WT_BASE="$SANDBOX/worktrees"
LOCK_DIR="$SANDBOX/locks"
STUB_BIN="$SANDBOX/bin"
GH_CALL_LOG="$SANDBOX/gh-calls.log"
BACKLOG_REL=".gaai/project/contexts/backlog/active.backlog.yaml"
BACKLOG_FILE="$PROJ/$BACKLOG_REL"

mkdir -p "$WT_BASE" "$LOCK_DIR" "$STUB_BIN"
: > "$GH_CALL_LOG"

git init --quiet --bare "$REMOTE"
git init --quiet "$PROJ"
git -C "$PROJ" config user.email t@t.t
git -C "$PROJ" config user.name "GAAI Test"
git -C "$PROJ" checkout -q -b staging
echo seed > "$PROJ/seed.txt"
mkdir -p "$(dirname "$BACKLOG_FILE")"
git -C "$PROJ" add -A
git -C "$PROJ" commit -q -m "initial"
git -C "$PROJ" remote add origin "$REMOTE"
git -C "$PROJ" push -q origin staging

# Helper: create story branch with one commit diverged from staging and push it.
# Does NOT pre-create a worktree — handle_commit_phase self-heals if the worktree
# dir is absent, so no worktree add is needed here (and it avoids the "branch already
# checked out in main repo" error that git raises when the branch is still active).
setup_story() {
local sid="$1"
git -C "$PROJ" checkout -q staging
git -C "$PROJ" checkout -q -b "story/$sid"
echo "$sid" > "$PROJ/${sid}.txt"
git -C "$PROJ" add -A
git -C "$PROJ" commit -q -m "impl($sid): work"
git -C "$PROJ" push -q origin "story/$sid"
git -C "$PROJ" checkout -q staging
}

# Helper: write a minimal backlog entry (phase_status=qa_passed) and push to staging
write_backlog() {
local sid="$1"
cat > "$BACKLOG_FILE" <<YAML
items:
- id: ${sid}
status: in_progress
phase_status: qa_passed
delivery_pipeline: 3phase
YAML
git -C "$PROJ" add "$BACKLOG_REL"
git -C "$PROJ" commit -q -m "backlog(${sid}): test fixture"
git -C "$PROJ" push -q origin staging
git -C "$PROJ" fetch -q origin staging
}

# ── gh stub ──────────────────────────────────────────────────────────────────
# Uses env vars to control what each call returns; records every call to GH_CALL_LOG.
# GH_PR_STALE_URL — URL returned for `gh pr list` (Guard 2 hit)
# GH_PR_STATE — state returned for `gh pr view <url> --json state`
# GH_PR_NUMBER — number returned for `gh pr view <branch> --json number`
# GH_PR_FRESH_URL — URL returned by `gh pr create`
# GH_AUTOMERGE_RESP — response for `gh pr view --json autoMergeRequest`
cat > "$STUB_BIN/gh" <<'GHEOF'
#!/usr/bin/env bash
echo "gh $*" >> "$GH_CALL_LOG"
subcmd="${1:-}"; shift
case "$subcmd" in
pr)
action="${1:-}"; shift
case "$action" in
list)
echo "${GH_PR_STALE_URL:-}"
;;
view)
_target="${1:-}"; shift
_args="$*"
if [[ "$_args" == *"--json state"* ]]; then echo "${GH_PR_STATE:-OPEN}"
elif [[ "$_args" == *"--json number"* ]]; then echo "${GH_PR_NUMBER:-99}"
elif [[ "$_args" == *"--json autoMergeRequest"* ]]; then echo "${GH_AUTOMERGE_RESP:-null}"
elif [[ "$_args" == *"--json mergeable"* ]]; then echo '{"mergeable":"MERGEABLE","mergeStateStatus":"CLEAN"}'
elif [[ "$_args" == *"--json url"* ]]; then echo "${GH_PR_STALE_URL:-}"
else echo '{}'
fi
;;
create)
echo "${GH_PR_FRESH_URL:-https://github.com/test/repo/pull/100}"
exit 0
;;
merge)
exit 0
;;
esac
;;
esac
GHEOF
chmod +x "$STUB_BIN/gh"

# Stub node to suppress routing-record emission
cat > "$STUB_BIN/node" <<'NODEEOF'
#!/usr/bin/env bash
exit 0
NODEEOF
chmod +x "$STUB_BIN/node"

export PATH="$STUB_BIN:$PATH"
export GH_CALL_LOG

# ── Source daemon-dispatch.sh with minimal required env ──────────────────────
export PROJECT_DIR="$PROJ"
export BACKLOG_FILE
export GAAI_WORKTREES_BASE="$WT_BASE"
export TARGET_BRANCH="staging"
export SCHEDULER
export LOCK_DIR

source "$DISPATCH" 2>/dev/null || true

# Override heavy dependencies that are not relevant to the PR-state guard
log() { :; }
CYAN="" YELLOW="" NC="" RED="" GREEN="" BOLD=""
_ensure_worktree_deps_fresh() { return 0; }
_check_worktree_integrity() { return 0; }
_recover_worktree_safe_base() { return 1; }
chore_commit_field() { return 0; }
chore_commit_multi_field() { return 0; }
notify_escalation_inline() { return 0; }
_run_triage_for_story() { return 0; }
_emit_commit_routing_record() { return 0; }

echo ""
echo "=== commit-pr-state: AC5 — PR-state guard in handle_commit_phase ==="
echo ""

# ────────────────────────────────────────────────────────────────────────────
# T1: CLOSED PR selected → gh pr create called, gh pr merge NOT called
# ────────────────────────────────────────────────────────────────────────────
echo "--- T1: CLOSED PR ---"
SID1="TST-PCS01"
setup_story "$SID1"
write_backlog "$SID1"

STALE_T1="https://github.com/test/repo/pull/97"
export GH_PR_STALE_URL="$STALE_T1"
export GH_PR_STATE="CLOSED"
export GH_PR_FRESH_URL="https://github.com/test/repo/pull/100"
export GH_PR_NUMBER="100"
export GAAI_AUTO_MERGE_POLICY="off"
: > "$GH_CALL_LOG"

set +e
handle_commit_phase "$SID1" "trace-t1"
set -e

echo "T1a: gh pr create called (fresh PR opened after CLOSED guard)"
if grep -q "gh pr create" "$GH_CALL_LOG"; then
pass "T1a: gh pr create was called"
else
fail "T1a: gh pr create NOT called — CLOSED guard did not clear _skip_pr_create"
fi

echo "T1b: gh pr merge NOT called with the closed URL"
if grep "gh pr merge" "$GH_CALL_LOG" 2>/dev/null | grep -q "$STALE_T1"; then
fail "T1b: gh pr merge was called with closed URL ($STALE_T1)"
else
pass "T1b: gh pr merge NOT called with closed URL"
fi

# ────────────────────────────────────────────────────────────────────────────
# T2: MERGED PR selected → reconcile to done, gh pr merge NOT called
# ────────────────────────────────────────────────────────────────────────────
echo "--- T2: MERGED PR ---"
SID2="TST-PCS02"
setup_story "$SID2"
write_backlog "$SID2"

STALE_T2="https://github.com/test/repo/pull/98"
export GH_PR_STALE_URL="$STALE_T2"
export GH_PR_STATE="MERGED"
export GAAI_AUTO_MERGE_POLICY="off"
: > "$GH_CALL_LOG"

set +e
handle_commit_phase "$SID2" "trace-t2"
set -e

echo "T2a: gh pr merge NOT called for MERGED PR"
if grep -q "gh pr merge" "$GH_CALL_LOG" 2>/dev/null; then
fail "T2a: gh pr merge was called even though selected PR is MERGED"
else
pass "T2a: gh pr merge NOT called"
fi

echo "T2b: phase_status=done after MERGED reconcile"
T2_PHASE=$(grep -A 10 "id: ${SID2}" "$BACKLOG_FILE" | grep "phase_status:" | head -1 | awk '{print $2}')
if [[ "$T2_PHASE" == "done" ]]; then
pass "T2b: phase_status=done (MERGED early-exit reconciled correctly)"
else
fail "T2b: phase_status='${T2_PHASE}' (expected done)"
fi

# ────────────────────────────────────────────────────────────────────────────
# T3: OPEN PR selected → gh pr merge IS called (regression — happy path unchanged)
# ────────────────────────────────────────────────────────────────────────────
echo "--- T3: OPEN PR ---"
SID3="TST-PCS03"
setup_story "$SID3"
write_backlog "$SID3"

OPEN_URL_T3="https://github.com/test/repo/pull/99"
export GH_PR_STALE_URL="$OPEN_URL_T3"
export GH_PR_STATE="OPEN"
export GH_PR_NUMBER="99"
export GH_AUTOMERGE_RESP='{"mergeType":"SQUASH"}'
export GAAI_AUTO_MERGE_POLICY="on"
: > "$GH_CALL_LOG"

set +e
handle_commit_phase "$SID3" "trace-t3"
set -e

echo "T3a: gh pr merge called with the OPEN URL (happy path unchanged)"
if grep "gh pr merge" "$GH_CALL_LOG" 2>/dev/null | grep -q "$OPEN_URL_T3"; then
pass "T3a: gh pr merge called with OPEN URL"
else
fail "T3a: gh pr merge NOT called with OPEN URL — regression in happy path"
fi

# ── Summary ──────────────────────────────────────────────────────────────────
echo ""
echo "Results: $PASS_COUNT passed, $FAIL_COUNT failed"
if [[ "$FAIL_COUNT" -eq 0 ]]; then
echo "ALL PASS"
exit 0
else
echo "FAILURES: $FAIL_COUNT"
exit 1
fi
Loading