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
44 changes: 44 additions & 0 deletions scripts/dev-lead-intent.sh
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,20 @@ has_trigger_phrase() {
return 1
}

# has_label <name>
# Returns 0 (true) if the PR or issue in the event payload carries label <name>.
# Reads labels straight from the payload (.pull_request.labels / .issue.labels),
# so it stays a pure classifier with no API calls. repository_dispatch payloads
# carry no labels; those are handled via a separate API call in the dispatch
# branch of the routing section.
has_label() {
local name="$1"
[ -n "$EVENT_PATH" ] && [ -f "$EVENT_PATH" ] || return 1
jq -e --arg n "$name" '
any(.pull_request.labels[]?, .issue.labels[]?; .name == $n)
' "$EVENT_PATH" >/dev/null 2>&1
Comment thread
don-petry marked this conversation as resolved.
}

# is_fork_pr <event_path>
# Returns 0 (true) if the PR head repo differs from GITHUB_REPOSITORY.
is_fork_pr() {
Expand Down Expand Up @@ -120,6 +134,20 @@ if [ "$EVENT_NAME" = "check_run" ]; then
exit 0
fi

# ── hands-off guard ───────────────────────────────────────────────────────────
# A PR or issue labeled `dev-lead:hands-off` is intentionally excluded from the
# agent — e.g. PRs that modify the dev-lead workflow itself, so the agent does
# not pile commits onto its own infrastructure changes. Runs before the anti-loop
# guard so a hands-off sync from BOT_USER emits `hands-off-label` rather than
# `dev-lead-own-commit`, keeping the skip reason stable whenever the label is
# present. Applies to every label-bearing event (PR opens/syncs, reviews, review
# comments, issue comments on PRs, issue labeling). repository_dispatch payloads
# carry no labels; those are checked via the API in the dispatch branch below.
if has_label "dev-lead:hands-off"; then
emit_skip "hands-off-label"
exit 0
fi

# ── anti-loop guard: pull_request synchronize ────────────────────────────────
# If the synchronize event was triggered by BOT_USER's own commit, skip to
# prevent an infinite fix → push → trigger → fix loop.
Expand Down Expand Up @@ -346,6 +374,22 @@ case "$EVENT_NAME" in
exit 0
fi

# Honour hands-off for dispatch events: the event payload carries no labels,
# so fetch them via the API before routing. Fail closed: if the API call
# fails (auth error, transient failure, token without label read access),
# skip rather than route with unknown label state.
_dispatch_labels=""
if ! _dispatch_labels=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${pr_number}/labels" \
--paginate --jq '.[].name' 2>/dev/null); then
Comment thread
don-petry marked this conversation as resolved.
emit_skip "label-lookup-error"
exit 0
fi
if echo "$_dispatch_labels" | grep -qxF "dev-lead:hands-off"; then
emit_skip "hands-off-label"
exit 0
fi
unset _dispatch_labels

case "$dispatch_type" in
dev-lead-ci-failure)
checks=$(jq -c '.client_payload.checks // []' "$EVENT_PATH" 2>/dev/null || echo "[]")
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"_test_expected_intent": "skip",
"action": "labeled",
"issue": {
"number": 100,
"title": "feat: implement OAuth2 support",
"body": "We need to add OAuth2 authentication support for third-party integrations.\n\n## Requirements\n- Support Google OAuth2\n- Support GitHub OAuth2\n- Add token refresh logic",
"state": "open",
"author_association": "OWNER",
"labels": [
{
"name": "dev-lead"
},
{
"name": "dev-lead:hands-off"
}
]
},
"label": {
"name": "dev-lead"
},
"repository": {
"full_name": "petry-projects/.github-private"
},
"sender": {
"login": "donpetry",
"type": "User"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
{
"_test_expected_intent": "skip",
"action": "created",
"comment": {
"id": 2001,
"body": "This function has a potential null pointer dereference on line 42.",
"path": "src/utils/helpers.js",
"line": 42,
"user": {
"login": "copilot-pull-request-reviewer[bot]",
"type": "Bot"
}
},
"pull_request": {
"number": 58,
"title": "feat: utility functions",
"state": "open",
"author_association": "OWNER",
"head": {
"sha": "hhh888iii999",
"ref": "feat/utilities",
"repo": {
"full_name": "petry-projects/.github-private"
}
},
"base": {
"ref": "main",
"repo": {
"full_name": "petry-projects/.github-private"
}
},
"labels": [
{
"name": "dev-lead:hands-off"
}
]
},
"repository": {
"full_name": "petry-projects/.github-private"
},
"sender": {
"login": "copilot-pull-request-reviewer[bot]",
"type": "Bot"
}
}
26 changes: 26 additions & 0 deletions tests/dev-lead/fixtures/events/pr_sync_bot_hands_off.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
{
"_test_expected_intent": "skip",
"action": "synchronize",
"number": 53,
"pull_request": {
"number": 53,
"title": "feat: update dev-lead workflow",
"body": "Automated fix by dev-lead agent.",
"state": "open",
"author_association": "OWNER",
"head": {
"sha": "ccc333ddd444",
"ref": "feat/dev-lead-update",
"repo": { "full_name": "petry-projects/.github-private" }
},
"base": {
"ref": "main",
"repo": { "full_name": "petry-projects/.github-private" }
},
"labels": [
{ "name": "dev-lead:hands-off" }
]
},
"repository": { "full_name": "petry-projects/.github-private" },
"sender": { "login": "donpetry-bot", "type": "Bot" }
}
10 changes: 10 additions & 0 deletions tests/dev-lead/unit/test_intent_ci.bats
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,20 @@ setup() {
export TRUSTED_BOTS="copilot-pull-request-reviewer[bot],gemini-code-assist[bot],sonarqubecloud[bot],coderabbitai[bot]"
export TRIGGER_PHRASES="@dev-lead"
export GITHUB_REPOSITORY="petry-projects/.github-private"
MOCK_BIN="$(mktemp -d)"
export PATH="$MOCK_BIN:$PATH"
# Stub gh so dispatch tests don't require an authenticated GitHub CLI.
# Default: no labels (unlabeled PR), exit 0.
cat > "$MOCK_BIN/gh" << 'GHEOF'
#!/usr/bin/env bash
exit 0
GHEOF
chmod +x "$MOCK_BIN/gh"
}

teardown() {
rm -f "$GITHUB_ENV" "$GITHUB_OUTPUT"
rm -rf "$MOCK_BIN"
}

_get_env() {
Expand Down
127 changes: 127 additions & 0 deletions tests/dev-lead/unit/test_intent_hands_off.bats
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
#!/usr/bin/env bats
# Unit tests for dev-lead-intent.sh — the `dev-lead:hands-off` label guard.
# A PR or issue carrying the label is excluded from the agent regardless of the
# event that would otherwise route it (so the agent doesn't pile commits onto
# PRs that modify the dev-lead workflow itself).

SCRIPT_DIR="$(cd "$(dirname "$BATS_TEST_FILENAME")/../../.." && pwd)"
INTENT_SCRIPT="$SCRIPT_DIR/scripts/dev-lead-intent.sh"
FIXTURES_DIR="$SCRIPT_DIR/tests/dev-lead/fixtures/events"

setup() {
export GITHUB_ENV="$(mktemp)"
export GITHUB_OUTPUT="$(mktemp)"
export BOT_USER="donpetry-bot"
export TRUSTED_BOTS="copilot-pull-request-reviewer[bot],gemini-code-assist[bot],sonarqubecloud[bot],coderabbitai[bot],chatgpt-codex-connector[bot]"
export TRIGGER_PHRASES="@dev-lead"
export GITHUB_REPOSITORY="petry-projects/.github-private"
MOCK_BIN="$(mktemp -d)"
export PATH="$MOCK_BIN:$PATH"
}

teardown() {
rm -f "$GITHUB_ENV" "$GITHUB_OUTPUT"
rm -rf "$MOCK_BIN"
}

_get_env() {
local key="$1"
grep "^${key}=" "$GITHUB_ENV" | cut -d= -f2- | head -1
}

@test "hands-off: bot review comment on a labeled PR → skip (not fix-reviews)" {
export GITHUB_EVENT_NAME="pull_request_review_comment"
export GITHUB_EVENT_PATH="$FIXTURES_DIR/pr_review_comment_copilot_hands_off.json"

run bash "$INTENT_SCRIPT"

[ "$status" -eq 0 ]
[ "$(_get_env INTENT_TYPE)" = "skip" ]
[ "$(_get_env INTENT_REASON)" = "hands-off-label" ]
}

@test "hands-off: control — same bot review comment without the label → fix-reviews" {
export GITHUB_EVENT_NAME="pull_request_review_comment"
export GITHUB_EVENT_PATH="$FIXTURES_DIR/pr_review_comment_copilot.json"

run bash "$INTENT_SCRIPT"

[ "$status" -eq 0 ]
[ "$(_get_env INTENT_TYPE)" = "fix-reviews" ]
}

@test "hands-off: issue labeled dev-lead but also hands-off → skip (no pickup)" {
export GITHUB_EVENT_NAME="issues"
export GITHUB_EVENT_PATH="$FIXTURES_DIR/issues_labeled_dev_lead_hands_off.json"

run bash "$INTENT_SCRIPT"

[ "$status" -eq 0 ]
[ "$(_get_env INTENT_TYPE)" = "skip" ]
[ "$(_get_env INTENT_REASON)" = "hands-off-label" ]
}

@test "hands-off: bot sync on labeled PR → skip with hands-off-label (not dev-lead-own-commit)" {
export GITHUB_EVENT_NAME="pull_request"
export GITHUB_EVENT_PATH="$FIXTURES_DIR/pr_sync_bot_hands_off.json"

run bash "$INTENT_SCRIPT"

[ "$status" -eq 0 ]
[ "$(_get_env INTENT_TYPE)" = "skip" ]
[ "$(_get_env INTENT_REASON)" = "hands-off-label" ]
}

@test "hands-off: repository_dispatch ci-failure for labeled PR → skip" {
# Mock gh to return the hands-off label for the dispatch PR
cat > "$MOCK_BIN/gh" << 'GHEOF'
#!/usr/bin/env bash
echo "dev-lead:hands-off"
GHEOF
chmod +x "$MOCK_BIN/gh"

export GITHUB_EVENT_NAME="repository_dispatch"
export GITHUB_EVENT_PATH="$FIXTURES_DIR/repository_dispatch_ci_failure.json"

run bash "$INTENT_SCRIPT"

[ "$status" -eq 0 ]
[ "$(_get_env INTENT_TYPE)" = "skip" ]
[ "$(_get_env INTENT_REASON)" = "hands-off-label" ]
}

@test "hands-off: repository_dispatch ci-failure for unlabeled PR → fix-ci (control)" {
# Mock gh to return no labels
cat > "$MOCK_BIN/gh" << 'GHEOF'
#!/usr/bin/env bash
exit 0
GHEOF
chmod +x "$MOCK_BIN/gh"

export GITHUB_EVENT_NAME="repository_dispatch"
export GITHUB_EVENT_PATH="$FIXTURES_DIR/repository_dispatch_ci_failure.json"

run bash "$INTENT_SCRIPT"

[ "$status" -eq 0 ]
[ "$(_get_env INTENT_TYPE)" = "fix-ci" ]
}

@test "hands-off: repository_dispatch ci-failure with API error → skip (fail closed)" {
# Mock gh to simulate a failure (e.g. token lacks issue read access)
cat > "$MOCK_BIN/gh" << 'GHEOF'
#!/usr/bin/env bash
echo "gh: request failed: HTTP 403" >&2
exit 1
GHEOF
chmod +x "$MOCK_BIN/gh"

export GITHUB_EVENT_NAME="repository_dispatch"
export GITHUB_EVENT_PATH="$FIXTURES_DIR/repository_dispatch_ci_failure.json"

run bash "$INTENT_SCRIPT"

[ "$status" -eq 0 ]
[ "$(_get_env INTENT_TYPE)" = "skip" ]
[ "$(_get_env INTENT_REASON)" = "label-lookup-error" ]
}
10 changes: 10 additions & 0 deletions tests/dev-lead/unit/test_intent_stub.bats
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,20 @@ setup() {
export GITHUB_ENV="$(mktemp)"
export GITHUB_OUTPUT="$(mktemp)"
export BOT_USER="donpetry-bot"
MOCK_BIN="$(mktemp -d)"
export PATH="$MOCK_BIN:$PATH"
# Stub gh so repository_dispatch tests don't require an authenticated GitHub CLI.
# Default: no labels (unlabeled PR), exit 0.
cat > "$MOCK_BIN/gh" << 'GHEOF'
#!/usr/bin/env bash
exit 0
GHEOF
chmod +x "$MOCK_BIN/gh"
}

teardown() {
rm -f "$GITHUB_ENV" "$GITHUB_OUTPUT"
rm -rf "$MOCK_BIN"
}

# ── helpers ──────────────────────────────────────────────────────────────────
Expand Down
Loading