diff --git a/.github/workflows/bm-bossbot.yml b/.github/workflows/bm-bossbot.yml index ad338892..30e1e427 100644 --- a/.github/workflows/bm-bossbot.yml +++ b/.github/workflows/bm-bossbot.yml @@ -65,6 +65,24 @@ jobs: echo "pr_number=$(jq -r '.pull_request.number' "${event_file}")" >> "${GITHUB_OUTPUT}" echo "head_sha=$(jq -r '.pull_request.head.sha' "${event_file}")" >> "${GITHUB_OUTPUT}" echo "head_ref=$(jq -r '.pull_request.head.ref' "${event_file}")" >> "${GITHUB_OUTPUT}" + echo "author_association=$(jq -r '.pull_request.author_association // ""' "${event_file}")" >> "${GITHUB_OUTPUT}" + + - name: Classify PR author + id: trust + env: + AUTHOR_ASSOCIATION: ${{ steps.pr.outputs.author_association }} + run: | + set -euo pipefail + case "${AUTHOR_ASSOCIATION}" in + OWNER|MEMBER|COLLABORATOR) + trusted_author=true + ;; + *) + trusted_author=false + ;; + esac + echo "trusted_author=${trusted_author}" >> "${GITHUB_OUTPUT}" + echo "author_association=${AUTHOR_ASSOCIATION}" >> "${GITHUB_OUTPUT}" - name: Mark BM Bossbot approval pending env: @@ -75,8 +93,36 @@ jobs: --repo "${GITHUB_REPOSITORY}" \ --run-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + - name: Decline outside contributor PRs + id: outside + if: steps.trust.outputs.trusted_author != 'true' + env: + HEAD_SHA: ${{ steps.pr.outputs.head_sha }} + AUTHOR_ASSOCIATION: ${{ steps.trust.outputs.author_association }} + run: | + set -euo pipefail + review_file="${RUNNER_TEMP}/bm-bossbot-review.json" + jq -n \ + --arg sha "${HEAD_SHA}" \ + --arg association "${AUTHOR_ASSOCIATION}" \ + '{ + reviewed_head_sha: $sha, + review_complete: false, + verdict: "needs_human", + blocking_findings: [ + { + title: "BM Bossbot does not run for outside contributors", + body: "This PR author association is \($association). BM Bossbot only runs for OWNER, MEMBER, and COLLABORATOR pull requests, so this PR requires a maintainer path outside the automatic merge gate." + } + ], + nonblocking_findings: [], + summary: "BM Bossbot intentionally did not run Codex because this PR was not opened by an owner, member, or collaborator." + }' > "${review_file}" + echo "review_file=${review_file}" >> "${GITHUB_OUTPUT}" + - name: Collect sanitized PR context id: context + if: steps.trust.outputs.trusted_author == 'true' env: GH_TOKEN: ${{ github.token }} PR_NUMBER: ${{ steps.pr.outputs.pr_number }} @@ -149,7 +195,7 @@ jobs: - name: Run BM Bossbot review with Codex id: codex - if: steps.context.outputs.diff_truncated != 'true' + if: steps.trust.outputs.trusted_author == 'true' && steps.context.outputs.diff_truncated != 'true' uses: openai/codex-action@v1 with: openai-api-key: ${{ secrets.OPENAI_API_KEY }} @@ -159,6 +205,17 @@ jobs: sandbox: read-only safety-strategy: drop-sudo + - name: Select BM Bossbot review output + id: review_output + if: always() + env: + OUTSIDE_REVIEW_FILE: ${{ steps.outside.outputs.review_file }} + CONTEXT_REVIEW_FILE: ${{ steps.context.outputs.review_file }} + run: | + set -euo pipefail + review_file="${OUTSIDE_REVIEW_FILE:-${CONTEXT_REVIEW_FILE:-${RUNNER_TEMP}/missing-bm-bossbot-review.json}}" + echo "review_file=${review_file}" >> "${GITHUB_OUTPUT}" + - name: Finalize BM Bossbot approval if: always() env: @@ -166,7 +223,7 @@ jobs: run: | uv run --script scripts/bm_bossbot_status.py finalize \ --event "${{ steps.pr.outputs.event_file }}" \ - --review "${{ steps.context.outputs.review_file }}" \ + --review "${{ steps.review_output.outputs.review_file }}" \ --repo "${GITHUB_REPOSITORY}" \ --run-url "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" diff --git a/tests/ci/test_bm_bossbot_workflow.py b/tests/ci/test_bm_bossbot_workflow.py index 46121c05..b39589d4 100644 --- a/tests/ci/test_bm_bossbot_workflow.py +++ b/tests/ci/test_bm_bossbot_workflow.py @@ -106,10 +106,38 @@ def test_bm_bossbot_rejects_oversized_diffs_without_partial_approval() -> None: assert "review_complete: false" in workflow_text assert 'verdict: "needs_human"' in workflow_text assert "Diff exceeds BM Bossbot review limit" in workflow_text - assert run_codex["if"] == "steps.context.outputs.diff_truncated != 'true'" + assert ( + run_codex["if"] + == "steps.trust.outputs.trusted_author == 'true' && steps.context.outputs.diff_truncated != 'true'" + ) assert "head -c 120000" not in workflow_text +def test_bm_bossbot_does_not_run_codex_for_outside_contributors() -> None: + workflow_text = WORKFLOW_PATH.read_text(encoding="utf-8") + workflow = _workflow() + steps = workflow["jobs"]["review"]["steps"] + + classify = next(step for step in steps if step["name"] == "Classify PR author") + outside = next(step for step in steps if step["name"] == "Decline outside contributor PRs") + collect = next(step for step in steps if step["name"] == "Collect sanitized PR context") + run_codex = next(step for step in steps if step["name"] == "Run BM Bossbot review with Codex") + select_review = next(step for step in steps if step["name"] == "Select BM Bossbot review output") + finalize = next(step for step in steps if step["name"] == "Finalize BM Bossbot approval") + + assert "OWNER|MEMBER|COLLABORATOR" in classify["run"] + assert outside["if"] == "steps.trust.outputs.trusted_author != 'true'" + assert collect["if"] == "steps.trust.outputs.trusted_author == 'true'" + assert ( + run_codex["if"] + == "steps.trust.outputs.trusted_author == 'true' && steps.context.outputs.diff_truncated != 'true'" + ) + assert select_review["if"] == "always()" + assert "BM Bossbot does not run for outside contributors" in workflow_text + assert "missing-bm-bossbot-review.json" in workflow_text + assert '--review "${{ steps.review_output.outputs.review_file }}"' in finalize["run"] + + def test_bm_bossbot_prompt_references_engineering_style_and_json_bullets() -> None: prompt = PROMPT_PATH.read_text(encoding="utf-8")