""" Jira Signal Agent Takes ThirdEye signals and converts them into well-formed Jira tickets. Responsibilities: 1. Map signal type → Jira issue type + priority 2. LLM-generate a clean ticket title and structured description from signal context 3. Extract assignee names and match them to Jira account IDs (best-effort) 4. Raise the ticket via jira_client and mark the signal in ChromaDB 5. Bulk-raise: process a group's unraised high-severity signals in one call """ import json import logging from datetime import datetime from backend.providers import call_llm from backend.integrations.jira_client import ( create_issue, search_issues, add_comment, is_configured, search_users ) from backend.db.chroma import store_signals, mark_signal_as_raised, get_raised_signal_ids from backend.config import ( JIRA_DEFAULT_PROJECT, JIRA_DEFAULT_ISSUE_TYPE, JIRA_AUTO_RAISE_SEVERITY ) logger = logging.getLogger("thirdeye.agents.jira_agent") # ─── Signal → Jira type mapping ────────────────────────────────────────────── # Maps ThirdEye signal type → (Jira issue type, default priority) # Note: Issue types must match what's available in your Jira project # Common types: Task, Bug, Story, Epic, Workstream (project-specific) SIGNAL_TYPE_MAP = { # Dev signals "tech_debt": ("Task", "Low"), "recurring_bug": ("Task", "High"), # Changed from Bug to Task "architecture_decision": ("Task", "Medium"), "deployment_risk": ("Task", "High"), "workaround": ("Task", "Medium"), "knowledge_silo": ("Task", "Medium"), # Product signals "feature_request": ("Task", "Medium"), # Changed from Story to Task "priority_conflict": ("Task", "High"), "sentiment_shift": ("Task", "Medium"), # Client signals "promise": ("Task", "High"), "scope_creep": ("Task", "High"), "risk": ("Task", "High"), # Meet signals "meet_action_item": ("Task", "Medium"), "meet_blocker": ("Task", "Highest"), "meet_risk": ("Task", "High"), "meet_decision": ("Task", "Medium"), "meet_open_q": ("Task", "Low"), # Generic "blocker": ("Task", "Highest"), "decision": ("Task", "Medium"), "action_item": ("Task", "Medium"), } SEVERITY_TO_PRIORITY = { "critical": "Highest", "high": "High", "medium": "Medium", "low": "Low", } RAISEABLE_TYPES = set(SIGNAL_TYPE_MAP.keys()) # ─── Assignee resolution ───────────────────────────────────────────────────── async def resolve_assignee_account_id(name: str) -> str | None: """ Resolve a person's display name (or @name) to their Jira account ID. Uses Jira's user search API and fuzzy-matches the best result. Returns the account ID string, or None if no confident match is found. """ if not name: return None clean = name.lstrip("@").strip() try: users = await search_users(clean) if not users: return None clean_lower = clean.lower() # Exact display-name match first for u in users: if u["display_name"].lower() == clean_lower: return u["account_id"] # Partial match (all search words appear in display name) words = clean_lower.split() for u in users: dn = u["display_name"].lower() if all(w in dn for w in words): return u["account_id"] # Last resort: first result return users[0]["account_id"] except Exception as e: logger.warning(f"resolve_assignee_account_id failed for '{name}': {e}") return None # ─── LLM ticket generation ─────────────────────────────────────────────────── TICKET_GEN_SYSTEM_PROMPT = """You are a senior engineering manager writing Jira tickets from team intelligence signals. Given a ThirdEye signal (a structured piece of extracted team knowledge), write a Jira ticket. Return ONLY a valid JSON object with exactly these fields: { "summary": "Short, actionable ticket title (max 100 chars). Start with a verb. No jargon.", "description": "Full ticket description. Include: what the issue is, context from the signal, why it matters, suggested next steps. Use blank lines between sections. Use '- ' for bullet points. Max 400 words.", "labels": ["label1", "label2"], "assignee_name": "First name or @name of the person to assign, or null if unclear" } Label rules: - Always include "thirdeye" and "auto-raised" - Add the signal type as a label (e.g. "tech-debt", "recurring-bug") - Add "urgent" if severity is high or critical - Labels must not have spaces (use hyphens) Summary rules: - Starts with a verb: "Fix", "Investigate", "Address", "Resolve", "Document", "Implement" - Be specific — "Fix intermittent checkout timeout" NOT "Fix bug" - Never exceed 100 characters Description must include: 1. What: clear 1-sentence problem statement 2. Context: what was actually said / detected (cite the signal) 3. Impact: why this matters to the team or product 4. Suggested next steps (2-3 bullet points) Return JSON only — no markdown, no preamble.""" async def generate_ticket_content(signal: dict) -> dict: """ Use an LLM to generate a clean, context-rich Jira ticket from a ThirdEye signal. Returns {"summary": str, "description": str, "labels": list, "assignee_name": str|None} """ signal_text = ( f"Signal type: {signal.get('type', 'unknown')}\n" f"Summary: {signal.get('summary', '')}\n" f"Raw quote: {signal.get('raw_quote', '')[:300]}\n" f"Severity: {signal.get('severity', 'medium')}\n" f"Entities involved: {', '.join(signal.get('entities', []))}\n" f"Keywords: {', '.join(signal.get('keywords', []))}\n" f"Timestamp: {signal.get('timestamp', '')}\n" f"Group: {signal.get('group_id', '')}\n" f"Lens: {signal.get('lens', '')}" ) try: result = await call_llm( task_type="fast_large", messages=[ {"role": "system", "content": TICKET_GEN_SYSTEM_PROMPT}, {"role": "user", "content": signal_text}, ], temperature=0.2, max_tokens=800, response_format={"type": "json_object"}, ) raw = result["content"].strip() if raw.startswith("```"): raw = raw.split("```")[1] if raw.startswith("json"): raw = raw[4:] return json.loads(raw) except Exception as e: logger.warning(f"Ticket generation LLM failed: {e}. Using fallback.") # Fallback: build a basic ticket without LLM sig_type = signal.get("type", "unknown").replace("_", " ").title() return { "summary": f"{sig_type}: {signal.get('summary', 'Unknown issue')[:80]}", "description": ( f"Signal detected by ThirdEye.\n\n" f"Type: {signal.get('type', 'unknown')}\n" f"Summary: {signal.get('summary', '')}\n\n" f"Raw context:\n{signal.get('raw_quote', '(none)')[:300]}\n\n" f"Severity: {signal.get('severity', 'medium')}" ), "labels": ["thirdeye", "auto-raised", signal.get("type", "unknown").replace("_", "-")], "assignee_name": None, } # ─── Main raise function ────────────────────────────────────────────────────── async def raise_ticket_for_signal( signal: dict, group_id: str, project_key: str = None, force: bool = False, assignee_account_id: str = None, ) -> dict: """ Create a Jira ticket for a single ThirdEye signal. Args: signal: The signal dict from ChromaDB group_id: The group this signal belongs to (for dedup tracking) project_key: Override project (default: JIRA_DEFAULT_PROJECT) force: If True, raise even if already raised before Returns: {"ok": True, "key": "ENG-42", "url": "...", "summary": "..."} OR {"ok": False, "reason": "already_raised" | "not_raiseable" | "jira_error", ...} """ if not is_configured(): return {"ok": False, "reason": "jira_not_configured"} signal_id = signal.get("id", "") signal_type = signal.get("type", "") # Check if this signal type is raiseable if signal_type not in RAISEABLE_TYPES: return {"ok": False, "reason": "not_raiseable", "signal_type": signal_type} # Check if already raised (skip if force=True) if not force and signal_id: already_raised = get_raised_signal_ids(group_id) if signal_id in already_raised: return {"ok": False, "reason": "already_raised", "signal_id": signal_id} # Determine Jira issue type and priority from signal default_type, default_priority = SIGNAL_TYPE_MAP.get(signal_type, (JIRA_DEFAULT_ISSUE_TYPE, "Medium")) severity = signal.get("severity", "medium").lower() priority = SEVERITY_TO_PRIORITY.get(severity, default_priority) # Generate ticket content via LLM ticket_content = await generate_ticket_content(signal) summary = ticket_content.get("summary", signal.get("summary", "ThirdEye signal")[:100]) description = ticket_content.get("description", signal.get("summary", "")) labels = ticket_content.get("labels", ["thirdeye", "auto-raised"]) # Always ensure thirdeye label is present if "thirdeye" not in labels: labels.append("thirdeye") # Append ThirdEye metadata as a context section in the description meta_section = ( f"\n\n---\n" f"Raised by: ThirdEye\n" f"Signal ID: {signal_id}\n" f"Group: {group_id}\n" f"Detected: {signal.get('timestamp', datetime.utcnow().isoformat())}" ) description = description + meta_section # Resolve assignee: explicit account_id wins, then signal override name, then LLM-extracted name if not assignee_account_id: name_hint = signal.get("assignee_override") or ticket_content.get("assignee_name") if name_hint: assignee_account_id = await resolve_assignee_account_id(name_hint) if assignee_account_id: logger.info(f"Resolved assignee '{name_hint}' → {assignee_account_id}") else: logger.warning(f"Could not resolve assignee '{name_hint}' to a Jira account") # Create the ticket result = await create_issue( project_key=project_key or JIRA_DEFAULT_PROJECT, summary=summary, description=description, issue_type=default_type, priority=priority, labels=labels, assignee_account_id=assignee_account_id, ) if result.get("ok"): jira_key = result["key"] jira_url = result["url"] # Mark this signal as raised in ChromaDB so we never duplicate it if signal_id: mark_signal_as_raised( group_id, signal_id, jira_key, jira_url=jira_url, jira_summary=summary, jira_priority=priority, ) logger.info(f"Raised Jira ticket {jira_key} for signal {signal_id} ({signal_type})") return { "ok": True, "key": jira_key, "url": jira_url, "summary": summary, "issue_type": default_type, "priority": priority, "assignee_account_id": assignee_account_id, } else: logger.error(f"Jira ticket creation failed: {result}") return { "ok": False, "reason": "jira_error", "error": result.get("error"), "details": result.get("details"), } async def bulk_raise_for_group( group_id: str, signals: list[dict], min_severity: str = None, project_key: str = None, max_tickets: int = 10, ) -> list[dict]: """ Raise Jira tickets for multiple signals from a group in one call. Filters: - Only raiseable signal types - Only signals at or above min_severity (defaults to JIRA_AUTO_RAISE_SEVERITY) - Skips signals already raised - Caps at max_tickets to avoid flooding Jira Returns list of raise results. """ min_sev = (min_severity or JIRA_AUTO_RAISE_SEVERITY).lower() severity_rank = {"low": 0, "medium": 1, "high": 2, "critical": 3} min_rank = severity_rank.get(min_sev, 2) # Default: high already_raised = get_raised_signal_ids(group_id) candidates = [] for sig in signals: sig_type = sig.get("type", "") sig_id = sig.get("id", "") severity = sig.get("severity", "low").lower() rank = severity_rank.get(severity, 0) if sig_type not in RAISEABLE_TYPES: continue if rank < min_rank: continue if sig_id in already_raised: continue candidates.append(sig) # Sort by severity descending, then raise up to max_tickets candidates.sort(key=lambda s: severity_rank.get(s.get("severity", "low"), 0), reverse=True) candidates = candidates[:max_tickets] results = [] for sig in candidates: result = await raise_ticket_for_signal(sig, group_id, project_key=project_key) results.append({**result, "signal_type": sig.get("type"), "signal_summary": sig.get("summary", "")[:80]}) logger.info(f"Bulk raise for group {group_id}: {len(results)} tickets from {len(signals)} signals") return results def format_raise_result_for_telegram(result: dict) -> str: """Format a single raise result as a Telegram message line.""" if result.get("ok"): return ( f"✅ [{result['key']}]({result['url']}) — " f"*{result.get('issue_type', 'Task')}* | {result.get('priority', 'Medium')} priority\n" f" _{result.get('summary', '')[:90]}_" ) reason = result.get("reason", "unknown") if reason == "already_raised": return f"⏭️ Already raised — skipped" if reason == "not_raiseable": return f"⚪ Signal type `{result.get('signal_type', '?')}` — not mapped to Jira" return f"❌ Failed: {result.get('error', reason)}"