From 9da27bd203b9c4bd971ceccb18ec297d293c92de Mon Sep 17 00:00:00 2001 From: ayoubdiourin7 Date: Thu, 17 Sep 2026 13:36:01 +0200 Subject: [PATCH 01/12] Email the requester when a UI-launched run finishes --- services/hackbot-api/app/config.py | 6 + services/hackbot-api/app/notifications.py | 143 ++++++++++++++++++ services/hackbot-api/app/routers/runs.py | 5 +- .../hackbot-api/tests/test_finalize_run.py | 14 +- .../hackbot-api/tests/test_notifications.py | 98 ++++++++++++ 5 files changed, 263 insertions(+), 3 deletions(-) create mode 100644 services/hackbot-api/app/notifications.py create mode 100644 services/hackbot-api/tests/test_notifications.py diff --git a/services/hackbot-api/app/config.py b/services/hackbot-api/app/config.py index bd245620a2..519afa8686 100644 --- a/services/hackbot-api/app/config.py +++ b/services/hackbot-api/app/config.py @@ -104,6 +104,12 @@ class Settings(BaseSettings): push_auth_audience: str = "" push_auth_service_account: str = "" + # Where the UI serves a run's page; completion emails link to + # `/runs/`. The mail itself is configured by the + # SENDGRID_API_KEY / NOTIFICATION_SENDER / NOTIFICATION_OVERRIDE_EMAIL env + # vars shared with hackbot-runtime's email handler (see app/notifications.py). + ui_base_url: str = "http://localhost:3000" + # Server port: int = 8080 environment: str = "development" diff --git a/services/hackbot-api/app/notifications.py b/services/hackbot-api/app/notifications.py new file mode 100644 index 0000000000..64de95374e --- /dev/null +++ b/services/hackbot-api/app/notifications.py @@ -0,0 +1,143 @@ +"""Email the person who requested a run when it reaches a terminal state. + +A run launched from the UI carries its requester's email; once the run has +succeeded, failed or timed out, that one address gets a short note with the +outcome and a link to the run page, so nobody has to keep a tab open to watch +progress. Runs with no requester (those triggered by webhooks) are skipped: +their results already land where they were asked for. + +This module owns only the message and its delivery. It keeps no state and +makes no guarantee of its own about how often it is called; the caller is +responsible for invoking it once per run. Delivery is best-effort: a failure +to send is logged, never raised. + +Configured entirely by environment: + +``SENDGRID_API_KEY`` / ``NOTIFICATION_SENDER`` + Required; without both, nothing is sent. +``NOTIFICATION_OVERRIDE_EMAIL`` + Replaces the recipient. Keeps a development deployment from mailing real + people. + +The recipient alone is addressed -- no team copy -- since this is a personal +"your run is done" ping rather than a report. +""" + +from __future__ import annotations + +import asyncio +import logging +import os + +import markdown2 +import sendgrid +from sendgrid.helpers.mail import Content, From, HtmlContent, Mail, Subject, To + +from app.config import settings +from app.database.models import Run +from app.schemas import RunStatus + +log = logging.getLogger(__name__) + +_STATUS_WORDING = { + RunStatus.succeeded.value: "succeeded", + RunStatus.failed.value: "failed", + RunStatus.timed_out.value: "timed out", +} + + +def run_label(inputs: dict) -> str: + """Human-readable summary of a run's inputs, mirroring the UI's run list.""" + bug_id = inputs.get("bug_id") + if isinstance(bug_id, int): + return f"bug {bug_id}" + commit = inputs.get("git_commit") + if isinstance(commit, str) and commit.strip(): + return f"commit {commit.strip()[:12]}" + feature = inputs.get("feature_name") + if isinstance(feature, str) and feature.strip(): + return feature.strip() + return "" + + +def run_url(run_id: str) -> str: + return f"{settings.ui_base_url.rstrip('/')}/runs/{run_id}" + + +def build_message(run: Run) -> tuple[str, str]: + """(subject, markdown body) for a completion notice about ``run``.""" + outcome = _STATUS_WORDING.get(run.status, run.status) + label = run_label(run.inputs or {}) + what = f"{run.agent} on {label}" if label else run.agent + subject = f"[Hackbot] {what} {outcome}" + + lines = [ + f"Your Hackbot run **{what}** has **{outcome}**.", + "", + f"Open the run: {run_url(str(run.run_id))}", + ] + if run.error: + lines += ["", "Error:", "", "```", run.error, "```"] + lines += ["", "-- Hackbot"] + return subject, "\n".join(lines) + + +def _recipient(run: Run) -> str | None: + override = os.environ.get("NOTIFICATION_OVERRIDE_EMAIL", "").strip() + if override: + return override + return run.requested_by or None + + +def _send_sync(sender: str, recipient: str, subject: str, body_md: str) -> int: + message = Mail( + From(sender), + To(recipient), + Subject(subject), + Content("text/plain", body_md), + HtmlContent(markdown2.markdown(body_md, extras=["fenced-code-blocks"])), + ) + api_key = os.environ["SENDGRID_API_KEY"] + response = sendgrid.SendGridAPIClient(api_key=api_key).send(message=message) + return response.status_code + + +async def notify_requester(run: Run) -> bool: + """Mail the run's requester about its terminal state. Returns whether it sent. + + A run with no requester or missing SendGrid config is a quiet no-op; a + delivery failure is logged and swallowed (see module docstring). + """ + if not run.requested_by: + return False + + sender = os.environ.get("NOTIFICATION_SENDER", "").strip() + if not (os.environ.get("SENDGRID_API_KEY") and sender): + log.warning( + "SENDGRID_API_KEY / NOTIFICATION_SENDER not configured; " + "not notifying %s about run %s", + run.requested_by, + run.run_id, + ) + return False + + recipient = _recipient(run) + if not recipient: + return False + + subject, body = build_message(run) + try: + status_code = await asyncio.to_thread( + _send_sync, sender, recipient, subject, body + ) + except Exception: + log.exception("Failed to notify %s about run %s", recipient, run.run_id) + return False + log.info( + "Notified %s about run %s (%s): SendGrid %s", + recipient, + run.run_id, + run.status, + status_code, + ) + return True diff --git a/services/hackbot-api/app/routers/runs.py b/services/hackbot-api/app/routers/runs.py index 276cb54242..2859a51b44 100644 --- a/services/hackbot-api/app/routers/runs.py +++ b/services/hackbot-api/app/routers/runs.py @@ -10,7 +10,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app import gcs, jobs, pubsub +from app import gcs, jobs, notifications, pubsub from app.actions_applier import apply_all_pending from app.agents import AGENT_REGISTRY, AgentSpec, model_to_env from app.auth import require_api_key @@ -234,7 +234,7 @@ async def apply_run_actions( async def finalize_run(db: AsyncSession, run: Run) -> None: - """Bring `run` to its terminal state and publish RunCompleted, once. + """Bring `run` to its terminal state, publish RunCompleted and mail the requester, once. Invoked from the Eventarc-triggered agent-run-finished route instead of from a client request. Idempotent via `finalized_at`, since Eventarc's @@ -287,6 +287,7 @@ async def finalize_run(db: AsyncSession, run: Run) -> None: run.agent, ) await pubsub.publish_run_completed(str(run.run_id), run.agent, run.status) + await notifications.notify_requester(run) def _has_unsubmitted_patch( diff --git a/services/hackbot-api/tests/test_finalize_run.py b/services/hackbot-api/tests/test_finalize_run.py index 353981ee96..f557b47c02 100644 --- a/services/hackbot-api/tests/test_finalize_run.py +++ b/services/hackbot-api/tests/test_finalize_run.py @@ -10,7 +10,7 @@ from datetime import datetime, timezone import pytest -from app import gcs, jobs, pubsub +from app import gcs, jobs, notifications, pubsub from app.jobs import ExecutionStatus from app.routers import runs as runs_module from app.routers.runs import finalize_run @@ -48,6 +48,18 @@ async def fake_publish(run_id, agent, status): return published +@pytest.fixture(autouse=True) +def _no_notify(monkeypatch): + notified = [] + + async def fake_notify(run): + notified.append(run) + return True + + monkeypatch.setattr(notifications, "notify_requester", fake_notify) + return notified + + async def test_noop_when_already_finalized(monkeypatch): run = _FakeRun(finalized_at=datetime.now(timezone.utc)) db = _FakeDB() diff --git a/services/hackbot-api/tests/test_notifications.py b/services/hackbot-api/tests/test_notifications.py new file mode 100644 index 0000000000..077d218a73 --- /dev/null +++ b/services/hackbot-api/tests/test_notifications.py @@ -0,0 +1,98 @@ +"""Tests for the run-completion email to the requester (app/notifications.py).""" + +import uuid +from dataclasses import dataclass, field + +import pytest +from app import notifications +from app.notifications import build_message, notify_requester, run_label +from app.schemas import RunStatus + + +@dataclass +class _FakeRun: + run_id: uuid.UUID = field(default_factory=uuid.uuid4) + agent: str = "bug-fix" + status: str = RunStatus.succeeded.value + inputs: dict = field(default_factory=lambda: {"bug_id": 1234567}) + requested_by: str | None = "someone@mozilla.com" + error: str | None = None + + +@pytest.fixture +def sent(monkeypatch): + """Capture outgoing mail instead of hitting SendGrid.""" + calls = [] + + def fake_send(sender, recipient, subject, body_md): + calls.append((sender, recipient, subject, body_md)) + return 202 + + monkeypatch.setattr(notifications, "_send_sync", fake_send) + monkeypatch.setenv("SENDGRID_API_KEY", "sg-test") + monkeypatch.setenv("NOTIFICATION_SENDER", "hackbot@mozilla.com") + monkeypatch.delenv("NOTIFICATION_OVERRIDE_EMAIL", raising=False) + return calls + + +def test_run_label_mirrors_ui(): + assert run_label({"bug_id": 42}) == "bug 42" + assert run_label({"git_commit": "abcdef0123456789"}) == "commit abcdef012345" + assert run_label({"feature_name": " Tab groups "}) == "Tab groups" + assert run_label({}) == "" + + +def test_build_message_links_to_run_page(monkeypatch): + monkeypatch.setattr(notifications.settings, "ui_base_url", "https://ui.example/") + run = _FakeRun(status=RunStatus.timed_out.value) + subject, body = build_message(run) + assert subject == "[Hackbot] bug-fix on bug 1234567 timed out" + assert f"https://ui.example/runs/{run.run_id}" in body + assert "```" not in body + + +def test_build_message_includes_error(): + run = _FakeRun(status=RunStatus.failed.value, error="boom") + _, body = build_message(run) + assert "failed" in body + assert "boom" in body + + +async def test_sends_to_requester(sent): + run = _FakeRun() + assert await notify_requester(run) is True + assert len(sent) == 1 + sender, recipient, subject, _ = sent[0] + assert sender == "hackbot@mozilla.com" + assert recipient == "someone@mozilla.com" + assert subject.startswith("[Hackbot] bug-fix on bug 1234567") + + +async def test_skips_runs_without_requester(sent): + assert await notify_requester(_FakeRun(requested_by=None)) is False + assert sent == [] + + +async def test_override_email_replaces_recipient(sent, monkeypatch): + monkeypatch.setenv("NOTIFICATION_OVERRIDE_EMAIL", "dev@example.com") + assert await notify_requester(_FakeRun()) is True + assert sent[0][1] == "dev@example.com" + + +async def test_unconfigured_sendgrid_is_a_quiet_noop(sent, monkeypatch, caplog): + monkeypatch.delenv("SENDGRID_API_KEY") + assert await notify_requester(_FakeRun()) is False + assert sent == [] + assert "not configured" in caplog.text + + +async def test_send_failure_is_logged_not_raised(monkeypatch, caplog): + monkeypatch.setenv("SENDGRID_API_KEY", "sg-test") + monkeypatch.setenv("NOTIFICATION_SENDER", "hackbot@mozilla.com") + + def boom(*_a): + raise RuntimeError("sendgrid down") + + monkeypatch.setattr(notifications, "_send_sync", boom) + assert await notify_requester(_FakeRun()) is False + assert "Failed to notify" in caplog.text From 752b32eb7215c06fd376270f0bcbe2921d54de19 Mon Sep 17 00:00:00 2001 From: ayoubdiourin7 Date: Thu, 17 Sep 2026 19:28:46 +0200 Subject: [PATCH 02/12] Simplify run status wording in emails --- services/hackbot-api/app/notifications.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/services/hackbot-api/app/notifications.py b/services/hackbot-api/app/notifications.py index 64de95374e..977d8626e4 100644 --- a/services/hackbot-api/app/notifications.py +++ b/services/hackbot-api/app/notifications.py @@ -35,16 +35,9 @@ from app.config import settings from app.database.models import Run -from app.schemas import RunStatus log = logging.getLogger(__name__) -_STATUS_WORDING = { - RunStatus.succeeded.value: "succeeded", - RunStatus.failed.value: "failed", - RunStatus.timed_out.value: "timed out", -} - def run_label(inputs: dict) -> str: """Human-readable summary of a run's inputs, mirroring the UI's run list.""" @@ -66,7 +59,7 @@ def run_url(run_id: str) -> str: def build_message(run: Run) -> tuple[str, str]: """(subject, markdown body) for a completion notice about ``run``.""" - outcome = _STATUS_WORDING.get(run.status, run.status) + outcome = run.status.replace("_", " ") label = run_label(run.inputs or {}) what = f"{run.agent} on {label}" if label else run.agent subject = f"[Hackbot] {what} {outcome}" From 571ba6238794e78ec77b8522ff83e7b0187fb5ee Mon Sep 17 00:00:00 2001 From: ayoubdiourin7 Date: Thu, 17 Sep 2026 19:41:15 +0200 Subject: [PATCH 03/12] Remove input details from run completion emails --- services/hackbot-api/app/notifications.py | 37 +++++-------------- .../hackbot-api/tests/test_notifications.py | 23 ++---------- 2 files changed, 13 insertions(+), 47 deletions(-) diff --git a/services/hackbot-api/app/notifications.py b/services/hackbot-api/app/notifications.py index 977d8626e4..3e96737427 100644 --- a/services/hackbot-api/app/notifications.py +++ b/services/hackbot-api/app/notifications.py @@ -39,20 +39,6 @@ log = logging.getLogger(__name__) -def run_label(inputs: dict) -> str: - """Human-readable summary of a run's inputs, mirroring the UI's run list.""" - bug_id = inputs.get("bug_id") - if isinstance(bug_id, int): - return f"bug {bug_id}" - commit = inputs.get("git_commit") - if isinstance(commit, str) and commit.strip(): - return f"commit {commit.strip()[:12]}" - feature = inputs.get("feature_name") - if isinstance(feature, str) and feature.strip(): - return feature.strip() - return "" - - def run_url(run_id: str) -> str: return f"{settings.ui_base_url.rstrip('/')}/runs/{run_id}" @@ -60,19 +46,16 @@ def run_url(run_id: str) -> str: def build_message(run: Run) -> tuple[str, str]: """(subject, markdown body) for a completion notice about ``run``.""" outcome = run.status.replace("_", " ") - label = run_label(run.inputs or {}) - what = f"{run.agent} on {label}" if label else run.agent - subject = f"[Hackbot] {what} {outcome}" - - lines = [ - f"Your Hackbot run **{what}** has **{outcome}**.", - "", - f"Open the run: {run_url(str(run.run_id))}", - ] - if run.error: - lines += ["", "Error:", "", "```", run.error, "```"] - lines += ["", "-- Hackbot"] - return subject, "\n".join(lines) + subject = f"[Hackbot] {run.agent} run {outcome}" + + body = "\n".join( + [ + f"Your **{run.agent}** run has **{outcome}**.", + "", + f"Open the run: {run_url(str(run.run_id))}", + ] + ) + return subject, body def _recipient(run: Run) -> str | None: diff --git a/services/hackbot-api/tests/test_notifications.py b/services/hackbot-api/tests/test_notifications.py index 077d218a73..d81c22286c 100644 --- a/services/hackbot-api/tests/test_notifications.py +++ b/services/hackbot-api/tests/test_notifications.py @@ -5,7 +5,7 @@ import pytest from app import notifications -from app.notifications import build_message, notify_requester, run_label +from app.notifications import build_message, notify_requester from app.schemas import RunStatus @@ -14,9 +14,7 @@ class _FakeRun: run_id: uuid.UUID = field(default_factory=uuid.uuid4) agent: str = "bug-fix" status: str = RunStatus.succeeded.value - inputs: dict = field(default_factory=lambda: {"bug_id": 1234567}) requested_by: str | None = "someone@mozilla.com" - error: str | None = None @pytest.fixture @@ -35,27 +33,12 @@ def fake_send(sender, recipient, subject, body_md): return calls -def test_run_label_mirrors_ui(): - assert run_label({"bug_id": 42}) == "bug 42" - assert run_label({"git_commit": "abcdef0123456789"}) == "commit abcdef012345" - assert run_label({"feature_name": " Tab groups "}) == "Tab groups" - assert run_label({}) == "" - - def test_build_message_links_to_run_page(monkeypatch): monkeypatch.setattr(notifications.settings, "ui_base_url", "https://ui.example/") run = _FakeRun(status=RunStatus.timed_out.value) subject, body = build_message(run) - assert subject == "[Hackbot] bug-fix on bug 1234567 timed out" + assert subject == "[Hackbot] bug-fix run timed out" assert f"https://ui.example/runs/{run.run_id}" in body - assert "```" not in body - - -def test_build_message_includes_error(): - run = _FakeRun(status=RunStatus.failed.value, error="boom") - _, body = build_message(run) - assert "failed" in body - assert "boom" in body async def test_sends_to_requester(sent): @@ -65,7 +48,7 @@ async def test_sends_to_requester(sent): sender, recipient, subject, _ = sent[0] assert sender == "hackbot@mozilla.com" assert recipient == "someone@mozilla.com" - assert subject.startswith("[Hackbot] bug-fix on bug 1234567") + assert subject == "[Hackbot] bug-fix run succeeded" async def test_skips_runs_without_requester(sent): From 8ac83527cf7fc0868181f8c3c6134c86ddbaad30 Mon Sep 17 00:00:00 2001 From: ayoubdiourin7 Date: Thu, 17 Sep 2026 20:16:35 +0200 Subject: [PATCH 04/12] Move notification email configuration into app settings --- services/hackbot-api/app/config.py | 9 ++-- services/hackbot-api/app/notifications.py | 49 +++++++------------ .../hackbot-api/tests/test_notifications.py | 17 ++++--- 3 files changed, 32 insertions(+), 43 deletions(-) diff --git a/services/hackbot-api/app/config.py b/services/hackbot-api/app/config.py index 519afa8686..f4f90493b3 100644 --- a/services/hackbot-api/app/config.py +++ b/services/hackbot-api/app/config.py @@ -104,11 +104,12 @@ class Settings(BaseSettings): push_auth_audience: str = "" push_auth_service_account: str = "" - # Where the UI serves a run's page; completion emails link to - # `/runs/`. The mail itself is configured by the - # SENDGRID_API_KEY / NOTIFICATION_SENDER / NOTIFICATION_OVERRIDE_EMAIL env - # vars shared with hackbot-runtime's email handler (see app/notifications.py). + # Run-completion email to the requester (see app/notifications.py). The + # override replaces the recipient so a dev deployment never mails real people. ui_base_url: str = "http://localhost:3000" + sendgrid_api_key: str = "" + notification_sender: str = "" + notification_override_email: str = "" # Server port: int = 8080 diff --git a/services/hackbot-api/app/notifications.py b/services/hackbot-api/app/notifications.py index 3e96737427..c90d241dd3 100644 --- a/services/hackbot-api/app/notifications.py +++ b/services/hackbot-api/app/notifications.py @@ -1,33 +1,20 @@ """Email the person who requested a run when it reaches a terminal state. -A run launched from the UI carries its requester's email; once the run has -succeeded, failed or timed out, that one address gets a short note with the -outcome and a link to the run page, so nobody has to keep a tab open to watch -progress. Runs with no requester (those triggered by webhooks) are skipped: -their results already land where they were asked for. - -This module owns only the message and its delivery. It keeps no state and -makes no guarantee of its own about how often it is called; the caller is -responsible for invoking it once per run. Delivery is best-effort: a failure -to send is logged, never raised. - -Configured entirely by environment: - -``SENDGRID_API_KEY`` / ``NOTIFICATION_SENDER`` - Required; without both, nothing is sent. -``NOTIFICATION_OVERRIDE_EMAIL`` - Replaces the recipient. Keeps a development deployment from mailing real - people. - -The recipient alone is addressed -- no team copy -- since this is a personal -"your run is done" ping rather than a report. +A run launched from the UI carries its requester's email. Once the run has +succeeded, failed or timed out, that address gets a short note with the +outcome and a link to the run page, so nobody has to keep a tab open. Runs +with no requester (triggered by webhooks) are skipped. + +This module only composes and delivers the message. It keeps no state, so +calling it once per run is the caller's job. Delivery is best-effort: a +failed send is logged, never raised. Only the requester is addressed; this +is a personal ping, not a report. """ from __future__ import annotations import asyncio import logging -import os import markdown2 import sendgrid @@ -44,7 +31,7 @@ def run_url(run_id: str) -> str: def build_message(run: Run) -> tuple[str, str]: - """(subject, markdown body) for a completion notice about ``run``.""" + """Compose the subject and Markdown body of the notice for ``run``.""" outcome = run.status.replace("_", " ") subject = f"[Hackbot] {run.agent} run {outcome}" @@ -59,7 +46,7 @@ def build_message(run: Run) -> tuple[str, str]: def _recipient(run: Run) -> str | None: - override = os.environ.get("NOTIFICATION_OVERRIDE_EMAIL", "").strip() + override = settings.notification_override_email.strip() if override: return override return run.requested_by or None @@ -73,24 +60,24 @@ def _send_sync(sender: str, recipient: str, subject: str, body_md: str) -> int: Content("text/plain", body_md), HtmlContent(markdown2.markdown(body_md, extras=["fenced-code-blocks"])), ) - api_key = os.environ["SENDGRID_API_KEY"] - response = sendgrid.SendGridAPIClient(api_key=api_key).send(message=message) + client = sendgrid.SendGridAPIClient(api_key=settings.sendgrid_api_key) + response = client.send(message=message) return response.status_code async def notify_requester(run: Run) -> bool: """Mail the run's requester about its terminal state. Returns whether it sent. - A run with no requester or missing SendGrid config is a quiet no-op; a - delivery failure is logged and swallowed (see module docstring). + No requester or no SendGrid config is a quiet no-op; a delivery failure is + logged and swallowed. """ if not run.requested_by: return False - sender = os.environ.get("NOTIFICATION_SENDER", "").strip() - if not (os.environ.get("SENDGRID_API_KEY") and sender): + sender = settings.notification_sender.strip() + if not (settings.sendgrid_api_key and sender): log.warning( - "SENDGRID_API_KEY / NOTIFICATION_SENDER not configured; " + "sendgrid_api_key / notification_sender not configured; " "not notifying %s about run %s", run.requested_by, run.run_id, diff --git a/services/hackbot-api/tests/test_notifications.py b/services/hackbot-api/tests/test_notifications.py index d81c22286c..8e8dfc5c10 100644 --- a/services/hackbot-api/tests/test_notifications.py +++ b/services/hackbot-api/tests/test_notifications.py @@ -5,6 +5,7 @@ import pytest from app import notifications +from app.config import settings from app.notifications import build_message, notify_requester from app.schemas import RunStatus @@ -27,14 +28,14 @@ def fake_send(sender, recipient, subject, body_md): return 202 monkeypatch.setattr(notifications, "_send_sync", fake_send) - monkeypatch.setenv("SENDGRID_API_KEY", "sg-test") - monkeypatch.setenv("NOTIFICATION_SENDER", "hackbot@mozilla.com") - monkeypatch.delenv("NOTIFICATION_OVERRIDE_EMAIL", raising=False) + monkeypatch.setattr(settings, "sendgrid_api_key", "sg-test") + monkeypatch.setattr(settings, "notification_sender", "hackbot@mozilla.com") + monkeypatch.setattr(settings, "notification_override_email", "") return calls def test_build_message_links_to_run_page(monkeypatch): - monkeypatch.setattr(notifications.settings, "ui_base_url", "https://ui.example/") + monkeypatch.setattr(settings, "ui_base_url", "https://ui.example/") run = _FakeRun(status=RunStatus.timed_out.value) subject, body = build_message(run) assert subject == "[Hackbot] bug-fix run timed out" @@ -57,21 +58,21 @@ async def test_skips_runs_without_requester(sent): async def test_override_email_replaces_recipient(sent, monkeypatch): - monkeypatch.setenv("NOTIFICATION_OVERRIDE_EMAIL", "dev@example.com") + monkeypatch.setattr(settings, "notification_override_email", "dev@example.com") assert await notify_requester(_FakeRun()) is True assert sent[0][1] == "dev@example.com" async def test_unconfigured_sendgrid_is_a_quiet_noop(sent, monkeypatch, caplog): - monkeypatch.delenv("SENDGRID_API_KEY") + monkeypatch.setattr(settings, "sendgrid_api_key", "") assert await notify_requester(_FakeRun()) is False assert sent == [] assert "not configured" in caplog.text async def test_send_failure_is_logged_not_raised(monkeypatch, caplog): - monkeypatch.setenv("SENDGRID_API_KEY", "sg-test") - monkeypatch.setenv("NOTIFICATION_SENDER", "hackbot@mozilla.com") + monkeypatch.setattr(settings, "sendgrid_api_key", "sg-test") + monkeypatch.setattr(settings, "notification_sender", "hackbot@mozilla.com") def boom(*_a): raise RuntimeError("sendgrid down") From a3341bbe88613e0f93c23e2875e61e168012dcf0 Mon Sep 17 00:00:00 2001 From: ayoubdiourin7 Date: Thu, 17 Sep 2026 20:26:18 +0200 Subject: [PATCH 05/12] Simplify notification recipient selection --- services/hackbot-api/app/notifications.py | 31 ++++--------------- .../hackbot-api/tests/test_notifications.py | 16 +++------- 2 files changed, 10 insertions(+), 37 deletions(-) diff --git a/services/hackbot-api/app/notifications.py b/services/hackbot-api/app/notifications.py index c90d241dd3..6474f1c759 100644 --- a/services/hackbot-api/app/notifications.py +++ b/services/hackbot-api/app/notifications.py @@ -45,16 +45,13 @@ def build_message(run: Run) -> tuple[str, str]: return subject, body -def _recipient(run: Run) -> str | None: - override = settings.notification_override_email.strip() - if override: - return override - return run.requested_by or None +def _recipient(run: Run) -> str: + return settings.notification_override_email.strip() or run.requested_by -def _send_sync(sender: str, recipient: str, subject: str, body_md: str) -> int: +def _send_sync(recipient: str, subject: str, body_md: str) -> int: message = Mail( - From(sender), + From(settings.notification_sender), To(recipient), Subject(subject), Content("text/plain", body_md), @@ -68,31 +65,15 @@ def _send_sync(sender: str, recipient: str, subject: str, body_md: str) -> int: async def notify_requester(run: Run) -> bool: """Mail the run's requester about its terminal state. Returns whether it sent. - No requester or no SendGrid config is a quiet no-op; a delivery failure is - logged and swallowed. + No requester is a quiet no-op; a delivery failure is logged and swallowed. """ if not run.requested_by: return False - sender = settings.notification_sender.strip() - if not (settings.sendgrid_api_key and sender): - log.warning( - "sendgrid_api_key / notification_sender not configured; " - "not notifying %s about run %s", - run.requested_by, - run.run_id, - ) - return False - recipient = _recipient(run) - if not recipient: - return False - subject, body = build_message(run) try: - status_code = await asyncio.to_thread( - _send_sync, sender, recipient, subject, body - ) + status_code = await asyncio.to_thread(_send_sync, recipient, subject, body) except Exception: log.exception("Failed to notify %s about run %s", recipient, run.run_id) return False diff --git a/services/hackbot-api/tests/test_notifications.py b/services/hackbot-api/tests/test_notifications.py index 8e8dfc5c10..dc8088e6aa 100644 --- a/services/hackbot-api/tests/test_notifications.py +++ b/services/hackbot-api/tests/test_notifications.py @@ -23,8 +23,8 @@ def sent(monkeypatch): """Capture outgoing mail instead of hitting SendGrid.""" calls = [] - def fake_send(sender, recipient, subject, body_md): - calls.append((sender, recipient, subject, body_md)) + def fake_send(recipient, subject, body_md): + calls.append((recipient, subject, body_md)) return 202 monkeypatch.setattr(notifications, "_send_sync", fake_send) @@ -46,8 +46,7 @@ async def test_sends_to_requester(sent): run = _FakeRun() assert await notify_requester(run) is True assert len(sent) == 1 - sender, recipient, subject, _ = sent[0] - assert sender == "hackbot@mozilla.com" + recipient, subject, _ = sent[0] assert recipient == "someone@mozilla.com" assert subject == "[Hackbot] bug-fix run succeeded" @@ -60,14 +59,7 @@ async def test_skips_runs_without_requester(sent): async def test_override_email_replaces_recipient(sent, monkeypatch): monkeypatch.setattr(settings, "notification_override_email", "dev@example.com") assert await notify_requester(_FakeRun()) is True - assert sent[0][1] == "dev@example.com" - - -async def test_unconfigured_sendgrid_is_a_quiet_noop(sent, monkeypatch, caplog): - monkeypatch.setattr(settings, "sendgrid_api_key", "") - assert await notify_requester(_FakeRun()) is False - assert sent == [] - assert "not configured" in caplog.text + assert sent[0][0] == "dev@example.com" async def test_send_failure_is_logged_not_raised(monkeypatch, caplog): From f861f034c323d2851809be83c4fcee3b67febab0 Mon Sep 17 00:00:00 2001 From: ayoubdiourin7 Date: Thu, 17 Sep 2026 20:43:17 +0200 Subject: [PATCH 06/12] add a timeout --- services/hackbot-api/app/notifications.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/services/hackbot-api/app/notifications.py b/services/hackbot-api/app/notifications.py index 6474f1c759..067f732822 100644 --- a/services/hackbot-api/app/notifications.py +++ b/services/hackbot-api/app/notifications.py @@ -25,6 +25,9 @@ log = logging.getLogger(__name__) +# A a SendGrid call must not hold finalization open. +_SEND_TIMEOUT_SECONDS = 10 + def run_url(run_id: str) -> str: return f"{settings.ui_base_url.rstrip('/')}/runs/{run_id}" @@ -58,6 +61,8 @@ def _send_sync(recipient: str, subject: str, body_md: str) -> int: HtmlContent(markdown2.markdown(body_md, extras=["fenced-code-blocks"])), ) client = sendgrid.SendGridAPIClient(api_key=settings.sendgrid_api_key) + # The SendGrid wrapper has no timeout option; its HTTP client does. + client.client.timeout = _SEND_TIMEOUT_SECONDS response = client.send(message=message) return response.status_code From 87a177bd48d7a60fc0913af2c1a48e76314d507a Mon Sep 17 00:00:00 2001 From: ayoubdiourin7 Date: Thu, 17 Sep 2026 22:26:58 +0200 Subject: [PATCH 07/12] Move requester emails to the run completed event handler --- services/hackbot-api/app/routers/events.py | 25 ++++++++++-- services/hackbot-api/app/routers/runs.py | 5 +-- services/hackbot-api/tests/test_events.py | 44 ++++++++++++++++++++++ 3 files changed, 68 insertions(+), 6 deletions(-) diff --git a/services/hackbot-api/app/routers/events.py b/services/hackbot-api/app/routers/events.py index d46da7ab6d..25bf113bb9 100644 --- a/services/hackbot-api/app/routers/events.py +++ b/services/hackbot-api/app/routers/events.py @@ -7,6 +7,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app import notifications from app.actions_applier import on_run_completed from app.auth import require_push_auth from app.database.connection import get_db @@ -24,9 +25,8 @@ def _decode_pubsub_push_body(body: dict) -> dict: """Decode a standard Pub/Sub push envelope's `message.data` as JSON. - Both the completion-log push subscription feeding agent-run-finished and the - `agent-run-events` action-applier subscription deliver via this same - envelope shape. + The completion-log, action-applier and requester-notification subscriptions + deliver via this same envelope shape. """ message = body.get("message") or {} data = message.get("data") @@ -135,3 +135,22 @@ async def apply_run_actions( return await on_run_completed(db, run) + + +@router.post("/notify-requester", status_code=204) +async def notify_requester( + request: Request, db: AsyncSession = Depends(get_db) +) -> None: + """Consumer of `run.completed`: email the run's requester. + + Its own subscription includes all terminal outcomes. + """ + event = _decode_pubsub_push_body(await request.json()) + run_id = event["run_id"] + + run = await db.get(Run, uuid.UUID(run_id)) + if run is None: + log.warning("No run found for run_id %s", run_id) + return + + await notifications.notify_requester(run) diff --git a/services/hackbot-api/app/routers/runs.py b/services/hackbot-api/app/routers/runs.py index cd7136763c..cbdf2a726c 100644 --- a/services/hackbot-api/app/routers/runs.py +++ b/services/hackbot-api/app/routers/runs.py @@ -11,7 +11,7 @@ from sqlalchemy.dialects.postgresql import insert as pg_insert from sqlalchemy.ext.asyncio import AsyncSession -from app import gcs, jobs, notifications, pubsub +from app import gcs, jobs, pubsub from app.action_handlers.registry import PATCH_ACTION_TYPES from app.actions_applier import apply_all_pending from app.agents import AGENT_REGISTRY, AgentSpec, model_to_env @@ -310,7 +310,7 @@ async def apply_run_actions( async def finalize_run(db: AsyncSession, run: Run) -> None: - """Bring `run` to its terminal state, publish RunCompleted and mail the requester, once. + """Bring `run` to its terminal state and publish RunCompleted, once. Invoked from the Eventarc-triggered agent-run-finished route instead of from a client request. Idempotent via `finalized_at`, since Eventarc's @@ -363,7 +363,6 @@ async def finalize_run(db: AsyncSession, run: Run) -> None: run.agent, ) await pubsub.publish_run_completed(str(run.run_id), run.agent, run.status) - await notifications.notify_requester(run) def _has_unsubmitted_patch( diff --git a/services/hackbot-api/tests/test_events.py b/services/hackbot-api/tests/test_events.py index 214ca8e912..2b1ed1a067 100644 --- a/services/hackbot-api/tests/test_events.py +++ b/services/hackbot-api/tests/test_events.py @@ -6,7 +6,13 @@ import base64 import json +import uuid +from types import SimpleNamespace +import pytest +from app import notifications +from app.auth import require_push_auth +from app.database.models import Run from app.routers.events import ( _decode_pubsub_push_body, _execution_name_from_completion_log, @@ -73,3 +79,41 @@ def test_execution_name_falls_back_to_labels(): def test_execution_name_missing(): assert _execution_name_from_completion_log({"protoPayload": {}}) is None assert _execution_name_from_completion_log({}) is None + + +@pytest.mark.parametrize("status", ["succeeded", "failed", "timed_out"]) +def test_notify_requester_consumes_completed_event(client, db, monkeypatch, status): + run = SimpleNamespace(run_id=uuid.uuid4(), status=status) + notified = [] + + async def get(model, key): + assert model is Run + assert key == run.run_id + return run + + async def notify(value): + notified.append(value) + return True + + monkeypatch.setattr(db, "get", get) + monkeypatch.setattr(notifications, "notify_requester", notify) + client.app.dependency_overrides[require_push_auth] = lambda: None + response = client.post( + "/internal/events/notify-requester", + json=_push_envelope({"run_id": str(run.run_id), "status": status}), + ) + assert response.status_code == 204 + assert notified == [run] + + +def test_notify_requester_skips_missing_run(client, monkeypatch): + async def notify(run): + pytest.fail("No email should be sent without a run") + + monkeypatch.setattr(notifications, "notify_requester", notify) + client.app.dependency_overrides[require_push_auth] = lambda: None + response = client.post( + "/internal/events/notify-requester", + json=_push_envelope({"run_id": str(uuid.uuid4())}), + ) + assert response.status_code == 204 From 914b09ba431e1880a18810c54de46587c7c72ff9 Mon Sep 17 00:00:00 2001 From: ayoubdiourin7 Date: Thu, 17 Sep 2026 22:35:14 +0200 Subject: [PATCH 08/12] Remove unnecessary future annotations import --- services/hackbot-api/app/notifications.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/services/hackbot-api/app/notifications.py b/services/hackbot-api/app/notifications.py index 067f732822..e8fb0af01b 100644 --- a/services/hackbot-api/app/notifications.py +++ b/services/hackbot-api/app/notifications.py @@ -11,8 +11,6 @@ is a personal ping, not a report. """ -from __future__ import annotations - import asyncio import logging From 839d7966caf6bcfc92245ef92916b4b6805c11a3 Mon Sep 17 00:00:00 2001 From: ayoubdiourin7 Date: Thu, 17 Sep 2026 22:50:01 +0200 Subject: [PATCH 09/12] Include run and bug IDs in completion emails --- services/hackbot-api/app/notifications.py | 8 ++++++-- services/hackbot-api/tests/test_notifications.py | 16 ++++++++++++++-- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/services/hackbot-api/app/notifications.py b/services/hackbot-api/app/notifications.py index e8fb0af01b..02e454f875 100644 --- a/services/hackbot-api/app/notifications.py +++ b/services/hackbot-api/app/notifications.py @@ -34,11 +34,15 @@ def run_url(run_id: str) -> str: def build_message(run: Run) -> tuple[str, str]: """Compose the subject and Markdown body of the notice for ``run``.""" outcome = run.status.replace("_", " ") - subject = f"[Hackbot] {run.agent} run {outcome}" + label = f"{run.agent} run {str(run.run_id)[:8]}" + bug_id = run.inputs.get("bug_id") + if bug_id is not None: + label += f" for bug {bug_id}" + subject = f"[Hackbot] {label} {outcome}" body = "\n".join( [ - f"Your **{run.agent}** run has **{outcome}**.", + f"Your **{label}** has **{outcome}**.", "", f"Open the run: {run_url(str(run.run_id))}", ] diff --git a/services/hackbot-api/tests/test_notifications.py b/services/hackbot-api/tests/test_notifications.py index dc8088e6aa..d2ea5dd137 100644 --- a/services/hackbot-api/tests/test_notifications.py +++ b/services/hackbot-api/tests/test_notifications.py @@ -16,6 +16,7 @@ class _FakeRun: agent: str = "bug-fix" status: str = RunStatus.succeeded.value requested_by: str | None = "someone@mozilla.com" + inputs: dict = field(default_factory=dict) @pytest.fixture @@ -38,7 +39,8 @@ def test_build_message_links_to_run_page(monkeypatch): monkeypatch.setattr(settings, "ui_base_url", "https://ui.example/") run = _FakeRun(status=RunStatus.timed_out.value) subject, body = build_message(run) - assert subject == "[Hackbot] bug-fix run timed out" + assert subject == f"[Hackbot] bug-fix run {str(run.run_id)[:8]} timed out" + assert f"bug-fix run {str(run.run_id)[:8]}" in body assert f"https://ui.example/runs/{run.run_id}" in body @@ -48,7 +50,17 @@ async def test_sends_to_requester(sent): assert len(sent) == 1 recipient, subject, _ = sent[0] assert recipient == "someone@mozilla.com" - assert subject == "[Hackbot] bug-fix run succeeded" + assert subject == f"[Hackbot] bug-fix run {str(run.run_id)[:8]} succeeded" + + +def test_build_message_includes_bug_id(): + run = _FakeRun( + run_id=uuid.UUID("ab603010-c278-4d55-bc29-f89463f78906"), + inputs={"bug_id": 123456}, + ) + subject, body = build_message(run) + assert subject == "[Hackbot] bug-fix run ab603010 for bug 123456 succeeded" + assert "bug-fix run ab603010 for bug 123456" in body async def test_skips_runs_without_requester(sent): From 3b314e2a592d0b4a3e8752556f366734c7041490 Mon Sep 17 00:00:00 2001 From: ayoubdiourin7 Date: Thu, 17 Sep 2026 23:26:08 +0200 Subject: [PATCH 10/12] Use an HTML template for run completion emails --- services/hackbot-api/app/notifications.py | 32 +++++++++---------- .../app/templates/run_completed.html | 7 ++++ .../hackbot-api/tests/test_notifications.py | 16 ++++++---- 3 files changed, 31 insertions(+), 24 deletions(-) create mode 100644 services/hackbot-api/app/templates/run_completed.html diff --git a/services/hackbot-api/app/notifications.py b/services/hackbot-api/app/notifications.py index 02e454f875..45d3642a89 100644 --- a/services/hackbot-api/app/notifications.py +++ b/services/hackbot-api/app/notifications.py @@ -13,26 +13,30 @@ import asyncio import logging +from pathlib import Path +from string import Template -import markdown2 import sendgrid -from sendgrid.helpers.mail import Content, From, HtmlContent, Mail, Subject, To +from sendgrid.helpers.mail import From, HtmlContent, Mail, Subject, To from app.config import settings from app.database.models import Run log = logging.getLogger(__name__) -# A a SendGrid call must not hold finalization open. +# A stalled SendGrid call must not hold finalization open. _SEND_TIMEOUT_SECONDS = 10 +_TEMPLATES = Path(__file__).parent / "templates" +_HTML_TEMPLATE = Template((_TEMPLATES / "run_completed.html").read_text()) + def run_url(run_id: str) -> str: return f"{settings.ui_base_url.rstrip('/')}/runs/{run_id}" def build_message(run: Run) -> tuple[str, str]: - """Compose the subject and Markdown body of the notice for ``run``.""" + """Compose the subject and HTML body of the notice for ``run``.""" outcome = run.status.replace("_", " ") label = f"{run.agent} run {str(run.run_id)[:8]}" bug_id = run.inputs.get("bug_id") @@ -40,27 +44,21 @@ def build_message(run: Run) -> tuple[str, str]: label += f" for bug {bug_id}" subject = f"[Hackbot] {label} {outcome}" - body = "\n".join( - [ - f"Your **{label}** has **{outcome}**.", - "", - f"Open the run: {run_url(str(run.run_id))}", - ] - ) - return subject, body + values = {"label": label, "outcome": outcome, "url": run_url(str(run.run_id))} + html_body = _HTML_TEMPLATE.substitute(values) + return subject, html_body def _recipient(run: Run) -> str: return settings.notification_override_email.strip() or run.requested_by -def _send_sync(recipient: str, subject: str, body_md: str) -> int: +def _send_sync(recipient: str, subject: str, html_body: str) -> int: message = Mail( From(settings.notification_sender), To(recipient), Subject(subject), - Content("text/plain", body_md), - HtmlContent(markdown2.markdown(body_md, extras=["fenced-code-blocks"])), + html_content=HtmlContent(html_body), ) client = sendgrid.SendGridAPIClient(api_key=settings.sendgrid_api_key) # The SendGrid wrapper has no timeout option; its HTTP client does. @@ -78,9 +76,9 @@ async def notify_requester(run: Run) -> bool: return False recipient = _recipient(run) - subject, body = build_message(run) + subject, html_body = build_message(run) try: - status_code = await asyncio.to_thread(_send_sync, recipient, subject, body) + status_code = await asyncio.to_thread(_send_sync, recipient, subject, html_body) except Exception: log.exception("Failed to notify %s about run %s", recipient, run.run_id) return False diff --git a/services/hackbot-api/app/templates/run_completed.html b/services/hackbot-api/app/templates/run_completed.html new file mode 100644 index 0000000000..a6a765b237 --- /dev/null +++ b/services/hackbot-api/app/templates/run_completed.html @@ -0,0 +1,7 @@ + + + +

Your $label has $outcome.

+

Open the run

+ + diff --git a/services/hackbot-api/tests/test_notifications.py b/services/hackbot-api/tests/test_notifications.py index d2ea5dd137..684efe8f2e 100644 --- a/services/hackbot-api/tests/test_notifications.py +++ b/services/hackbot-api/tests/test_notifications.py @@ -24,8 +24,8 @@ def sent(monkeypatch): """Capture outgoing mail instead of hitting SendGrid.""" calls = [] - def fake_send(recipient, subject, body_md): - calls.append((recipient, subject, body_md)) + def fake_send(recipient, subject, html_body): + calls.append((recipient, subject, html_body)) return 202 monkeypatch.setattr(notifications, "_send_sync", fake_send) @@ -38,10 +38,12 @@ def fake_send(recipient, subject, body_md): def test_build_message_links_to_run_page(monkeypatch): monkeypatch.setattr(settings, "ui_base_url", "https://ui.example/") run = _FakeRun(status=RunStatus.timed_out.value) - subject, body = build_message(run) + subject, html_body = build_message(run) + url = f"https://ui.example/runs/{run.run_id}" assert subject == f"[Hackbot] bug-fix run {str(run.run_id)[:8]} timed out" - assert f"bug-fix run {str(run.run_id)[:8]}" in body - assert f"https://ui.example/runs/{run.run_id}" in body + assert f"bug-fix run {str(run.run_id)[:8]}" in html_body + assert f'' in html_body + assert "timed out" in html_body async def test_sends_to_requester(sent): @@ -58,9 +60,9 @@ def test_build_message_includes_bug_id(): run_id=uuid.UUID("ab603010-c278-4d55-bc29-f89463f78906"), inputs={"bug_id": 123456}, ) - subject, body = build_message(run) + subject, html_body = build_message(run) assert subject == "[Hackbot] bug-fix run ab603010 for bug 123456 succeeded" - assert "bug-fix run ab603010 for bug 123456" in body + assert "bug-fix run ab603010 for bug 123456" in html_body async def test_skips_runs_without_requester(sent): From 4b03f3ad90b91b1477ec65a5d5079eddb0d27dbb Mon Sep 17 00:00:00 2001 From: ayoubdiourin7 Date: Thu, 17 Sep 2026 23:36:35 +0200 Subject: [PATCH 11/12] Move requester check into the event handler --- services/hackbot-api/app/notifications.py | 8 +------ services/hackbot-api/app/routers/events.py | 3 ++- services/hackbot-api/tests/test_events.py | 23 ++++++++++++++++++- .../hackbot-api/tests/test_notifications.py | 5 ---- 4 files changed, 25 insertions(+), 14 deletions(-) diff --git a/services/hackbot-api/app/notifications.py b/services/hackbot-api/app/notifications.py index 45d3642a89..41bf444210 100644 --- a/services/hackbot-api/app/notifications.py +++ b/services/hackbot-api/app/notifications.py @@ -68,13 +68,7 @@ def _send_sync(recipient: str, subject: str, html_body: str) -> int: async def notify_requester(run: Run) -> bool: - """Mail the run's requester about its terminal state. Returns whether it sent. - - No requester is a quiet no-op; a delivery failure is logged and swallowed. - """ - if not run.requested_by: - return False - + """Mail the run's requester about its terminal state. Returns whether it sent.""" recipient = _recipient(run) subject, html_body = build_message(run) try: diff --git a/services/hackbot-api/app/routers/events.py b/services/hackbot-api/app/routers/events.py index 25bf113bb9..a31935a494 100644 --- a/services/hackbot-api/app/routers/events.py +++ b/services/hackbot-api/app/routers/events.py @@ -153,4 +153,5 @@ async def notify_requester( log.warning("No run found for run_id %s", run_id) return - await notifications.notify_requester(run) + if run.requested_by: + await notifications.notify_requester(run) diff --git a/services/hackbot-api/tests/test_events.py b/services/hackbot-api/tests/test_events.py index 2b1ed1a067..336e01a8a7 100644 --- a/services/hackbot-api/tests/test_events.py +++ b/services/hackbot-api/tests/test_events.py @@ -83,7 +83,9 @@ def test_execution_name_missing(): @pytest.mark.parametrize("status", ["succeeded", "failed", "timed_out"]) def test_notify_requester_consumes_completed_event(client, db, monkeypatch, status): - run = SimpleNamespace(run_id=uuid.uuid4(), status=status) + run = SimpleNamespace( + run_id=uuid.uuid4(), status=status, requested_by="someone@mozilla.com" + ) notified = [] async def get(model, key): @@ -117,3 +119,22 @@ async def notify(run): json=_push_envelope({"run_id": str(uuid.uuid4())}), ) assert response.status_code == 204 + + +def test_notify_requester_skips_run_without_requester(client, db, monkeypatch): + run = SimpleNamespace(run_id=uuid.uuid4(), requested_by=None) + + async def get(model, key): + return run + + async def notify(value): + pytest.fail("No email should be sent without a requester") + + monkeypatch.setattr(db, "get", get) + monkeypatch.setattr(notifications, "notify_requester", notify) + client.app.dependency_overrides[require_push_auth] = lambda: None + response = client.post( + "/internal/events/notify-requester", + json=_push_envelope({"run_id": str(run.run_id)}), + ) + assert response.status_code == 204 diff --git a/services/hackbot-api/tests/test_notifications.py b/services/hackbot-api/tests/test_notifications.py index 684efe8f2e..20ecb359c4 100644 --- a/services/hackbot-api/tests/test_notifications.py +++ b/services/hackbot-api/tests/test_notifications.py @@ -65,11 +65,6 @@ def test_build_message_includes_bug_id(): assert "bug-fix run ab603010 for bug 123456" in html_body -async def test_skips_runs_without_requester(sent): - assert await notify_requester(_FakeRun(requested_by=None)) is False - assert sent == [] - - async def test_override_email_replaces_recipient(sent, monkeypatch): monkeypatch.setattr(settings, "notification_override_email", "dev@example.com") assert await notify_requester(_FakeRun()) is True From 2a05123628b1bf792d3434893f90b15803471421 Mon Sep 17 00:00:00 2001 From: ayoubdiourin7 Date: Thu, 17 Sep 2026 23:41:27 +0200 Subject: [PATCH 12/12] Rename UI URL setting to hackbot_ui_url for consistency --- services/hackbot-api/app/config.py | 2 +- services/hackbot-api/app/notifications.py | 2 +- services/hackbot-api/tests/test_notifications.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/services/hackbot-api/app/config.py b/services/hackbot-api/app/config.py index f4f90493b3..013517992a 100644 --- a/services/hackbot-api/app/config.py +++ b/services/hackbot-api/app/config.py @@ -106,7 +106,7 @@ class Settings(BaseSettings): # Run-completion email to the requester (see app/notifications.py). The # override replaces the recipient so a dev deployment never mails real people. - ui_base_url: str = "http://localhost:3000" + hackbot_ui_url: str = "http://localhost:3000" sendgrid_api_key: str = "" notification_sender: str = "" notification_override_email: str = "" diff --git a/services/hackbot-api/app/notifications.py b/services/hackbot-api/app/notifications.py index 41bf444210..9d6c43d039 100644 --- a/services/hackbot-api/app/notifications.py +++ b/services/hackbot-api/app/notifications.py @@ -32,7 +32,7 @@ def run_url(run_id: str) -> str: - return f"{settings.ui_base_url.rstrip('/')}/runs/{run_id}" + return f"{settings.hackbot_ui_url.rstrip('/')}/runs/{run_id}" def build_message(run: Run) -> tuple[str, str]: diff --git a/services/hackbot-api/tests/test_notifications.py b/services/hackbot-api/tests/test_notifications.py index 20ecb359c4..935828829a 100644 --- a/services/hackbot-api/tests/test_notifications.py +++ b/services/hackbot-api/tests/test_notifications.py @@ -36,7 +36,7 @@ def fake_send(recipient, subject, html_body): def test_build_message_links_to_run_page(monkeypatch): - monkeypatch.setattr(settings, "ui_base_url", "https://ui.example/") + monkeypatch.setattr(settings, "hackbot_ui_url", "https://ui.example/") run = _FakeRun(status=RunStatus.timed_out.value) subject, html_body = build_message(run) url = f"https://ui.example/runs/{run.run_id}"