"""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"}