"""Jira webhook handler — mirrors ``agent/webhooks/linear.py`` for Jira issues. Helpers and constants stay in webapp.py; they are accessed through the module object (``webapp.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 agent import webapp 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 ``webapp.jira_webhook`` for the shape). repo_config: The repo configuration with owner and name. """ issue_key = issue_data.get("key", "") webapp.logger.info( "Processing Jira issue %s for repo %s/%s", issue_key, repo_config.get("owner"), repo_config.get("name"), ) thread_id = webapp.generate_thread_id_from_jira_issue(issue_key) full_issue = await webapp.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 webapp.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 = webapp.extract_image_urls(description) if description_image_urls: image_urls.extend(description_image_urls) webapp.logger.debug( "Found %d image URL(s) in issue description", len(description_image_urls), ) raw_comments = await webapp.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 = webapp._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:] webapp.logger.debug( "Using triggering comment index %d to build relevant comments", trigger_index, ) else: relevant_comments = webapp.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 = webapp.extract_image_urls(body) if body_image_urls: image_urls.extend(body_image_urls) webapp.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 = webapp.extract_image_urls(trigger_body) if trigger_image_urls: image_urls.extend(trigger_image_urls) webapp.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" webapp.logger.debug( "Appended triggering comment %s not present in issue comments list", triggering_comment_id or "", ) 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 webapp.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 webapp.is_login_mapped(mapped_login): webapp.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 = webapp.dedupe_urls(image_urls) resolved_model_id = await webapp.resolve_agent_model_id(mapped_login) if not webapp.model_supports_images(resolved_model_id): fallback_model_id, fallback_effort = webapp.default_vision_model_pair() webapp.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) webapp.logger.info("Preparing %d image(s) for multimodal content", len(image_urls)) webapp.logger.debug("Image hosts: %s", [urlparse(u).hostname for u in image_urls]) async with httpx.AsyncClient(timeout=webapp.DEFAULT_HTTP_TIMEOUT) as client: for image_url in image_urls: image_block = await webapp.fetch_image_block(image_url, client) if image_block: content_blocks.append(image_block) webapp.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 webapp.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 webapp.dispatch_agent_run( thread_id, content_blocks, configurable, source="jira", metadata=webapp._AGENT_VERSION_METADATA, ) webapp.logger.info( "LangGraph run dispatched for thread %s (run=%s)", thread_id, run.get("run_id") if isinstance(run, dict) else None, ) await webapp.post_jira_trace_comment(issue_key, thread_id)