open-swe/agent/webhooks/jira_routes.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

182 lines
7.2 KiB
Python

"""Jira webhook HTTP routes."""
from fastapi import APIRouter
from . import common
from . import jira as service
router = APIRouter()
@router.post("/webhooks/jira")
async def jira_webhook( # noqa: PLR0911, PLR0912
request: common.Request, background_tasks: common.BackgroundTasks
) -> dict[str, str]:
"""Handle Jira Automation webhooks.
Triggers a new LangGraph run when a comment mentioning ``@openswe`` is
added to an issue. Unlike Linear, Jira Cloud has no native outgoing-webhook
signing, so this is fronted by a Jira **Automation** rule (trigger:
"Issue commented") with a "Send web request" action posting a custom JSON
body to this route, carrying the shared-secret token in
``X-Automation-Webhook-Token``.
Expected payload (the Automation rule's custom JSON body, built from smart
values)::
{
"issue_key": "PROJ-123",
"comment_id": "10050",
"comment_author_is_bot": false
}
``issue_key`` (validated against the Jira key format) and ``comment_id`` are
**required** — they are the only fields trusted from the unsigned body, and
only as a pointer. The triggering comment's real author and text are then
re-fetched from Jira server-side (``fetch_jira_comment``) and everything
security-relevant (identity/attribution, the ``@openswe`` trigger check, the
prompt text, repo routing) is derived from that authoritative record, never
from payload-supplied author/body fields. ``comment_author_is_bot`` is an
optional cheap early-out only. A comment that cannot be corroborated
server-side is rejected.
"""
common.logger.info("Received Jira webhook")
if not common.verify_jira_source_ip(request):
raise common.HTTPException(status_code=403, detail="Source IP not allowed")
if not common.verify_jira_secret(request.headers):
common.logger.warning("Invalid Jira webhook token")
raise common.HTTPException(status_code=401, detail="Invalid token")
body = await request.body()
if not common.verify_jira_signature(body, request.headers):
raise common.HTTPException(status_code=401, detail="Invalid signature")
try:
payload = common.json.loads(body)
except common.json.JSONDecodeError:
common.logger.exception("Failed to parse Jira webhook JSON")
return {"status": "error", "message": "Invalid JSON"}
# Cheap early-out on the (untrusted) payload before any Jira API call.
if payload.get("comment_author_is_bot"):
common.logger.debug("Ignoring webhook: comment is from a bot")
return {"status": "ignored", "reason": "Comment is from a bot"}
issue_key = payload.get("issue_key", "") or ""
if not common.is_valid_jira_issue_key(issue_key):
common.logger.debug("Ignoring webhook: missing or malformed issue key")
return {"status": "ignored", "reason": "Missing or malformed issue key"}
comment_id = payload.get("comment_id", "") or ""
if not comment_id:
common.logger.debug("Ignoring webhook: no comment id to corroborate")
return {"status": "ignored", "reason": "No comment id in payload"}
# Corroborate against the real Jira record. The webhook body is unsigned, so
# the triggering comment's author and text are read server-side (matched by
# comment_id) rather than trusted from the payload — this is what prevents a
# secret-holder from spoofing the author (to hijack another user's token) or
# injecting arbitrary agent instructions. A comment that can't be fetched
# (nonexistent issue/comment or a forged event) is rejected.
server_comment = await common.fetch_jira_comment(issue_key, comment_id)
if not server_comment:
common.logger.warning(
"Rejecting Jira webhook: comment %s on %s could not be corroborated",
comment_id,
issue_key,
)
return {"status": "ignored", "reason": "Triggering comment not found"}
author = server_comment.get("author") or {}
account_id = author.get("account_id") or ""
display_name = author.get("name") or ""
comment_body = server_comment.get("body") or ""
for prefix in common._GITHUB_BOT_MESSAGE_PREFIXES:
if comment_body.startswith(prefix):
common.logger.debug("Ignoring webhook: comment is our own bot message")
return {"status": "ignored", "reason": "Comment is our own bot message"}
if "@openswe" not in comment_body.lower():
common.logger.debug("Ignoring webhook: comment doesn't mention @openswe")
return {"status": "ignored", "reason": "Comment doesn't mention @openswe"}
# Derive the project key from the (validated, corroborated) issue key rather
# than trusting the payload's project_key for repo routing.
project_key = issue_key.split("-", 1)[0]
actor_email = await common.get_jira_user_email(account_id) if account_id else None
repo_config = common.extract_repo_from_text(
comment_body, default_owner=common.DEFAULT_REPO_OWNER
)
if repo_config:
common.logger.debug(
"Using repo from comment body: %s/%s",
repo_config["owner"],
repo_config["name"],
)
else:
try:
profile_repo = await common.get_profile_default_repo(
await common.resolve_login_from_email_async(actor_email) if actor_email else None
)
except Exception: # noqa: BLE001
common.logger.exception("Failed to apply dashboard default_repo for Jira user")
profile_repo = None
if profile_repo:
common.logger.info(
"Applying dashboard default_repo for Jira user %s: %s/%s",
account_id,
profile_repo["owner"],
profile_repo["name"],
)
repo_config = profile_repo
if not repo_config:
repo_config = common.get_repo_config_from_jira_mapping(project_key)
if not repo_config:
repo_config = await common.get_team_default_repo()
if not repo_config:
return {"status": "ignored", "reason": "No default repository configured"}
if not common._is_repo_allowed(repo_config):
common.logger.warning(
"Rejecting Jira webhook: repo '%s/%s' not in allowlist",
repo_config.get("owner"),
repo_config.get("name"),
)
return {"status": "ignored", "reason": "Repository not in allowlist"}
issue_data = {
"key": issue_key,
"project_key": project_key,
"triggering_comment": comment_body,
"triggering_comment_id": comment_id,
"comment_author": {
"account_id": account_id,
"email": actor_email,
"name": display_name,
},
}
common.logger.info(
"Accepted webhook for issue '%s', scheduling background task",
issue_key,
)
background_tasks.add_task(service.process_jira_issue, issue_data, repo_config)
return {
"status": "accepted",
"message": f"Processing issue '{issue_key}' for repo "
f"{repo_config['owner']}/{repo_config['name']}",
}
@router.get("/webhooks/jira")
async def jira_webhook_verify() -> dict[str, str]:
"""Verify endpoint for Jira webhook setup."""
return {"status": "ok", "message": "Jira webhook endpoint is active"}