Suppress routine GitHub CI webhook deliveries
This commit is contained in:
@@ -26,3 +26,5 @@ WEBHOOK_DISPATCH_THROTTLE_SECONDS=2.0
|
|||||||
WEBHOOK_DISPATCH_SCHEDULE_ID=webhook-dispatch-batch
|
WEBHOOK_DISPATCH_SCHEDULE_ID=webhook-dispatch-batch
|
||||||
WEBHOOK_DISPATCH_SCHEDULE_INTERVAL_SECONDS=900
|
WEBHOOK_DISPATCH_SCHEDULE_INTERVAL_SECONDS=900
|
||||||
WEBHOOK_DISPATCH_MAX_RETRIES=3
|
WEBHOOK_DISPATCH_MAX_RETRIES=3
|
||||||
|
# Suppress routine GitHub CI telemetry events from lead notifications (still persisted to DB/memory).
|
||||||
|
WEBHOOK_DISPATCH_SUPPRESS_ROUTINE_EVENTS=true
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ class Settings(BaseSettings):
|
|||||||
webhook_dispatch_throttle_seconds: float = 2.0
|
webhook_dispatch_throttle_seconds: float = 2.0
|
||||||
webhook_dispatch_schedule_interval_seconds: int = 900
|
webhook_dispatch_schedule_interval_seconds: int = 900
|
||||||
webhook_dispatch_max_retries: int = 3
|
webhook_dispatch_max_retries: int = 3
|
||||||
|
# If true, suppress high-volume routine CI telemetry events (e.g. GitHub check_run success)
|
||||||
|
# from lead notifications. Payloads are still persisted and recorded in board memory.
|
||||||
|
webhook_dispatch_suppress_routine_events: bool = True
|
||||||
|
|
||||||
# Logging
|
# Logging
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
|
|||||||
@@ -24,6 +24,102 @@ from app.services.webhooks.queue import (
|
|||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
_ROUTINE_GITHUB_EVENTS = frozenset({"check_run", "check_suite", "workflow_run"})
|
||||||
|
_SUCCESS_GITHUB_CONCLUSIONS = frozenset({None, "success", "neutral", "skipped"})
|
||||||
|
# Consider these actionable enough to page the lead / surface in task threads.
|
||||||
|
_ACTIONABLE_GITHUB_CONCLUSIONS = frozenset(
|
||||||
|
{
|
||||||
|
"failure",
|
||||||
|
"cancelled",
|
||||||
|
"timed_out",
|
||||||
|
"action_required",
|
||||||
|
"stale",
|
||||||
|
"startup_failure",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_dict(value: object) -> dict[str, object] | None:
|
||||||
|
if isinstance(value, dict):
|
||||||
|
# Keep only string keys; payloads can include non-str keys in edge cases.
|
||||||
|
normalized: dict[str, object] = {}
|
||||||
|
for k, v in value.items():
|
||||||
|
if isinstance(k, str):
|
||||||
|
normalized[k] = v
|
||||||
|
return normalized
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _str_or_none(value: object) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
if isinstance(value, str):
|
||||||
|
return value
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_github_conclusion(payload: dict[str, object], *, key: str) -> str | None:
|
||||||
|
container = _as_dict(payload.get(key))
|
||||||
|
if not container:
|
||||||
|
return None
|
||||||
|
return _str_or_none(container.get("conclusion"))
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_github_status(payload: dict[str, object], *, key: str) -> str | None:
|
||||||
|
container = _as_dict(payload.get(key))
|
||||||
|
if not container:
|
||||||
|
return None
|
||||||
|
return _str_or_none(container.get("status"))
|
||||||
|
|
||||||
|
|
||||||
|
def _should_suppress_routine_delivery(
|
||||||
|
*,
|
||||||
|
payload_event: str | None,
|
||||||
|
payload_value: object,
|
||||||
|
) -> bool:
|
||||||
|
"""Return True if this delivery is routine noise and should not notify leads.
|
||||||
|
|
||||||
|
This intentionally only targets high-volume GitHub CI telemetry events.
|
||||||
|
We still persist the webhook payload + board memory entry for audit/debug.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not settings.webhook_dispatch_suppress_routine_events:
|
||||||
|
return False
|
||||||
|
|
||||||
|
if payload_event not in _ROUTINE_GITHUB_EVENTS:
|
||||||
|
return False
|
||||||
|
|
||||||
|
payload = _as_dict(payload_value)
|
||||||
|
if payload is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
action = _str_or_none(payload.get("action"))
|
||||||
|
# If GitHub hasn't marked it completed, it's almost always noise.
|
||||||
|
if action and action != "completed":
|
||||||
|
return True
|
||||||
|
|
||||||
|
if payload_event == "workflow_run":
|
||||||
|
status = _extract_github_status(payload, key="workflow_run")
|
||||||
|
if status and status != "completed":
|
||||||
|
return True
|
||||||
|
conclusion = _extract_github_conclusion(payload, key="workflow_run")
|
||||||
|
elif payload_event == "check_run":
|
||||||
|
status = _extract_github_status(payload, key="check_run")
|
||||||
|
if status and status != "completed":
|
||||||
|
return True
|
||||||
|
conclusion = _extract_github_conclusion(payload, key="check_run")
|
||||||
|
else: # check_suite
|
||||||
|
status = _extract_github_status(payload, key="check_suite")
|
||||||
|
if status and status != "completed":
|
||||||
|
return True
|
||||||
|
conclusion = _extract_github_conclusion(payload, key="check_suite")
|
||||||
|
|
||||||
|
if conclusion in _SUCCESS_GITHUB_CONCLUSIONS:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Only page on explicitly non-success conclusions.
|
||||||
|
return conclusion not in _ACTIONABLE_GITHUB_CONCLUSIONS
|
||||||
|
|
||||||
|
|
||||||
def _build_payload_preview(payload_value: object) -> str:
|
def _build_payload_preview(payload_value: object) -> str:
|
||||||
if isinstance(payload_value, str):
|
if isinstance(payload_value, str):
|
||||||
@@ -165,6 +261,22 @@ async def _process_single_item(item: QueuedWebhookDelivery) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
board, webhook, payload = loaded
|
board, webhook, payload = loaded
|
||||||
|
if _should_suppress_routine_delivery(
|
||||||
|
payload_event=item.payload_event,
|
||||||
|
payload_value=payload.payload,
|
||||||
|
):
|
||||||
|
logger.info(
|
||||||
|
"webhook.dispatch.suppressed_routine",
|
||||||
|
extra={
|
||||||
|
"payload_id": str(item.payload_id),
|
||||||
|
"webhook_id": str(item.webhook_id),
|
||||||
|
"board_id": str(item.board_id),
|
||||||
|
"event": item.payload_event,
|
||||||
|
"attempt": item.attempts,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
await _notify_lead(session=session, board=board, webhook=webhook, payload=payload)
|
await _notify_lead(session=session, board=board, webhook=webhook, payload=payload)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
|
|
||||||
|
|||||||
71
backend/tests/test_webhook_routine_suppression.py
Normal file
71
backend/tests/test_webhook_routine_suppression.py
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
# ruff: noqa: INP001
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.services.webhooks import dispatch
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("payload_event", "payload_value", "expected"),
|
||||||
|
[
|
||||||
|
("check_run", {"action": "completed", "check_run": {"status": "completed", "conclusion": "success"}}, True),
|
||||||
|
("check_run", {"action": "completed", "check_run": {"status": "completed", "conclusion": None}}, True),
|
||||||
|
("check_run", {"action": "created", "check_run": {"status": "queued"}}, True),
|
||||||
|
("check_run", {"action": "completed", "check_run": {"status": "completed", "conclusion": "failure"}}, False),
|
||||||
|
(
|
||||||
|
"workflow_run",
|
||||||
|
{"action": "completed", "workflow_run": {"status": "completed", "conclusion": "success"}},
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"workflow_run",
|
||||||
|
{"action": "completed", "workflow_run": {"status": "completed", "conclusion": "cancelled"}},
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"check_suite",
|
||||||
|
{"action": "completed", "check_suite": {"status": "completed", "conclusion": "timed_out"}},
|
||||||
|
False,
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"check_suite",
|
||||||
|
{"action": "completed", "check_suite": {"status": "completed", "conclusion": "neutral"}},
|
||||||
|
True,
|
||||||
|
),
|
||||||
|
# Non-target events should not be suppressed by this helper.
|
||||||
|
("pull_request", {"action": "opened"}, False),
|
||||||
|
(None, {"action": "opened"}, False),
|
||||||
|
# Non-dict payloads: don't suppress (we can't reason about it).
|
||||||
|
("check_run", "raw", False),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_should_suppress_routine_delivery(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
payload_event: str | None,
|
||||||
|
payload_value: object,
|
||||||
|
expected: bool,
|
||||||
|
) -> None:
|
||||||
|
monkeypatch.setattr(dispatch.settings, "webhook_dispatch_suppress_routine_events", True)
|
||||||
|
assert (
|
||||||
|
dispatch._should_suppress_routine_delivery(
|
||||||
|
payload_event=payload_event,
|
||||||
|
payload_value=payload_value,
|
||||||
|
)
|
||||||
|
is expected
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_suppression_disabled_via_settings(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(dispatch.settings, "webhook_dispatch_suppress_routine_events", False)
|
||||||
|
assert (
|
||||||
|
dispatch._should_suppress_routine_delivery(
|
||||||
|
payload_event="check_run",
|
||||||
|
payload_value={
|
||||||
|
"action": "completed",
|
||||||
|
"check_run": {"status": "completed", "conclusion": "success"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
is False
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user