open-swe/tests/test_jira_webhook_author.py
Adam Moussa b3fc62da80
refactor: split webapp.py into api/ + per-source webhook routes
Plan step C4 (docs/upstream-sync/domain-reorg/reorg-build-plan.md, approved
decisions 1-2): split the 2,590-line agent/webapp.py monolith into
agent/webhooks/common.py (shared verify/dispatch helpers), agent/api/app.py
(composition), agent/api/health.py (/health + /webhooks/run-complete), and
per-source {github,linear,slack,jira,confluence}_routes.py. Atlassian
Connect lifecycle + descriptor routes (/connect/*) fold into
confluence_routes.py; webapp.py becomes the upstream-shaped compatibility
shim (from .api.app import app). langgraph.json http.app stays
agent.webapp:app via the shim.

Fork content, upstream layout: linear/slack route files verified
content-identical to upstream 8356eb34 and taken verbatim; github_routes is
upstream + the fork's CI auto-fix trigger wiring; jira/confluence routes are
fork-only, transformed to the same common.X / service.X module-attribute
style. All signature verification (GitHub HMAC, Slack, Linear
timestamp-freshness, verify_jira_secret + opt-in HMAC/timestamp/IP
allowlist, Connect JWT/qsh), token-attribution gating, TID-COLLIDE-01 repo
binding, _is_repo_auto_review_enabled gates, and public-repo org gate move
unchanged.

Handlers rewired from webapp.X to common.X; test monkeypatch sites across
26 files + conftest.py + e2e/harness.py retargeted to
webhook_common/handler/route modules per upstream's pattern. Residual
agent.webapp importers: only the shim, langgraph.json http.app, Makefile
uvicorn target, and docs (doc-path updates land in C7).

Gates: ruff check + format, pytest --co, full unit (1637 passed), full
Playwright E2E vs real langgraph dev (9/9), residual-importer sweep.
2026-07-17 14:30:05 -04:00

196 lines
6.4 KiB
Python

"""Tests for Jira webhook PR author linking and repo-mapping cascade."""
from __future__ import annotations
import asyncio
from typing import Any
from unittest.mock import AsyncMock, patch
from agent.webhooks import common as webhook_common
from agent.webhooks import jira as jira_webhook
def _full_issue(*, title: str = "Fix the flaky test") -> dict:
return {
"key": "PROJ-42",
"id": "10001",
"title": title,
"description": "Do the thing",
"url": "https://seahaven.atlassian.net/browse/PROJ-42",
"project_key": "PROJ",
}
def _issue_data(*, account_id: str | None, email: str | None, name: str = "Jane") -> dict:
# jira_webhook resolves account_id -> email once and attaches it to
# comment_author before dispatch (see jira_routes.jira_webhook).
return {
"key": "PROJ-42",
"project_key": "PROJ",
"triggering_comment": "@openswe fix this",
"triggering_comment_id": "10050",
"comment_author": {"account_id": account_id, "email": email, "name": name},
}
def _run_process(
issue_data: dict,
repo_config: dict[str, str],
*,
active_logins: set[str] | None = None,
) -> tuple[dict, dict, str | None]:
captured: dict[str, Any] = {}
active = {"jane"} if active_logins is None else active_logins
async def fake_dispatch(
thread_id, content, configurable, *, source, metadata=None, client=None
):
captured["configurable"] = configurable
return {"run_id": "run-1"}
async def fake_upsert(
thread_id,
*,
source,
repo_config=None,
github_login="",
user_email="",
title="",
source_context=None,
):
captured["upsert"] = {"github_login": github_login, "user_email": user_email}
return None
async def fake_resolve_login(email):
captured["resolved_email"] = email
return "jane" if email == "jane@example.com" else None
with (
patch.object(
webhook_common,
"generate_thread_id_from_jira_issue",
return_value="thread-1",
),
patch.object(
webhook_common,
"fetch_jira_issue_details",
new_callable=AsyncMock,
return_value=_full_issue(),
),
patch.object(
webhook_common,
"fetch_jira_issue_comments",
new_callable=AsyncMock,
return_value=[],
),
patch.object(
webhook_common,
"resolve_login_from_email_async",
side_effect=fake_resolve_login,
),
patch.object(
webhook_common,
"is_login_mapped",
side_effect=lambda login: login in active,
),
patch.object(webhook_common, "dispatch_agent_run", side_effect=fake_dispatch),
patch.object(
webhook_common,
"upsert_agent_thread_owner_metadata",
side_effect=fake_upsert,
),
patch.object(webhook_common, "post_jira_trace_comment", new_callable=AsyncMock),
):
asyncio.run(jira_webhook.process_jira_issue(issue_data, repo_config))
return (
captured.get("configurable", {}),
captured.get("upsert", {}),
captured.get("resolved_email"),
)
def test_jira_configurable_carries_github_login() -> None:
configurable, _upsert, resolved_email = _run_process(
_issue_data(account_id="acc-1", email="jane@example.com"),
{"owner": "langchain-ai", "name": "open-swe"},
)
assert resolved_email == "jane@example.com"
assert configurable["source"] == "jira"
assert configurable["github_login"] == "jane"
assert configurable["user_email"] == "jane@example.com"
assert configurable["jira_issue"]["key"] == "PROJ-42"
assert configurable["jira_issue"]["project_key"] == "PROJ"
assert configurable["jira_issue"]["issue_number"] == "42"
def test_jira_upsert_tags_thread_with_login() -> None:
_configurable, upsert, _email = _run_process(
_issue_data(account_id="acc-1", email="jane@example.com"),
{"owner": "langchain-ai", "name": "open-swe"},
)
assert upsert["github_login"] == "jane"
assert upsert["user_email"] == "jane@example.com"
def test_jira_omits_login_when_unmapped() -> None:
configurable, upsert, resolved_email = _run_process(
_issue_data(account_id="acc-2", email="nobody@example.com"),
{"owner": "langchain-ai", "name": "open-swe"},
)
assert resolved_email == "nobody@example.com"
assert "github_login" not in configurable
assert upsert["github_login"] == ""
def test_jira_omits_login_when_mapping_not_active() -> None:
# A resolvable email whose mapping is pending/inactive must not be attributed.
configurable, upsert, resolved_email = _run_process(
_issue_data(account_id="acc-1", email="jane@example.com"),
{"owner": "langchain-ai", "name": "open-swe"},
active_logins=set(),
)
assert resolved_email == "jane@example.com"
assert "github_login" not in configurable
assert upsert["github_login"] == ""
def test_jira_omits_login_when_no_email_resolved() -> None:
configurable, upsert, resolved_email = _run_process(
_issue_data(account_id=None, email=None),
{"owner": "langchain-ai", "name": "open-swe"},
)
assert resolved_email is None
assert "github_login" not in configurable
assert upsert["github_login"] == ""
def test_repo_cascade_uses_project_mapping(monkeypatch) -> None:
monkeypatch.setattr(
webhook_common, "JIRA_PROJECT_TO_REPO", {"PROJ": {"owner": "acme", "name": "widgets"}}
)
assert webhook_common.get_repo_config_from_jira_mapping("PROJ") == {
"owner": "acme",
"name": "widgets",
}
def test_repo_cascade_falls_back_to_default_repo(monkeypatch) -> None:
monkeypatch.setattr(webhook_common, "JIRA_PROJECT_TO_REPO", {})
monkeypatch.setattr(webhook_common, "DEFAULT_REPO_OWNER", "langchain-ai")
monkeypatch.setattr(webhook_common, "DEFAULT_REPO_NAME", "open-swe")
assert webhook_common.get_repo_config_from_jira_mapping("UNKNOWN") == {
"owner": "langchain-ai",
"name": "open-swe",
}
def test_repo_cascade_empty_without_default(monkeypatch) -> None:
monkeypatch.setattr(webhook_common, "JIRA_PROJECT_TO_REPO", {})
monkeypatch.setattr(webhook_common, "DEFAULT_REPO_NAME", "")
assert webhook_common.get_repo_config_from_jira_mapping("UNKNOWN") == {}