From 62c144eb66f39a15574c77d2d2294e4e5bb8beac Mon Sep 17 00:00:00 2001 From: Hiroshi Nishio Date: Sat, 21 Feb 2026 18:30:35 -0800 Subject: [PATCH] Fix pull_request.labeled handler processing any label from any sender - Check label name matches PRODUCT_ID before processing (root cause: dependabot labels like "dependencies" triggered full agent pipeline) - Reject bot senders except GitAuto's own app (allow schedule triggers) - Return early for non-gitauto branch prefixes instead of defaulting to "dashboard" - Add tests for all three guard checks --- schemas/supabase/types.py | 16 +++++ services/webhook/test_webhook_handler.py | 74 ++++++++++++++++++++++++ services/webhook/webhook_handler.py | 23 +++++++- 3 files changed, 111 insertions(+), 2 deletions(-) diff --git a/schemas/supabase/types.py b/schemas/supabase/types.py index 07bc5b8d..b76a4639 100644 --- a/schemas/supabase/types.py +++ b/schemas/supabase/types.py @@ -160,6 +160,22 @@ class CreditsInsert(TypedDict): expires_at: NotRequired[datetime.datetime | None] +class EmailSends(TypedDict): + id: int + owner_id: int + owner_name: str + email_type: str + resend_email_id: str | None + created_at: datetime.datetime + + +class EmailSendsInsert(TypedDict): + owner_id: int + owner_name: str + email_type: str + resend_email_id: NotRequired[str | None] + + class Installations(TypedDict): created_at: datetime.datetime installation_id: int diff --git a/services/webhook/test_webhook_handler.py b/services/webhook/test_webhook_handler.py index b173b73e..519935c4 100644 --- a/services/webhook/test_webhook_handler.py +++ b/services/webhook/test_webhook_handler.py @@ -238,7 +238,9 @@ async def test_handle_webhook_event_pull_request_labeled_dashboard( """Test handling of pull request labeled event from dashboard triggers handle_new_pr.""" payload = { "action": "labeled", + "label": {"name": "gitauto"}, "pull_request": {"head": {"ref": "gitauto/dashboard-20250101-120000-Ab12"}}, + "sender": {"login": "test-user", "id": 12345}, } with patch("services.webhook.webhook_handler.PRODUCT_ID", "gitauto"): @@ -257,7 +259,9 @@ async def test_handle_webhook_event_pull_request_labeled_schedule( """Test handling of pull request labeled event from schedule triggers handle_new_pr.""" payload = { "action": "labeled", + "label": {"name": "gitauto"}, "pull_request": {"head": {"ref": "gitauto/schedule-20250101-120000-Ab12"}}, + "sender": {"login": "test-user", "id": 12345}, } with patch("services.webhook.webhook_handler.PRODUCT_ID", "gitauto"): @@ -269,6 +273,76 @@ async def test_handle_webhook_event_pull_request_labeled_schedule( lambda_info=None, ) + @pytest.mark.asyncio + async def test_handle_webhook_event_pull_request_labeled_non_gitauto_label_ignored( + self, mock_handle_new_pr + ): + """Test that non-gitauto labels (e.g. dependabot's 'dependencies') are ignored.""" + payload = { + "action": "labeled", + "label": {"name": "dependencies"}, + "pull_request": {"head": {"ref": "dependabot/npm_and_yarn/ajv-6.14.0"}}, + "sender": {"login": "dependabot[bot]", "id": 49699333}, + } + + with patch("services.webhook.webhook_handler.PRODUCT_ID", "gitauto"): + await handle_webhook_event(event_name="pull_request", payload=payload) + + mock_handle_new_pr.assert_not_called() + + @pytest.mark.asyncio + async def test_handle_webhook_event_pull_request_labeled_bot_sender_ignored( + self, mock_handle_new_pr + ): + """Test that bot senders (other than GitAuto) are rejected even with gitauto label.""" + payload = { + "action": "labeled", + "label": {"name": "gitauto"}, + "pull_request": {"head": {"ref": "dependabot/npm_and_yarn/ajv-6.14.0"}}, + "sender": {"login": "dependabot[bot]", "id": 49699333}, + } + + with patch("services.webhook.webhook_handler.PRODUCT_ID", "gitauto"): + await handle_webhook_event(event_name="pull_request", payload=payload) + + mock_handle_new_pr.assert_not_called() + + @pytest.mark.asyncio + async def test_handle_webhook_event_pull_request_labeled_gitauto_bot_allowed( + self, mock_handle_new_pr + ): + """Test that GitAuto's own bot is allowed (for schedule triggers).""" + payload = { + "action": "labeled", + "label": {"name": "gitauto"}, + "pull_request": {"head": {"ref": "gitauto/schedule-20250101-120000-Ab12"}}, + "sender": {"login": "gitauto[bot]", "id": 160085510}, + } + + with patch("services.webhook.webhook_handler.PRODUCT_ID", "gitauto"), patch( + "services.webhook.webhook_handler.GITHUB_APP_USER_ID", 160085510 + ): + await handle_webhook_event(event_name="pull_request", payload=payload) + + mock_handle_new_pr.assert_called_once() + + @pytest.mark.asyncio + async def test_handle_webhook_event_pull_request_labeled_non_gitauto_branch_ignored( + self, mock_handle_new_pr + ): + """Test that a gitauto label on a non-gitauto branch is ignored.""" + payload = { + "action": "labeled", + "label": {"name": "gitauto"}, + "pull_request": {"head": {"ref": "feature/some-branch"}}, + "sender": {"login": "test-user", "id": 12345}, + } + + with patch("services.webhook.webhook_handler.PRODUCT_ID", "gitauto"): + await handle_webhook_event(event_name="pull_request", payload=payload) + + mock_handle_new_pr.assert_not_called() + @pytest.mark.asyncio async def test_handle_webhook_event_check_suite_completed_failure( self, mock_handle_check_suite diff --git a/services/webhook/webhook_handler.py b/services/webhook/webhook_handler.py index c9be3639..2c8ca372 100644 --- a/services/webhook/webhook_handler.py +++ b/services/webhook/webhook_handler.py @@ -3,6 +3,7 @@ # Local imports from config import ( + GITHUB_APP_USER_ID, GITHUB_CHECK_RUN_FAILURES, PRODUCT_ID, ) @@ -183,11 +184,29 @@ async def handle_webhook_event( # See https://docs.github.com/en/webhooks/webhook-events-and-payloads#pull_request if event_name == "pull_request" and action == "labeled": typed_payload = cast(PrLabeledPayload, payload) + + # Only process when the "gitauto" label is specifically added + label_name = typed_payload["label"]["name"] + if label_name != PRODUCT_ID: + logger.info("Ignoring non-gitauto label: %s", label_name) + return + + # Reject bot senders (except GitAuto's own app for schedule triggers) + sender = typed_payload["sender"] + sender_login = sender["login"] + if sender_login.endswith("[bot]") and sender["id"] != GITHUB_APP_USER_ID: + logger.info("Ignoring label event from bot: %s", sender_login) + return + # Determine trigger from branch name: {PRODUCT_ID}/schedule-* vs {PRODUCT_ID}/dashboard-* head_ref = typed_payload["pull_request"]["head"]["ref"] prefix = f"{PRODUCT_ID}/" - suffix = head_ref[len(prefix) :] if head_ref.startswith(prefix) else "" - trigger = cast(Trigger, suffix.split("-")[0] if suffix else "dashboard") + if not head_ref.startswith(prefix): + logger.info("Ignoring non-gitauto branch: %s", head_ref) + return + + suffix = head_ref[len(prefix) :] + trigger = cast(Trigger, suffix.split("-")[0]) await handle_new_pr( payload=typed_payload, trigger=trigger,