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
6 changes: 5 additions & 1 deletion scripts/dev-lead-fix-reviews.sh
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ source "$(dirname "$0")/engine.sh"
source "$(dirname "$0")/lib/git-identity.sh"
source "$(dirname "$0")/lib/pr-worktree.sh"
source "$(dirname "$0")/lib/auto-merge.sh"
source "$(dirname "$0")/lib/git-push-guard.sh"
source "$(dirname "$0")/lib/pr-automation-budget.sh"

INTENT_TYPE="${INTENT_TYPE:-fix-reviews}"
Expand Down Expand Up @@ -1013,7 +1014,10 @@ commit_and_push() {
# false "Changes committed and pushed" comment.
git commit -m "$commit_msg" || { echo "::error::git commit failed — check git identity configuration on the runner" >&2; exit 1; }
fi
git push || {
# No-clobber push (#1311): never discard a concurrent writer's unseen commit.
# push_no_clobber fast-forwards normally and only ever force-with-leases a
# rewritten branch, aborting if the remote moved beyond what we fetched.
push_no_clobber || {
echo "::error::git push failed — check remote access and branch permissions" >&2
exit 1
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Expand Down
57 changes: 57 additions & 0 deletions scripts/dev-lead-intent.sh
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,27 @@ is_fork_pr() {
[ -n "$head_repo" ] && [ "$head_repo" != "$base_repo" ]
}

# is_dev_lead_authored
# Returns 0 (true) iff the PR in the event payload was authored by the dev-lead
# identity: its head branch matches `dev-lead/issue-*` OR the PR/issue author
# login equals BOT_USER. dev-lead's fix/push/merge intents must only ever act on
# PRs it authored — a review or bot comment on a human's in-flight PR must be
# left to the human (issue #1311: dev-lead drove a human PR to merge and dropped
# an unseen commit). FAILS CLOSED: any indeterminate state (no payload, or no
# head ref AND no author) returns 1, so callers skip rather than seize a PR whose
# ownership cannot be established.
is_dev_lead_authored() {
[ -n "$EVENT_PATH" ] && [ -f "$EVENT_PATH" ] || return 1
local head_ref author
head_ref=$(jq -r '(.pull_request?.head?.ref // "" | tostring)' "$EVENT_PATH" 2>/dev/null || true)
author=$(jq -r '(.pull_request?.user?.login // .issue?.user?.login // "" | tostring)' "$EVENT_PATH" 2>/dev/null || true)
case "$head_ref" in
dev-lead/issue-*) return 0 ;;
esac
[ -n "$author" ] && [ "$author" = "$BOT_USER" ] && return 0
return 1
}

# ── read event ───────────────────────────────────────────────────────────────

EVENT_NAME="${GITHUB_EVENT_NAME:-}"
Expand Down Expand Up @@ -189,6 +210,12 @@ case "$EVENT_NAME" in
emit_skip "bot-pr"
exit 0
fi
# Authorship gate (#1311): only act on PRs dev-lead authored. A human's
# own PR is theirs to finish — dev-lead must not seize and drive it.
if ! is_dev_lead_authored; then
emit_skip "not-dev-lead-authored"
exit 0
fi
context=$(jq -nc \
--argjson pr_number "${pr_number:-0}" \
--arg head_sha "${head_sha:-}" \
Expand All @@ -202,6 +229,11 @@ case "$EVENT_NAME" in
emit_skip "bot-sync"
exit 0
fi
# Authorship gate (#1311): a human pushing to their own PR keeps it.
if ! is_dev_lead_authored; then
emit_skip "not-dev-lead-authored"
exit 0
fi
context=$(jq -nc \
--argjson pr_number "${pr_number:-0}" \
--arg head_sha "${head_sha:-}" \
Expand Down Expand Up @@ -242,6 +274,15 @@ case "$EVENT_NAME" in
exit 0
fi

# Authorship gate (#1311): a review — from a trusted bot OR a trusted human —
# only drives dev-lead's fix/push/merge path on a PR dev-lead authored. On a
# human's in-flight PR, dev-lead stays out (it may still advise, but never
# pushes or merges). Fails closed on indeterminate authorship.
if ! is_dev_lead_authored; then
emit_skip "not-dev-lead-authored"
exit 0
fi

context=$(jq -nc \
--argjson pr_number "${pr_number:-0}" \
--arg head_sha "${head_sha:-}" \
Expand Down Expand Up @@ -282,8 +323,16 @@ case "$EVENT_NAME" in
'{"pr_number":$pr_number,"head_sha":$head_sha,"actor":$actor,"body":$body}')

if is_trusted_bot "$commenter"; then
# Authorship gate (#1311): a bot review comment only drives dev-lead's
# auto-fix/push on a PR dev-lead authored. On a human's PR it is advisory.
if ! is_dev_lead_authored; then
emit_skip "not-dev-lead-authored"
exit 0
fi
emit_intent "fix-reviews" "bot-review-comment" "$context"
elif is_human_trusted "$author_assoc" && has_trigger_phrase "$comment_body"; then
# on-mention is an explicit human request (@dev-lead) — direct
# authorization — so it is exempt from the authorship gate.
emit_intent "on-mention" "human-review-comment-trigger" "$context"
else
emit_skip "no-trigger-or-untrusted"
Expand Down Expand Up @@ -337,6 +386,14 @@ case "$EVENT_NAME" in
'{"pr_number":$pr_number,"actor":$actor,"body":$body}')

if is_trusted_bot "$commenter"; then
# Authorship gate (#1311): a bot comment only drives dev-lead's auto-fix/
# push on a PR dev-lead authored (author == BOT_USER; issue_comment
# payloads carry no head ref). On a human's PR it is advisory. The rebase
# sentinel above and the on-mention branch below are intentionally exempt.
if ! is_dev_lead_authored; then
emit_skip "not-dev-lead-authored"
exit 0
fi
emit_intent "fix-bot-comment" "trusted-bot-comment" "$context"
elif is_human_trusted "$author_assoc" && has_trigger_phrase "$comment_body"; then
emit_intent "on-mention" "human-comment-trigger" "$context"
Expand Down
56 changes: 56 additions & 0 deletions scripts/lib/git-push-guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/usr/bin/env bash
# git-push-guard.sh — no-clobber push helper for dev-lead (#1311).
Comment thread
coderabbitai[bot] marked this conversation as resolved.
#
# dev-lead must never overwrite a commit it has not seen. A concurrent writer —
# a human pushing to the same branch, or the auto-rebase bot — can advance the
# remote head after dev-lead checked the branch out. push_no_clobber pushes so
# that such an unseen commit is never discarded:
#
# 1. Plain (fast-forward) push first — the common case. It can NEVER discard:
# a non-fast-forward remote is rejected, not overwritten.
# 2. Retry with --force-with-lease ONLY when the local branch has DIVERGED
# from its upstream (history was rewritten, e.g. a rebase). The lease is the
# remote-tracking ref captured at checkout, so the force ABORTS if the
# remote advanced beyond what we last fetched. We deliberately do NOT fetch
# here — a fetch would refresh the lease and defeat the guard — and never
# use a bare --force.
# 3. If the lease fails, the remote moved under us: refuse (return non-zero).
# Discarding an unseen commit is never acceptable.
#
# Usage: push_no_clobber [git push args...] e.g. push_no_clobber origin main

push_no_clobber() {
local errf
errf="$(mktemp)"

if git push "$@" 2>"$errf"; then
rm -f "$errf"
return 0
fi

local upstream
upstream=$(git rev-parse --abbrev-ref --symbolic-full-name '@{u}' 2>/dev/null || true)

# Retry with --force-with-lease ONLY on a non-fast-forward rejection where our
# branch has diverged from the upstream we last saw (rewritten history).
# --force-with-lease (no explicit value) leases against the remote-tracking
# ref, so it still ABORTS if the remote advanced beyond that ref.
if grep -qiE 'non-fast-forward|\[rejected\]|fetch first' "$errf" \
&& [ -n "$upstream" ] \
&& ! git merge-base --is-ancestor "$upstream" HEAD 2>/dev/null; then
echo "::notice::push rejected (non-fast-forward) and HEAD diverged from ${upstream} — history was rewritten (likely a rebase); retrying with --force-with-lease (aborts if the remote moved under us)" >&2
if git push --force-with-lease "$@" 2>>"$errf"; then
rm -f "$errf"
return 0
fi
cat "$errf" >&2
rm -f "$errf"
echo "::error::--force-with-lease refused: the remote branch advanced beyond the commit dev-lead checked out. Refusing to discard an unseen commit." >&2
return 1
fi

cat "$errf" >&2
rm -f "$errf"
echo "::error::git push failed — the remote head moved (a commit dev-lead never fetched) or access was denied; not overwriting." >&2
return 1
}
12 changes: 8 additions & 4 deletions tests/dev-lead/e2e/scenarios/05-skip-anti-loop.sh
Original file line number Diff line number Diff line change
Expand Up @@ -173,15 +173,19 @@ main() {
else
echo "[PASS] ${SCENARIO_NAME}(B): human sync correctly NOT skipped with anti-loop reason (got: ${intent_reason_b})"
fi
# human synchronize should route to human-pr
if ! assert_eq "${intent_type_b}" "human-pr" "${SCENARIO_NAME}(B): INTENT_TYPE=human-pr for human sync"; then
# A human sync on a human-authored branch (not dev-lead/issue-*) must be left
# to the human: skip not-dev-lead-authored (#1311), not seized by dev-lead.
if ! assert_eq "${intent_type_b}" "skip" "${SCENARIO_NAME}(B): INTENT_TYPE=skip for human sync"; then
all_pass=false
fi
if ! assert_eq "${intent_reason_b}" "not-dev-lead-authored" "${SCENARIO_NAME}(B): INTENT_REASON=not-dev-lead-authored for human sync"; then
all_pass=false
fi

# ── Result ─────────────────────────────────────────────────────────────────
if [ "${all_pass}" = "true" ]; then
log "[PASS] ${SCENARIO_NAME}: anti-loop guard fires for BOT_USER, not for human"
record_result "${SCENARIO_NAME}" "PASS" "bot→skip(dev-lead-own-commit) human→human-pr"
log "[PASS] ${SCENARIO_NAME}: anti-loop guard fires for BOT_USER, human PR left untouched"
record_result "${SCENARIO_NAME}" "PASS" "bot→skip(dev-lead-own-commit) human→skip(not-dev-lead-authored)"
exit 0
else
err "[FAIL] ${SCENARIO_NAME}: one or more assertions failed"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"_test_expected_intent": "skip",
"action": "created",
"issue": {
"number": 1303,
"title": "feat: onboard personas",
"state": "open",
"user": {
"login": "donpetry",
"type": "User"
},
"pull_request": {
"url": "https://api.github.com/repos/petry-projects/.github-private/pulls/1303"
}
},
"comment": {
"id": 4060,
"body": "SonarQube analysis found 3 new issues:\n- Critical: SQL injection vulnerability in line 42",
"user": {
"login": "sonarqubecloud[bot]",
"type": "Bot"
}
},
"repository": { "full_name": "petry-projects/.github-private" },
"sender": { "login": "sonarqubecloud[bot]", "type": "Bot" }
}
13 changes: 11 additions & 2 deletions tests/dev-lead/fixtures/events/issue_comment_coderabbit.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
"state": "open",
"pull_request": {
"url": "https://api.github.com/repos/petry-projects/.github-private/pulls/62"
},
"user": {
"login": "donpetry-bot",
"type": "Bot"
}
},
"comment": {
Expand All @@ -17,6 +21,11 @@
"type": "Bot"
}
},
"repository": { "full_name": "petry-projects/.github-private" },
"sender": { "login": "coderabbitai[bot]", "type": "Bot" }
"repository": {
"full_name": "petry-projects/.github-private"
},
"sender": {
"login": "coderabbitai[bot]",
"type": "Bot"
}
}
13 changes: 11 additions & 2 deletions tests/dev-lead/fixtures/events/issue_comment_sonarqube.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
"state": "open",
"pull_request": {
"url": "https://api.github.com/repos/petry-projects/.github-private/pulls/61"
},
"user": {
"login": "donpetry-bot",
"type": "Bot"
}
},
"comment": {
Expand All @@ -17,6 +21,11 @@
"type": "Bot"
}
},
"repository": { "full_name": "petry-projects/.github-private" },
"sender": { "login": "sonarqubecloud[bot]", "type": "Bot" }
"repository": {
"full_name": "petry-projects/.github-private"
},
"sender": {
"login": "sonarqubecloud[bot]",
"type": "Bot"
}
}
27 changes: 27 additions & 0 deletions tests/dev-lead/fixtures/events/pr_opened_dev_lead.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
{
"_test_expected_intent": "review-changes",
"action": "opened",
"number": 1402,
"pull_request": {
"number": 1402,
"title": "feat: implement issue #1402",
"body": "Implements the assigned issue.",
"state": "open",
"author_association": "OWNER",
"user": {
"login": "donpetry-bot",
"type": "Bot"
},
"head": {
"sha": "abc1402def",
"ref": "dev-lead/issue-1402-20260718-1200",
"repo": { "full_name": "petry-projects/.github-private" }
},
"base": {
"ref": "main",
"repo": { "full_name": "petry-projects/.github-private" }
}
},
"repository": { "full_name": "petry-projects/.github-private" },
"sender": { "login": "donpetry-bot", "type": "User" }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"_test_expected_intent": "fix-reviews",
"action": "submitted",
"review": {
"id": 4002,
"state": "COMMENTED",
"body": "Please address the following issues in your code.",
"user": {
"login": "copilot-pull-request-reviewer[bot]",
"type": "Bot"
}
},
"pull_request": {
"number": 1400,
"title": "feat: implement issue #1400",
"state": "open",
"author_association": "OWNER",
"user": {
"login": "donpetry-bot",
"type": "Bot"
},
"head": {
"sha": "beef1234cafe",
"ref": "dev-lead/issue-1400-20260718-1200",
"repo": { "full_name": "petry-projects/.github-private" }
},
"base": {
"ref": "main",
"repo": { "full_name": "petry-projects/.github-private" }
}
},
"repository": { "full_name": "petry-projects/.github-private" },
"sender": { "login": "copilot-pull-request-reviewer[bot]", "type": "Bot" }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
{
"_test_expected_intent": "skip",
"action": "submitted",
"review": {
"id": 4001,
"state": "COMMENTED",
"body": "Please address the following issues in your code.",
"user": {
"login": "copilot-pull-request-reviewer[bot]",
"type": "Bot"
}
},
"pull_request": {
"number": 1303,
"title": "feat: onboard personas",
"state": "open",
"author_association": "OWNER",
"user": {
"login": "donpetry",
"type": "User"
},
"head": {
"sha": "08794259aaaa",
"ref": "feat/onboard-personas",
"repo": { "full_name": "petry-projects/.github-private" }
},
"base": {
"ref": "main",
"repo": { "full_name": "petry-projects/.github-private" }
}
},
"repository": { "full_name": "petry-projects/.github-private" },
"sender": { "login": "copilot-pull-request-reviewer[bot]", "type": "Bot" }
}
Loading
Loading