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

230 lines
9.5 KiB
Python

"""Jira webhook handler — mirrors ``agent/webhooks/linear.py`` for Jira issues.
Helpers and constants stay in common.py; they are accessed through the module
object (``common.X``) so tests that monkeypatch them keep working.
"""
from typing import Any
from urllib.parse import urlparse
import httpx
from langchain_core.messages.content import create_text_block
from . import common
async def process_jira_issue( # noqa: PLR0912, PLR0915
issue_data: dict[str, Any], repo_config: dict[str, str]
) -> None:
"""Process a Jira issue comment by creating a new LangGraph thread and run.
Args:
issue_data: The Jira issue data from the webhook (basic info + the
triggering comment; see ``jira_routes.jira_webhook`` for the shape).
repo_config: The repo configuration with owner and name.
"""
issue_key = issue_data.get("key", "")
common.logger.info(
"Processing Jira issue %s for repo %s/%s",
issue_key,
repo_config.get("owner"),
repo_config.get("name"),
)
thread_id = common.generate_thread_id_from_jira_issue(issue_key)
full_issue = await common.fetch_jira_issue_details(issue_key)
if not full_issue:
full_issue = {}
# Actor email for token attribution: restricted to the comment author only,
# resolved once (by account_id) in the webhook handler and carried through
# here — mirrors the Linear handler's restriction to the comment author,
# so a PR is never opened as a non-actor.
comment_author = issue_data.get("comment_author") or {}
actor_email = comment_author.get("email")
user_name = comment_author.get("name") or None
user_email = actor_email
common.logger.info("User email for issue %s: %s", issue_key, user_email)
title = full_issue.get("title") or "No title"
description = full_issue.get("description") or "No description"
image_urls: list[str] = []
description_image_urls = common.extract_image_urls(description)
if description_image_urls:
image_urls.extend(description_image_urls)
common.logger.debug(
"Found %d image URL(s) in issue description",
len(description_image_urls),
)
raw_comments = await common.fetch_jira_issue_comments(issue_key)
comments = [{**comment, "createdAt": comment.get("created", "")} for comment in raw_comments]
comments_text = ""
triggering_comment = issue_data.get("triggering_comment", "")
triggering_comment_id = issue_data.get("triggering_comment_id", "")
bot_message_prefixes = common._GITHUB_BOT_MESSAGE_PREFIXES
comment_ids: set[str] = set()
comment_id_to_index: dict[str, int] = {}
if comments:
for i, comment in enumerate(comments):
comment_id = comment.get("id", "")
if comment_id:
comment_ids.add(comment_id)
comment_id_to_index[comment_id] = i
relevant_comments = []
trigger_index = None
if triggering_comment_id:
trigger_index = comment_id_to_index.get(triggering_comment_id)
if trigger_index is not None:
relevant_comments = comments[trigger_index:]
common.logger.debug(
"Using triggering comment index %d to build relevant comments",
trigger_index,
)
else:
relevant_comments = common.get_recent_comments(comments, bot_message_prefixes)
if relevant_comments:
comments_text = "\n\n## Comments:\n"
for comment in relevant_comments:
author = (comment.get("author") or {}).get("name") or "User"
body = comment.get("body", "")
body_image_urls = common.extract_image_urls(body)
if body_image_urls:
image_urls.extend(body_image_urls)
common.logger.debug(
"Found %d image URL(s) in comment by %s",
len(body_image_urls),
author,
)
if any(body.startswith(prefix) for prefix in bot_message_prefixes):
continue
comments_text += f"\n**{author}:** {body}\n"
if triggering_comment and triggering_comment_id not in comment_ids:
if not comments_text:
comments_text = "\n\n## Comments:\n"
trigger_author = comment_author.get("name") or "Unknown"
trigger_body = triggering_comment
trigger_image_urls = common.extract_image_urls(trigger_body)
if trigger_image_urls:
image_urls.extend(trigger_image_urls)
common.logger.debug(
"Found %d image URL(s) in triggering comment by %s",
len(trigger_image_urls),
trigger_author,
)
comments_text += f"\n**{trigger_author}:** {trigger_body}\n"
common.logger.debug(
"Appended triggering comment %s not present in issue comments list",
triggering_comment_id or "<missing-id>",
)
project_key = issue_data.get("project_key", "") or full_issue.get("project_key", "")
issue_number = issue_key.split("-", 1)[1] if "-" in issue_key else ""
issue_url = full_issue.get("url", "")
triggered_by_line = f"## Triggered by: {user_name}\n\n" if user_name else ""
tag_instruction = (
f"When calling jira_comment, tag @{user_name} if you are asking them a question, need their input, or are notifying them of something important (e.g. a completed PR). For simple answers, tagging is not required."
if user_name
else ""
)
prompt = (
f"Please work on the following issue:\n\n"
f"## Repository: {repo_config.get('owner')}/{repo_config.get('name')}\n\n"
f"## Title: {title}\n\n"
f"{triggered_by_line}"
f"## Jira Ticket: {issue_key}\n\n"
f"## Description:\n{description}\n"
f"{comments_text}\n\n"
f"Please analyze this issue and implement the necessary changes. "
f"When you're done, commit and push your changes. {tag_instruction}"
)
content_blocks: list[dict[str, Any]] = [create_text_block(prompt)]
# Resolve the GitHub login from the actor's Jira email via the same
# user-mapping store Slack/Linear use, so PRs open *as the triggering user*
# and the thread is tagged for the dashboard. Restricted to the comment
# author so token attribution never falls back to reporter/assignee.
mapped_login = await common.resolve_login_from_email_async(actor_email) if actor_email else None
# Only attribute to an *active* user mapping; a pending/unconfirmed mapping
# must never drive PR authorship or token resolution.
if mapped_login and not common.is_login_mapped(mapped_login):
common.logger.info(
"Jira actor login %s is not an active mapping; running unattributed", mapped_login
)
mapped_login = None
image_model_override: tuple[str, str] | None = None
if image_urls:
image_urls = common.dedupe_urls(image_urls)
resolved_model_id = await common.resolve_agent_model_id(mapped_login)
if not common.model_supports_images(resolved_model_id):
fallback_model_id, fallback_effort = common.default_vision_model_pair()
common.logger.info(
"Using vision fallback model %s for %d Jira image(s); configured model %s "
"does not support images",
fallback_model_id,
len(image_urls),
resolved_model_id,
)
resolved_model_id = fallback_model_id
image_model_override = (fallback_model_id, fallback_effort)
common.logger.info("Preparing %d image(s) for multimodal content", len(image_urls))
common.logger.debug("Image hosts: %s", [urlparse(u).hostname for u in image_urls])
async with httpx.AsyncClient(timeout=common.DEFAULT_HTTP_TIMEOUT) as client:
for image_url in image_urls:
image_block = await common.fetch_image_block(image_url, client)
if image_block:
content_blocks.append(image_block)
common.logger.info("Built %d content block(s) for prompt", len(content_blocks))
configurable: dict[str, Any] = {
"repo": repo_config,
"jira_issue": {
"key": issue_key,
"url": issue_url,
"project_key": project_key,
"issue_number": issue_number,
"title": title,
"triggering_user_name": user_name or "",
},
"user_email": user_email,
"source": "jira",
}
if mapped_login:
configurable["github_login"] = mapped_login
if image_model_override:
configurable["agent_model_id"] = image_model_override[0]
configurable["agent_effort"] = image_model_override[1]
await common.upsert_agent_thread_owner_metadata(
thread_id,
source="jira",
repo_config=repo_config,
github_login=mapped_login or "",
user_email=user_email or "",
title=title or issue_key or "Jira issue",
source_context={"jira_issue": configurable["jira_issue"]},
)
run = await common.dispatch_agent_run(
thread_id,
content_blocks,
configurable,
source="jira",
metadata=common._AGENT_VERSION_METADATA,
)
common.logger.info(
"LangGraph run dispatched for thread %s (run=%s)",
thread_id,
run.get("run_id") if isinstance(run, dict) else None,
)
await common.post_jira_trace_comment(issue_key, thread_id)