mirror of
https://github.com/Sea-Haven-Industries/open-swe.git
synced 2026-09-30 12:43:16 +00:00
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.
182 lines
7.2 KiB
Python
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"}
|