Skip to content
Open
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
260 changes: 260 additions & 0 deletions .github/scripts/review-handoff-test.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,260 @@
#!/usr/bin/env bash
# review-handoff-test.sh — Tests for review-handoff.sh and the maintainer
# workflow patch that inlines the same skip.
#
# Run from the repo root:
# bash .github/scripts/review-handoff-test.sh

set -euo pipefail

SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "${SCRIPT_DIR}/../.." && pwd)"
# shellcheck source=review-handoff.sh
source "${SCRIPT_DIR}/review-handoff.sh"

TMPDIR_ROOT="$(mktemp -d)"
trap 'rm -rf "${TMPDIR_ROOT}"' EXIT

FAILURES=0

pass() { echo "PASS: $1"; }
fail() {
echo "FAIL: $1"
FAILURES=$((FAILURES + 1))
}

assert_skip() {
local name="$1" labels="$2"
if should_skip_automatic_review_handoff "${labels}"; then
pass "${name}"
else
fail "${name} — expected skip for labels '${labels}'"
fi
}

assert_dispatch_label() {
local name="$1" labels="$2"
if should_skip_automatic_review_handoff "${labels}"; then
fail "${name} — expected dispatch for labels '${labels}'"
else
pass "${name}"
fi
}

assert_route() {
local name="$1" action="$2" triggering_label="$3" labels="$4" expect="$5"
local rc=0
github_like_should_dispatch_review "${action}" "${triggering_label}" "${labels}" || rc=$?
if [[ "${expect}" == "dispatch" && "${rc}" -eq 0 ]]; then
pass "${name}"
elif [[ "${expect}" == "skip" && "${rc}" -ne 0 ]]; then
pass "${name}"
else
fail "${name} — action=${action} triggering_label='${triggering_label}' labels='${labels}' expect=${expect} rc=${rc}"
fi
}

echo "=== review-handoff helper ==="

assert_dispatch_label "empty labels dispatch" ""
assert_dispatch_label "ready-for-review only dispatches" "ready-for-review"
assert_dispatch_label "unrelated labels dispatch" "bug,ready-to-code"
assert_skip "provenance present skips" "ready-for-review,fullsend-auto-review-handoff"
assert_skip "provenance first skips" "fullsend-auto-review-handoff,ready-for-review"
assert_skip "provenance only skips" "fullsend-auto-review-handoff"
assert_dispatch_label "substring is not provenance" "not-fullsend-auto-review-handoff,ready-for-review"
assert_dispatch_label "prefix is not provenance" "fullsend-auto-review-handoff-extra,ready-for-review"

# Actor identity is not an input of the helper. A bot-looking CSV must
# not skip without the provenance label.
assert_dispatch_label "bot username in labels is not provenance" "fullsend-ai-coder[bot],ready-for-review"

echo "=== github-like routing scenarios ==="

HANDOFF="fullsend-auto-review-handoff,ready-for-review"
EXPLICIT="ready-for-review"

assert_route "opened dispatches" "opened" "" "${HANDOFF}" dispatch
assert_route "automatic labeled skips" "labeled" "ready-for-review" "${HANDOFF}" skip
assert_route "explicit labeled dispatches" "labeled" "ready-for-review" "${EXPLICIT}" dispatch
assert_route "synchronize after handoff dispatches" "synchronize" "" "" dispatch
assert_route "fix-agent push (synchronize) dispatches" "synchronize" "" "ready-for-review" dispatch
assert_route "slash-review same SHA dispatches" "slash-review" "" "${HANDOFF}" dispatch
assert_route "ready_for_review event dispatches" "ready_for_review" "" "" dispatch
assert_route "closed does not dispatch review" "closed" "" "" skip

# A labeled event whose triggering label is not ready-for-review must
# skip even when the label snapshot happens to include both
# ready-for-review and the handoff marker (regression test for the
# labeled arm dispatching on any triggering label).
assert_route "non-ready-for-review labeled event skips" "labeled" "ready-to-code" "${HANDOFF}" skip

count_pair() {
local first_action="$1" first_trigger="$2" first_labels="$3"
local second_action="$4" second_trigger="$5" second_labels="$6"
local n=0 rc
rc=0
github_like_should_dispatch_review "${first_action}" "${first_trigger}" "${first_labels}" || rc=$?
[[ "${rc}" -eq 0 ]] && n=$((n + 1))
rc=0
github_like_should_dispatch_review "${second_action}" "${second_trigger}" "${second_labels}" || rc=$?
[[ "${rc}" -eq 0 ]] && n=$((n + 1))
echo "${n}"
}

got=$(count_pair opened "" "${HANDOFF}" labeled "ready-for-review" "${HANDOFF}")
if [[ "${got}" == "1" ]]; then
pass "opened then automatic labeled: one review"
else
fail "opened then automatic labeled: got ${got}, want 1"
fi

got=$(count_pair labeled "ready-for-review" "${HANDOFF}" opened "" "${HANDOFF}")
if [[ "${got}" == "1" ]]; then
pass "automatic labeled then opened: one review"
else
fail "automatic labeled then opened: got ${got}, want 1"
fi

got=$(count_pair opened "" "" labeled "ready-for-review" "${EXPLICIT}")
if [[ "${got}" == "2" ]]; then
pass "opened then explicit labeled: two reviews"
else
fail "opened then explicit labeled: got ${got}, want 2"
fi

echo "=== concurrent-ish pair (background) ==="

concurrent_count() {
local a1="$1" t1="$2" l1="$3" a2="$4" t2="$5" l2="$6"
local tmp
tmp="$(mktemp -d "${TMPDIR_ROOT}/concurrent.XXXXXX")"
(
rc=0
github_like_should_dispatch_review "${a1}" "${t1}" "${l1}" || rc=$?
[[ "${rc}" -eq 0 ]] && echo 1 >"${tmp}/1"
) &
(
rc=0
github_like_should_dispatch_review "${a2}" "${t2}" "${l2}" || rc=$?
[[ "${rc}" -eq 0 ]] && echo 1 >"${tmp}/2"
) &
wait
local n=0
[[ -f "${tmp}/1" ]] && n=$((n + 1))
[[ -f "${tmp}/2" ]] && n=$((n + 1))
echo "${n}"
}

i=0
while [[ "${i}" -lt 16 ]]; do
if [[ $((i % 2)) -eq 0 ]]; then
got=$(concurrent_count opened "" "${HANDOFF}" labeled "ready-for-review" "${HANDOFF}")
else
got=$(concurrent_count labeled "ready-for-review" "${HANDOFF}" opened "" "${HANDOFF}")
fi
if [[ "${got}" != "1" ]]; then
fail "concurrent automatic pair iteration ${i}: got ${got}, want 1"
break
fi
i=$((i + 1))
done
if [[ "${i}" -eq 16 ]]; then
pass "concurrent automatic pair (16 iterations, both orders)"
fi

i=0
while [[ "${i}" -lt 16 ]]; do
got=$(concurrent_count opened "" "" labeled "ready-for-review" "${EXPLICIT}")
if [[ "${got}" != "2" ]]; then
fail "concurrent explicit pair iteration ${i}: got ${got}, want 2"
break
fi
i=$((i + 1))
done
if [[ "${i}" -eq 16 ]]; then
pass "concurrent explicit same-revision pair (16 iterations)"
fi

echo "=== maintainer patch ==="

PATCH="${REPO_ROOT}/docs/contributing/patches/7384-review-handoff-dedup.patch"
DISPATCH="${REPO_ROOT}/.github/workflows/reusable-dispatch.yml"
SCAFFOLD="${REPO_ROOT}/internal/scaffold/fullsend-repo/.github/workflows/dispatch.yml"

if [[ ! -f "${PATCH}" ]]; then
fail "patch file missing: ${PATCH}"
else
pass "patch file present"
fi

assert_patched_routing() {
local file="$1" name="$2"
local s
s="$(cat "${file}")"
if [[ "${s}" != *"fullsend-auto-review-handoff"* ]]; then
fail "${name} missing provenance skip"
return
fi
if [[ "${s}" != *'[[ "${PR_USER_LOGIN}" =~ \[bot\]$ ]] || is_event_actor_authorized "${PR_USER_LOGIN}" triage'* ]]; then
fail "${name} lost bot authorization exemption on opened|synchronize|ready_for_review"
return
fi
if ! grep -A6 'TRIGGERING_LABEL}" == "ready-for-review"' "${file}" | grep -q 'fullsend-auto-review-handoff'; then
fail "${name} ready-for-review labeled path missing provenance skip"
return
fi
if grep -A8 'pull_request_target' "${file}" | grep -A20 'labeled)' | grep -q '\[bot\]'; then
fail "${name} labeled review path must not use bot-name suppression"
return
fi
pass "${name} patched routing"
}

if grep -q 'fullsend-auto-review-handoff' "${DISPATCH}"; then
echo "Workflow files already contain provenance skip (patch applied)."
assert_patched_routing "${DISPATCH}" "reusable-dispatch.yml"
assert_patched_routing "${SCAFFOLD}" "scaffold dispatch.yml"
else
if git -C "${REPO_ROOT}" apply --check "${PATCH}"; then
pass "git apply --check"
else
fail "git apply --check failed"
fi

tmp="$(mktemp -d "${TMPDIR_ROOT}/patch.XXXXXX")"
mkdir -p "${tmp}/.github/workflows" \
"${tmp}/internal/scaffold/fullsend-repo/.github/workflows"
cp "${DISPATCH}" "${tmp}/.github/workflows/reusable-dispatch.yml"
cp "${SCAFFOLD}" "${tmp}/internal/scaffold/fullsend-repo/.github/workflows/dispatch.yml"
apply_rc=0
git -C "${tmp}" apply "${PATCH}" || apply_rc=$?
if [[ "${apply_rc}" -eq 0 ]]; then
pass "git apply on temp copies"
assert_patched_routing "${tmp}/.github/workflows/reusable-dispatch.yml" "patched reusable-dispatch.yml"
assert_patched_routing "${tmp}/internal/scaffold/fullsend-repo/.github/workflows/dispatch.yml" "patched scaffold dispatch.yml"
if grep -q 'has_label "fullsend-auto-review-handoff" "${PR_LABELS}"' \
"${tmp}/.github/workflows/reusable-dispatch.yml" \
&& grep -q 'has_label "fullsend-auto-review-handoff" "${ISSUE_LABELS}"' \
"${tmp}/.github/workflows/reusable-dispatch.yml"; then
pass "reusable-dispatch uses has_label on PR_LABELS and ISSUE_LABELS"
else
fail "reusable-dispatch patched skip missing has_label on both label CSVs"
fi
if grep -q 'has_label "fullsend-auto-review-handoff" "${PR_LABELS}"' \
"${tmp}/internal/scaffold/fullsend-repo/.github/workflows/dispatch.yml"; then
pass "scaffold dispatch uses has_label on PR_LABELS"
else
fail "scaffold dispatch patched skip missing has_label"
fi
else
fail "git apply on temp copies failed"
fi
fi

echo "=== results ==="
if [[ "${FAILURES}" -ne 0 ]]; then
echo "${FAILURES} failure(s)"
exit 1
fi
echo "All review-handoff tests passed"
67 changes: 67 additions & 0 deletions .github/scripts/review-handoff.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
# review-handoff.sh — Decide whether a ready-for-review labeled event is
# the automatic code-agent creation handoff (skip) or an explicit review
# request (dispatch).
#
# Provenance is the fullsend-auto-review-handoff label on the event
# snapshot, not the actor's username, a [bot] suffix, or a previous
# review of the same SHA. See docs/contributing/review-handoff-dedup.md.
#
# The GitHub Route job inlines equivalent logic via has_label after the
# maintainer patch is applied. That job sparse-checkouts only
# .fullsend/config.yaml, so it cannot source this file. Keep the
# inlined check and this helper in sync.
#
# This file is sourced only (by review-handoff-test.sh); it is not
# invoked directly, so it carries no shebang and is not executable.

# shellcheck shell=bash

AUTO_REVIEW_HANDOFF_LABEL="fullsend-auto-review-handoff"

# should_skip_automatic_review_handoff <csv-labels>
# Returns 0 if the labeled event should not dispatch review.
# Returns 1 if review should dispatch.
# Matches reusable-dispatch.yml has_label: exact token match, no trim.
should_skip_automatic_review_handoff() {
local csv="${1:-}"
local needle="${AUTO_REVIEW_HANDOFF_LABEL}"
local token
local -a tokens
IFS=',' read -ra tokens <<< "${csv}"
for token in "${tokens[@]}"; do
[[ "$token" == "$needle" ]] && return 0
done
return 1
}

# github_like_should_dispatch_review <action> <triggering-label> <csv-labels>
# Mirrors per-repo GitHub Route after the maintainer patch:
# opened|synchronize|ready_for_review dispatch; a labeled event only
# enters the ready-for-review decision when the triggering label is
# ready-for-review (production YAML gates on
# TRIGGERING_LABEL == "ready-for-review" before checking has_label), and
# dispatches unless the automatic-handoff provenance label is present;
# other labeled events skip; slash-review dispatches. Authorization is
# out of scope.
github_like_should_dispatch_review() {
local action="${1:-}"
local triggering_label="${2:-}"
local csv_labels="${3:-}"
case "${action}" in
opened|synchronize|ready_for_review|slash-review)
return 0
;;
labeled)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[low] logic-error

github_like_should_dispatch_review's labeled arm dispatches unless the automatic-handoff provenance label is present in the CSV, but never checks that the triggering label is actually ready-for-review (the production YAML in the maintainer patch only enters this skip/dispatch branch when TRIGGERING_LABEL == ready-for-review). The same gap exists in the Go test helper githubLikeReviewDecision's labeled case. This means the shell test helper (and its tests) would not catch a patched workflow that started reviewing on every labeled event, not just ready-for-review.

Suggested fix: Pass the triggering label into the helper and only dispatch on labeled when it equals ready-for-review and should_skip_automatic_review_handoff is false; keep other labeled events as skip.

if [[ "${triggering_label}" != "ready-for-review" ]]; then
return 1
fi
if should_skip_automatic_review_handoff "${csv_labels}"; then
return 1
fi
return 0
;;
*)
return 1
;;
esac
}
1 change: 1 addition & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,7 @@ script-test:
$(call run-timed,bash scripts/check-e2e-authorization-test.sh)
$(call run-timed,bash scripts/redact-behaviour-artifacts-test.sh)
$(call run-timed,bash .github/scripts/check-fix-eligibility-test.sh)
$(call run-timed,bash .github/scripts/review-handoff-test.sh)
$(call run-timed,bash scripts/check-agents-gate-pin-test.sh)
$(call run-timed,bash scripts/verify-release-tag-test.sh)
$(call run-timed,bash internal/scaffold/fullsend-repo/scripts/reconcile-repos-test.sh)
Expand Down
4 changes: 4 additions & 0 deletions docs/ADRs/0002-initial-fullsend-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ It **does not** read the **issue comment thread** for intake decisions—no scan
> label, `/fs-review` slash command) dispatch only when the issue has an
> associated pull request (`issue.pull_request` present). Per-org `dispatch.yml`
> is unchanged pending follow-up.
>
> **Note (2026-09):** Automatic code-agent `ready-for-review` on PR creation is a
> state handoff, not a second review trigger. See
> [Review handoff dedup](../contributing/review-handoff-dedup.md) (#7384).

**When a review run starts** (initial review, **`/review`**, or **push-triggered re-review**): **remove** **`ready-for-review`** **and** **`ready-for-merge`**. A new round **supersedes** any prior merge verdict until the coordinator finishes this round—otherwise **`ready-for-merge`** could describe an **old** head after the author **pushed** new commits, which is **unsafe** for bots and humans. Reviewers evaluate the **current** PR head; the coordinator applies outcomes using the algorithm below. (**`requires-manual-review`** is **not** removed here by default—humans may still need to resolve an earlier split verdict unless **repo policy** clears it when enqueueing a new round.)

Expand Down
2 changes: 1 addition & 1 deletion docs/agents/code.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ on issues (not PRs). The code agent is also triggered automatically when the
| Label | Meaning |
|-------|---------|
| `ready-to-code` | Triggers the code agent. Applied by the [triage](triage.md) post-script for low-risk categories (bug, documentation, performance), or manually by a human for feature work after prioritization. |
| `ready-for-review` | Applied by the code agent's post-script after pushing a PR. In per-repo installs, triggers review when applied to a PR; also marks workflow state for humans and the retro agent. |
| `ready-for-review` | Applied by the code agent's post-script after pushing a PR. Marks workflow state for humans and the retro agent. In per-repo installs, an explicit application still triggers review; the automatic code-agent application is paired with `fullsend-auto-review-handoff` so it does not start a second review of the same revision. See [Review handoff dedup](../contributing/review-handoff-dedup.md). |

## Configuration and extension

Expand Down
2 changes: 1 addition & 1 deletion docs/agents/review.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ These labels are applied by the review post-script based on the review outcome.

| Label | Meaning |
|-------|---------|
| `ready-for-review` | Workflow state marker on the PR. Applied by the [code agent](code.md) post-script after pushing. In per-repo installs, triggers review when applied to a PR. |
| `ready-for-review` | Workflow state marker on the PR. Applied by the [code agent](code.md) post-script after pushing. In per-repo installs, an **explicit** application (no `fullsend-auto-review-handoff` provenance on the event) triggers review. The automatic code-agent application is a state marker; initial review comes from the PR-opened path so the pair does not double-dispatch. See [Review handoff dedup](../contributing/review-handoff-dedup.md). |
| `ready-for-merge` | The review agent approved the PR. No blocking findings. |
| `requires-manual-review` | The review agent found issues that require human judgment — it could not confidently approve or reject. |
| `rejected` | The review agent rejected the PR and the post-script closed it. |
Expand Down
Loading
Loading