mirror of
https://github.com/arkorty/B.Tech-Project-III.git
synced 2026-04-19 12:41:48 +00:00
2040 lines
77 KiB
Markdown
2040 lines
77 KiB
Markdown
# ThirdEye — Jira Integration Milestones (17→19)
|
||
|
||
> **Prerequisite: Milestones 0–16 must be COMPLETE and PASSING. This feature layers on top of the existing working system.**
|
||
> **Same rule: Do NOT skip milestones. Do NOT skip tests. Every test must PASS before moving to the next milestone.**
|
||
|
||
---
|
||
|
||
## WHAT THIS ADDS
|
||
|
||
ThirdEye already extracts structured signals from Telegram chats, documents, links, and Google Meet recordings. This integration adds a **Jira action layer** on top of that intelligence:
|
||
|
||
1. A **Jira API client** that your existing agents can call to create, search, and link tickets
|
||
2. A **Jira Signal Agent** that takes any ThirdEye signal (tech debt, bug, blocker, action item, risk) and uses an LLM to generate a well-formed Jira ticket — correct issue type, priority, description, labels, and assignee
|
||
3. **Telegram commands** to raise tickets on demand, check ticket status, and auto-raise critical signals without any manual work
|
||
4. **Auto-raise mode** — when pattern detection flags a recurring bug or critical blocker, a Jira ticket is opened automatically and the group is notified
|
||
|
||
**The integration is seamless:** signals already in ChromaDB become Jira tickets. Tickets raised by ThirdEye are tagged and cross-linked back to the source signal so nothing is lost.
|
||
|
||
---
|
||
|
||
## PRE-WORK: Dependencies & Config Updates
|
||
|
||
### Step 0.1 — Add new dependencies
|
||
|
||
Append to `thirdeye/requirements.txt`:
|
||
```
|
||
httpx==0.28.1
|
||
```
|
||
|
||
> `httpx` is almost certainly already installed (it's a transitive dep from openai). Verify first:
|
||
> ```bash
|
||
> python -c "import httpx; print(httpx.__version__)"
|
||
> ```
|
||
> If it prints a version, skip the install. If not: `pip install httpx==0.28.1`
|
||
|
||
No other packages are needed. The Jira REST API v3 is plain HTTPS — no SDK required.
|
||
|
||
### Step 0.2 — Add new env vars
|
||
|
||
Append to `thirdeye/.env`:
|
||
```bash
|
||
# Jira Integration (Milestone 17)
|
||
JIRA_BASE_URL=https://your-org.atlassian.net # Your Atlassian cloud URL (no trailing slash)
|
||
JIRA_EMAIL=your-email@example.com # The email you use to log into Jira
|
||
JIRA_API_TOKEN=your_jira_api_token_here # See Step 0.4 below
|
||
JIRA_DEFAULT_PROJECT=ENG # Default project key (e.g. ENG, DEV, THIRDEYE)
|
||
JIRA_DEFAULT_ISSUE_TYPE=Task # Default type: Task, Bug, Story
|
||
ENABLE_JIRA=true
|
||
JIRA_AUTO_RAISE=false # Set true to auto-raise critical signals
|
||
JIRA_AUTO_RAISE_SEVERITY=high # Minimum severity to auto-raise: high or critical
|
||
```
|
||
|
||
### Step 0.3 — Update config.py
|
||
|
||
Add these lines at the bottom of `thirdeye/backend/config.py`:
|
||
```python
|
||
# Jira
|
||
JIRA_BASE_URL = os.getenv("JIRA_BASE_URL", "").rstrip("/")
|
||
JIRA_EMAIL = os.getenv("JIRA_EMAIL", "")
|
||
JIRA_API_TOKEN = os.getenv("JIRA_API_TOKEN", "")
|
||
JIRA_DEFAULT_PROJECT = os.getenv("JIRA_DEFAULT_PROJECT", "ENG")
|
||
JIRA_DEFAULT_ISSUE_TYPE = os.getenv("JIRA_DEFAULT_ISSUE_TYPE", "Task")
|
||
ENABLE_JIRA = os.getenv("ENABLE_JIRA", "true").lower() == "true"
|
||
JIRA_AUTO_RAISE = os.getenv("JIRA_AUTO_RAISE", "false").lower() == "true"
|
||
JIRA_AUTO_RAISE_SEVERITY = os.getenv("JIRA_AUTO_RAISE_SEVERITY", "high")
|
||
```
|
||
|
||
### Step 0.4 — Get your Jira API token (5 minutes, free)
|
||
|
||
**Free Jira account:** https://www.atlassian.com/software/jira/free — up to 10 users, unlimited projects, no credit card.
|
||
|
||
**API token:**
|
||
```
|
||
1. Log into Jira → click your avatar (top right) → "Manage account"
|
||
2. Click the "Security" tab
|
||
3. Under "API tokens" → "Create and manage API tokens" → "Create API token"
|
||
4. Label: "ThirdEye" → Create → Copy the token immediately (shown only once)
|
||
5. Paste into JIRA_API_TOKEN in your .env
|
||
```
|
||
|
||
**Project key:** In Jira, click your project → the short ALL-CAPS code in the URL and on every ticket (e.g. `ENG-42` → project key is `ENG`). Paste into `JIRA_DEFAULT_PROJECT`.
|
||
|
||
---
|
||
|
||
## MILESTONE 17: Jira API Client (135%)
|
||
|
||
**Goal:** A clean async Jira API wrapper that can authenticate, list projects, list issue types, create issues, get issue details, and search by JQL. Every ThirdEye agent will use this client — it must be solid before anything else is built on top.
|
||
|
||
### Step 17.1 — Create the Jira client
|
||
|
||
Create file: `thirdeye/backend/integrations/jira_client.py`
|
||
|
||
```python
|
||
"""
|
||
Jira REST API v3 client — async, using httpx.
|
||
All methods return plain dicts (no Jira SDK objects).
|
||
Authentication: Basic auth with email + API token (Jira Cloud standard).
|
||
Docs: https://developer.atlassian.com/cloud/jira/platform/rest/v3/
|
||
"""
|
||
import base64
|
||
import logging
|
||
from typing import Optional
|
||
import httpx
|
||
|
||
from backend.config import (
|
||
JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN, ENABLE_JIRA
|
||
)
|
||
|
||
logger = logging.getLogger("thirdeye.integrations.jira")
|
||
|
||
# ─── Auth ────────────────────────────────────────────────────────────────────
|
||
|
||
def _auth_header() -> dict:
|
||
"""Build the Basic auth header from email + API token."""
|
||
raw = f"{JIRA_EMAIL}:{JIRA_API_TOKEN}"
|
||
encoded = base64.b64encode(raw.encode()).decode()
|
||
return {
|
||
"Authorization": f"Basic {encoded}",
|
||
"Accept": "application/json",
|
||
"Content-Type": "application/json",
|
||
}
|
||
|
||
|
||
def _base_url() -> str:
|
||
return f"{JIRA_BASE_URL}/rest/api/3"
|
||
|
||
|
||
def is_configured() -> bool:
|
||
"""Return True if all required Jira config is set."""
|
||
return bool(JIRA_BASE_URL and JIRA_EMAIL and JIRA_API_TOKEN and ENABLE_JIRA)
|
||
|
||
|
||
# ─── Core HTTP helpers ───────────────────────────────────────────────────────
|
||
|
||
async def _get(path: str, params: dict = None) -> dict:
|
||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||
resp = await client.get(
|
||
f"{_base_url()}{path}",
|
||
headers=_auth_header(),
|
||
params=params or {},
|
||
)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
|
||
|
||
async def _post(path: str, body: dict) -> dict:
|
||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||
resp = await client.post(
|
||
f"{_base_url()}{path}",
|
||
headers=_auth_header(),
|
||
json=body,
|
||
)
|
||
resp.raise_for_status()
|
||
return resp.json()
|
||
|
||
|
||
async def _put(path: str, body: dict) -> dict:
|
||
async with httpx.AsyncClient(timeout=15.0) as client:
|
||
resp = await client.put(
|
||
f"{_base_url()}{path}",
|
||
headers=_auth_header(),
|
||
json=body,
|
||
)
|
||
resp.raise_for_status()
|
||
# PUT /issue returns 204 No Content on success
|
||
if resp.status_code == 204:
|
||
return {"ok": True}
|
||
return resp.json()
|
||
|
||
|
||
# ─── Public API ──────────────────────────────────────────────────────────────
|
||
|
||
async def test_connection() -> dict:
|
||
"""
|
||
Verify credentials work by calling /myself.
|
||
Returns {"ok": True, "displayName": "...", "email": "..."} or {"ok": False, "error": "..."}
|
||
"""
|
||
try:
|
||
data = await _get("/myself")
|
||
return {
|
||
"ok": True,
|
||
"display_name": data.get("displayName", "Unknown"),
|
||
"email": data.get("emailAddress", "Unknown"),
|
||
"account_id": data.get("accountId", ""),
|
||
}
|
||
except httpx.HTTPStatusError as e:
|
||
return {"ok": False, "error": f"HTTP {e.response.status_code}: {e.response.text[:200]}"}
|
||
except Exception as e:
|
||
return {"ok": False, "error": str(e)}
|
||
|
||
|
||
async def list_projects() -> list[dict]:
|
||
"""
|
||
List all accessible Jira projects.
|
||
Returns list of {"key": "ENG", "name": "Engineering", "id": "10001"}
|
||
"""
|
||
data = await _get("/project/search", params={"maxResults": 50})
|
||
return [
|
||
{
|
||
"key": p["key"],
|
||
"name": p["name"],
|
||
"id": p["id"],
|
||
"type": p.get("projectTypeKey", "software"),
|
||
}
|
||
for p in data.get("values", [])
|
||
]
|
||
|
||
|
||
async def list_issue_types(project_key: str) -> list[dict]:
|
||
"""
|
||
List issue types available for a specific project.
|
||
Returns list of {"id": "10001", "name": "Bug", "subtask": False}
|
||
"""
|
||
data = await _get(f"/project/{project_key}")
|
||
issue_types = data.get("issueTypes", [])
|
||
return [
|
||
{
|
||
"id": it["id"],
|
||
"name": it["name"],
|
||
"subtask": it.get("subtask", False),
|
||
}
|
||
for it in issue_types
|
||
if not it.get("subtask", False) # Exclude subtask types
|
||
]
|
||
|
||
|
||
async def get_issue(issue_key: str) -> dict:
|
||
"""
|
||
Get a single issue by key (e.g. "ENG-42").
|
||
Returns simplified issue dict.
|
||
"""
|
||
data = await _get(f"/issue/{issue_key}")
|
||
fields = data.get("fields", {})
|
||
return {
|
||
"key": data["key"],
|
||
"id": data["id"],
|
||
"summary": fields.get("summary", ""),
|
||
"status": fields.get("status", {}).get("name", "Unknown"),
|
||
"priority": fields.get("priority", {}).get("name", "Medium"),
|
||
"assignee": (fields.get("assignee") or {}).get("displayName", "Unassigned"),
|
||
"issue_type": fields.get("issuetype", {}).get("name", "Task"),
|
||
"url": f"{JIRA_BASE_URL}/browse/{data['key']}",
|
||
"created": fields.get("created", ""),
|
||
"updated": fields.get("updated", ""),
|
||
}
|
||
|
||
|
||
async def create_issue(
|
||
project_key: str,
|
||
summary: str,
|
||
description: str,
|
||
issue_type: str = "Task",
|
||
priority: str = "Medium",
|
||
labels: list[str] = None,
|
||
assignee_account_id: str = None,
|
||
) -> dict:
|
||
"""
|
||
Create a new Jira issue.
|
||
|
||
Args:
|
||
project_key: Project key (e.g. "ENG")
|
||
summary: Issue title (max ~250 chars)
|
||
description: Full description in Atlassian Document Format (ADF)
|
||
issue_type: "Task", "Bug", "Story", "Epic"
|
||
priority: "Highest", "High", "Medium", "Low", "Lowest"
|
||
labels: List of label strings (no spaces allowed in labels)
|
||
assignee_account_id: Jira account ID to assign to (optional)
|
||
|
||
Returns:
|
||
{"key": "ENG-42", "id": "10042", "url": "https://..."}
|
||
"""
|
||
fields: dict = {
|
||
"project": {"key": project_key},
|
||
"summary": summary[:255], # Jira hard limit
|
||
"description": _text_to_adf(description),
|
||
"issuetype": {"name": issue_type},
|
||
"priority": {"name": priority},
|
||
}
|
||
|
||
if labels:
|
||
# Jira labels cannot have spaces — replace with hyphens
|
||
fields["labels"] = [l.replace(" ", "-") for l in labels]
|
||
|
||
if assignee_account_id:
|
||
fields["assignee"] = {"accountId": assignee_account_id}
|
||
|
||
body = {"fields": fields}
|
||
|
||
try:
|
||
data = await _post("/issue", body)
|
||
issue_key = data["key"]
|
||
return {
|
||
"ok": True,
|
||
"key": issue_key,
|
||
"id": data["id"],
|
||
"url": f"{JIRA_BASE_URL}/browse/{issue_key}",
|
||
}
|
||
except httpx.HTTPStatusError as e:
|
||
error_body = {}
|
||
try:
|
||
error_body = e.response.json()
|
||
except Exception:
|
||
pass
|
||
errors = error_body.get("errors", {})
|
||
messages = error_body.get("errorMessages", [])
|
||
return {
|
||
"ok": False,
|
||
"error": f"HTTP {e.response.status_code}",
|
||
"details": errors or messages or e.response.text[:300],
|
||
}
|
||
except Exception as e:
|
||
return {"ok": False, "error": str(e)}
|
||
|
||
|
||
async def search_issues(jql: str, max_results: int = 10) -> list[dict]:
|
||
"""
|
||
Search issues using JQL (Jira Query Language).
|
||
Example JQL: 'project = ENG AND labels = thirdeye AND status != Done'
|
||
|
||
Returns list of simplified issue dicts.
|
||
"""
|
||
data = await _post("/search", {
|
||
"jql": jql,
|
||
"maxResults": max_results,
|
||
"fields": ["summary", "status", "priority", "assignee", "issuetype", "labels", "created"],
|
||
})
|
||
results = []
|
||
for issue in data.get("issues", []):
|
||
fields = issue.get("fields", {})
|
||
results.append({
|
||
"key": issue["key"],
|
||
"summary": fields.get("summary", ""),
|
||
"status": fields.get("status", {}).get("name", "Unknown"),
|
||
"priority": fields.get("priority", {}).get("name", "Medium"),
|
||
"assignee": (fields.get("assignee") or {}).get("displayName", "Unassigned"),
|
||
"issue_type": fields.get("issuetype", {}).get("name", "Task"),
|
||
"labels": fields.get("labels", []),
|
||
"url": f"{JIRA_BASE_URL}/browse/{issue['key']}",
|
||
})
|
||
return results
|
||
|
||
|
||
async def add_comment(issue_key: str, comment: str) -> dict:
|
||
"""Add a plain-text comment to an existing issue."""
|
||
try:
|
||
data = await _post(f"/issue/{issue_key}/comment", {
|
||
"body": _text_to_adf(comment)
|
||
})
|
||
return {"ok": True, "id": data.get("id")}
|
||
except Exception as e:
|
||
return {"ok": False, "error": str(e)}
|
||
|
||
|
||
# ─── ADF helper ──────────────────────────────────────────────────────────────
|
||
|
||
def _text_to_adf(text: str) -> dict:
|
||
"""
|
||
Convert plain text to Atlassian Document Format (ADF).
|
||
Jira Cloud requires ADF for description/comment fields (not plain strings).
|
||
Splits on double newlines to create separate paragraphs.
|
||
"""
|
||
paragraphs = [p.strip() for p in text.split("\n\n") if p.strip()]
|
||
if not paragraphs:
|
||
paragraphs = [text.strip() or "(no description)"]
|
||
|
||
content = []
|
||
for para in paragraphs:
|
||
# Handle bullet lines within a paragraph (lines starting with - or *)
|
||
lines = para.split("\n")
|
||
bullet_items = [l.lstrip("-* ").strip() for l in lines if l.strip().startswith(("-", "*", "•"))]
|
||
non_bullets = [l for l in lines if not l.strip().startswith(("-", "*", "•"))]
|
||
|
||
if non_bullets:
|
||
content.append({
|
||
"type": "paragraph",
|
||
"content": [{"type": "text", "text": " ".join(non_bullets)}],
|
||
})
|
||
|
||
if bullet_items:
|
||
content.append({
|
||
"type": "bulletList",
|
||
"content": [
|
||
{
|
||
"type": "listItem",
|
||
"content": [
|
||
{
|
||
"type": "paragraph",
|
||
"content": [{"type": "text", "text": item}],
|
||
}
|
||
],
|
||
}
|
||
for item in bullet_items
|
||
],
|
||
})
|
||
|
||
return {
|
||
"type": "doc",
|
||
"version": 1,
|
||
"content": content or [
|
||
{"type": "paragraph", "content": [{"type": "text", "text": "(no description)"}]}
|
||
],
|
||
}
|
||
```
|
||
|
||
### Step 17.2 — Create the integrations package init
|
||
|
||
```bash
|
||
mkdir -p thirdeye/backend/integrations
|
||
touch thirdeye/backend/integrations/__init__.py
|
||
```
|
||
|
||
### Step 17.3 — Store raised ticket IDs in ChromaDB signal metadata
|
||
|
||
When a ticket is raised for a signal, we need to record it so we don't raise duplicates. Add a helper to `thirdeye/backend/db/chroma.py`:
|
||
|
||
```python
|
||
# Append to backend/db/chroma.py
|
||
|
||
def mark_signal_as_raised(group_id: str, signal_id: str, jira_key: str):
|
||
"""
|
||
Tag a signal with its Jira ticket key so we never raise it twice.
|
||
Adds a new signal of type 'jira_raised' linked to the original signal_id.
|
||
"""
|
||
import uuid
|
||
from datetime import datetime
|
||
|
||
tracking_signal = {
|
||
"id": str(uuid.uuid4()),
|
||
"type": "jira_raised",
|
||
"summary": f"Jira ticket {jira_key} raised for signal {signal_id}",
|
||
"raw_quote": signal_id, # store original signal_id here
|
||
"severity": "low",
|
||
"status": "raised",
|
||
"sentiment": "neutral",
|
||
"urgency": "none",
|
||
"entities": [jira_key],
|
||
"keywords": ["jira", jira_key, "raised"],
|
||
"timestamp": datetime.utcnow().isoformat(),
|
||
"group_id": group_id,
|
||
"lens": "jira",
|
||
"jira_key": jira_key,
|
||
"original_signal_id": signal_id,
|
||
}
|
||
store_signals(group_id, [tracking_signal])
|
||
|
||
|
||
def get_raised_signal_ids(group_id: str) -> set[str]:
|
||
"""
|
||
Return the set of signal IDs that have already had Jira tickets raised.
|
||
Used to prevent duplicates.
|
||
"""
|
||
collection = get_collection(group_id)
|
||
try:
|
||
results = collection.get(where={"type": "jira_raised"})
|
||
# raw_quote stores the original signal_id
|
||
return set(results.get("documents", []))
|
||
except Exception:
|
||
return set()
|
||
```
|
||
|
||
### ✅ TEST MILESTONE 17
|
||
|
||
Create file: `thirdeye/scripts/test_m17.py`
|
||
```python
|
||
"""
|
||
Test Milestone 17: Jira API client.
|
||
Tests real API connectivity — your .env must have valid Jira credentials.
|
||
"""
|
||
import asyncio
|
||
import os
|
||
import sys
|
||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||
|
||
|
||
async def test_config_loaded():
|
||
"""Test that all Jira config vars are present."""
|
||
from backend.config import (
|
||
JIRA_BASE_URL, JIRA_EMAIL, JIRA_API_TOKEN,
|
||
JIRA_DEFAULT_PROJECT, ENABLE_JIRA
|
||
)
|
||
checks = {
|
||
"JIRA_BASE_URL": JIRA_BASE_URL,
|
||
"JIRA_EMAIL": JIRA_EMAIL,
|
||
"JIRA_API_TOKEN": JIRA_API_TOKEN,
|
||
"JIRA_DEFAULT_PROJECT": JIRA_DEFAULT_PROJECT,
|
||
}
|
||
all_pass = True
|
||
for name, val in checks.items():
|
||
ok = bool(val and len(val) > 3)
|
||
status = "✅" if ok else "❌ MISSING"
|
||
if not ok:
|
||
all_pass = False
|
||
print(f" {status} {name}: {val[:30] if val else '(empty)'}...")
|
||
assert all_pass, "Fix missing Jira config vars in .env before continuing"
|
||
print(f" ✅ ENABLE_JIRA: {ENABLE_JIRA}")
|
||
|
||
|
||
async def test_connection():
|
||
"""Test that credentials are valid and can reach the Jira API."""
|
||
from backend.integrations.jira_client import test_connection
|
||
|
||
print("\nTesting Jira API connection...")
|
||
result = await test_connection()
|
||
|
||
assert result.get("ok"), f"Connection failed: {result.get('error')}"
|
||
print(f" ✅ Connected as: {result['display_name']} ({result['email']})")
|
||
print(f" Account ID: {result['account_id']}")
|
||
|
||
|
||
async def test_list_projects():
|
||
"""Test listing projects — must return at least one."""
|
||
from backend.integrations.jira_client import list_projects
|
||
from backend.config import JIRA_DEFAULT_PROJECT
|
||
|
||
print("\nTesting list_projects()...")
|
||
projects = await list_projects()
|
||
|
||
assert len(projects) > 0, "No projects returned. Make sure your account has at least one project."
|
||
print(f" ✅ Found {len(projects)} project(s):")
|
||
for p in projects[:5]:
|
||
print(f" [{p['key']}] {p['name']}")
|
||
|
||
keys = [p["key"] for p in projects]
|
||
assert JIRA_DEFAULT_PROJECT in keys, (
|
||
f"JIRA_DEFAULT_PROJECT '{JIRA_DEFAULT_PROJECT}' not found in your projects: {keys}\n"
|
||
"Update JIRA_DEFAULT_PROJECT in .env to one of the listed keys."
|
||
)
|
||
print(f" ✅ Default project '{JIRA_DEFAULT_PROJECT}' exists")
|
||
|
||
|
||
async def test_list_issue_types():
|
||
"""Test listing issue types for the default project."""
|
||
from backend.integrations.jira_client import list_issue_types
|
||
from backend.config import JIRA_DEFAULT_PROJECT
|
||
|
||
print("\nTesting list_issue_types()...")
|
||
types = await list_issue_types(JIRA_DEFAULT_PROJECT)
|
||
|
||
assert len(types) > 0, f"No issue types returned for project {JIRA_DEFAULT_PROJECT}"
|
||
names = [t["name"] for t in types]
|
||
print(f" ✅ Issue types in '{JIRA_DEFAULT_PROJECT}': {names}")
|
||
|
||
|
||
async def test_create_and_get_issue():
|
||
"""Test creating a real Jira issue and then retrieving it."""
|
||
from backend.integrations.jira_client import create_issue, get_issue
|
||
from backend.config import JIRA_DEFAULT_PROJECT
|
||
|
||
print("\nTesting create_issue() and get_issue()...")
|
||
|
||
result = await create_issue(
|
||
project_key=JIRA_DEFAULT_PROJECT,
|
||
summary="[ThirdEye Test] Milestone 17 verification ticket",
|
||
description=(
|
||
"This ticket was created automatically by the ThirdEye test suite.\n\n"
|
||
"It verifies that the Jira API client can create issues successfully.\n\n"
|
||
"Safe to close or delete."
|
||
),
|
||
issue_type="Task",
|
||
priority="Low",
|
||
labels=["thirdeye", "test", "automated"],
|
||
)
|
||
|
||
assert result.get("ok"), f"create_issue failed: {result.get('error')} — details: {result.get('details')}"
|
||
issue_key = result["key"]
|
||
print(f" ✅ Created issue: {issue_key}")
|
||
print(f" URL: {result['url']}")
|
||
|
||
# Retrieve it
|
||
issue = await get_issue(issue_key)
|
||
assert issue["key"] == issue_key
|
||
assert "ThirdEye Test" in issue["summary"]
|
||
assert issue["status"] in ("To Do", "Open", "Backlog", "New")
|
||
print(f" ✅ Retrieved issue: [{issue['key']}] {issue['summary']}")
|
||
print(f" Status: {issue['status']} | Priority: {issue['priority']}")
|
||
|
||
return issue_key
|
||
|
||
|
||
async def test_search_issues(issue_key: str):
|
||
"""Test searching issues by JQL."""
|
||
from backend.integrations.jira_client import search_issues
|
||
from backend.config import JIRA_DEFAULT_PROJECT
|
||
|
||
print("\nTesting search_issues() via JQL...")
|
||
jql = f'project = {JIRA_DEFAULT_PROJECT} AND labels = "thirdeye" AND labels = "test" ORDER BY created DESC'
|
||
results = await search_issues(jql, max_results=5)
|
||
|
||
assert len(results) > 0, f"Expected at least one result for JQL: {jql}"
|
||
keys = [r["key"] for r in results]
|
||
assert issue_key in keys, f"Newly created issue {issue_key} not found in search results"
|
||
print(f" ✅ JQL search returned {len(results)} result(s), including {issue_key}")
|
||
|
||
|
||
async def test_add_comment(issue_key: str):
|
||
"""Test adding a comment to an existing issue."""
|
||
from backend.integrations.jira_client import add_comment
|
||
|
||
print("\nTesting add_comment()...")
|
||
result = await add_comment(
|
||
issue_key,
|
||
"ThirdEye test comment — verifying comment API works correctly."
|
||
)
|
||
assert result.get("ok"), f"add_comment failed: {result.get('error')}"
|
||
print(f" ✅ Comment added to {issue_key} (comment id: {result.get('id')})")
|
||
|
||
|
||
async def test_adf_conversion():
|
||
"""Test that _text_to_adf produces valid ADF structure."""
|
||
from backend.integrations.jira_client import _text_to_adf
|
||
|
||
print("\nTesting ADF conversion...")
|
||
text = "This is paragraph one.\n\nThis is paragraph two.\n\n- Bullet A\n- Bullet B"
|
||
adf = _text_to_adf(text)
|
||
|
||
assert adf["type"] == "doc"
|
||
assert adf["version"] == 1
|
||
assert len(adf["content"]) >= 2
|
||
print(f" ✅ ADF produced {len(adf['content'])} content block(s)")
|
||
|
||
# Empty text should not crash
|
||
adf_empty = _text_to_adf("")
|
||
assert adf_empty["type"] == "doc"
|
||
print(" ✅ Empty text handled gracefully")
|
||
|
||
|
||
async def main():
|
||
print("Running Milestone 17 tests...\n")
|
||
await test_config_loaded()
|
||
await test_connection()
|
||
await test_list_projects()
|
||
await test_list_issue_types()
|
||
issue_key = await test_create_and_get_issue()
|
||
await test_search_issues(issue_key)
|
||
await test_add_comment(issue_key)
|
||
await test_adf_conversion()
|
||
print(f"\n🎉 MILESTONE 17 PASSED — Jira API client working. Test ticket: {issue_key}")
|
||
|
||
|
||
asyncio.run(main())
|
||
```
|
||
|
||
Run: `cd thirdeye && python scripts/test_m17.py`
|
||
|
||
**Expected output:**
|
||
```
|
||
✅ JIRA_BASE_URL: https://your-org.atlassian.net...
|
||
✅ JIRA_EMAIL: your-email@example.com...
|
||
✅ JIRA_API_TOKEN: (token)...
|
||
✅ JIRA_DEFAULT_PROJECT: ENG...
|
||
✅ Connected as: Your Name (your-email@example.com)
|
||
✅ Found 3 project(s): [ENG] Engineering, [PRD] Product, ...
|
||
✅ Default project 'ENG' exists
|
||
✅ Issue types in 'ENG': ['Task', 'Bug', 'Story', 'Epic']
|
||
✅ Created issue: ENG-42
|
||
URL: https://your-org.atlassian.net/browse/ENG-42
|
||
✅ Retrieved issue: [ENG-42] [ThirdEye Test] Milestone 17 verification ticket
|
||
✅ JQL search returned 1 result(s), including ENG-42
|
||
✅ Comment added to ENG-42
|
||
✅ ADF produced 3 content block(s)
|
||
|
||
🎉 MILESTONE 17 PASSED — Jira API client working. Test ticket: ENG-42
|
||
```
|
||
|
||
---
|
||
|
||
## MILESTONE 18: Jira Signal Agent (140%)
|
||
|
||
**Goal:** An LLM-powered agent that takes any ThirdEye signal — from chat, documents, links, or Meet — and produces a complete, ready-to-file Jira ticket. It decides the correct issue type, priority, title, and description automatically. It also handles bulk-raise: given a list of unraised signals from a group, it filters for raiseable ones, deduplicates against existing tickets, and raises them all.
|
||
|
||
### Step 18.1 — Create the Jira Signal Agent
|
||
|
||
Create file: `thirdeye/backend/agents/jira_agent.py`
|
||
```python
|
||
"""
|
||
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
|
||
)
|
||
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)
|
||
SIGNAL_TYPE_MAP = {
|
||
# Dev signals
|
||
"tech_debt": ("Task", "Low"),
|
||
"recurring_bug": ("Bug", "High"),
|
||
"architecture_decision": ("Task", "Medium"),
|
||
"deployment_risk": ("Task", "High"),
|
||
"workaround": ("Task", "Medium"),
|
||
"knowledge_silo": ("Task", "Medium"),
|
||
# Product signals
|
||
"feature_request": ("Story", "Medium"),
|
||
"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())
|
||
|
||
|
||
# ─── 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,
|
||
) -> 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
|
||
|
||
# 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,
|
||
)
|
||
|
||
if result.get("ok"):
|
||
jira_key = result["key"]
|
||
# 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)
|
||
logger.info(f"Raised Jira ticket {jira_key} for signal {signal_id} ({signal_type})")
|
||
return {
|
||
"ok": True,
|
||
"key": jira_key,
|
||
"url": result["url"],
|
||
"summary": summary,
|
||
"issue_type": default_type,
|
||
"priority": priority,
|
||
}
|
||
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)}"
|
||
```
|
||
|
||
### ✅ TEST MILESTONE 18
|
||
|
||
Create file: `thirdeye/scripts/test_m18.py`
|
||
```python
|
||
"""
|
||
Test Milestone 18: Jira Signal Agent.
|
||
Seeds real signals and raises actual Jira tickets.
|
||
Requires Milestone 17 (Jira client) to be passing.
|
||
"""
|
||
import asyncio
|
||
import os
|
||
import sys
|
||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||
|
||
|
||
# ─── Sample signals ───────────────────────────────────────────────────────────
|
||
|
||
SAMPLE_SIGNALS = [
|
||
{
|
||
"id": "test-signal-001",
|
||
"type": "recurring_bug",
|
||
"summary": "Checkout endpoint hits intermittent timeout — third time this sprint. Restarting the pod is the workaround.",
|
||
"raw_quote": "Sam: Timeout error AGAIN. That's the third time. We have a systemic issue here.",
|
||
"severity": "high",
|
||
"status": "open",
|
||
"sentiment": "negative",
|
||
"urgency": "high",
|
||
"entities": ["@Sam", "@Alex"],
|
||
"keywords": ["timeout", "checkout", "pod", "systemic"],
|
||
"timestamp": "2026-03-21T09:00:00Z",
|
||
"group_id": "acme_dev",
|
||
"lens": "dev",
|
||
},
|
||
{
|
||
"id": "test-signal-002",
|
||
"type": "tech_debt",
|
||
"summary": "JWT secret is hardcoded in auth service. Will move to Vault later, no timeline set.",
|
||
"raw_quote": "Alex: For the auth service, I'm hardcoding the JWT secret for now. We'll move to vault later.",
|
||
"severity": "medium",
|
||
"status": "open",
|
||
"sentiment": "neutral",
|
||
"urgency": "low",
|
||
"entities": ["@Alex"],
|
||
"keywords": ["jwt", "hardcode", "vault", "auth", "secret"],
|
||
"timestamp": "2026-03-21T09:05:00Z",
|
||
"group_id": "acme_dev",
|
||
"lens": "dev",
|
||
},
|
||
{
|
||
"id": "test-signal-003",
|
||
"type": "meet_blocker",
|
||
"summary": "Dashboard spec has been blocked waiting on design for two weeks. Dev cannot start work.",
|
||
"raw_quote": "Alex: Still no dashboard specs from design. This is blocking my entire sprint work.",
|
||
"severity": "high",
|
||
"status": "open",
|
||
"sentiment": "negative",
|
||
"urgency": "high",
|
||
"entities": ["@Alex", "@design"],
|
||
"keywords": ["dashboard", "blocked", "design", "specs", "sprint"],
|
||
"timestamp": "2026-03-21T10:00:00Z",
|
||
"group_id": "meet_sessions",
|
||
"lens": "meet",
|
||
"meeting_id": "sprint-planning-test",
|
||
},
|
||
]
|
||
|
||
# A signal type that should NOT be raised (raw chunk is not a raiseable type)
|
||
NON_RAISEABLE_SIGNAL = {
|
||
"id": "test-signal-999",
|
||
"type": "meet_chunk_raw",
|
||
"summary": "Raw transcript chunk — should not be raised as a ticket",
|
||
"raw_quote": "...",
|
||
"severity": "low",
|
||
"status": "open",
|
||
"sentiment": "neutral",
|
||
"urgency": "none",
|
||
"entities": [],
|
||
"keywords": [],
|
||
"timestamp": "2026-03-21T10:00:00Z",
|
||
"group_id": "meet_sessions",
|
||
"lens": "meet",
|
||
}
|
||
|
||
|
||
async def test_ticket_generation():
|
||
"""Test that LLM generates a valid ticket from a signal."""
|
||
from backend.agents.jira_agent import generate_ticket_content
|
||
|
||
print("Testing LLM ticket content generation...")
|
||
signal = SAMPLE_SIGNALS[0] # recurring_bug
|
||
content = await generate_ticket_content(signal)
|
||
|
||
assert "summary" in content and len(content["summary"]) > 5, "Summary too short or missing"
|
||
assert len(content["summary"]) <= 100, f"Summary exceeds 100 chars: {len(content['summary'])}"
|
||
assert "description" in content and len(content["description"]) > 30, "Description too short"
|
||
assert "labels" in content and "thirdeye" in content["labels"], "Missing 'thirdeye' label"
|
||
assert "assignee_name" in content # can be None, that's fine
|
||
|
||
print(f" ✅ Summary ({len(content['summary'])} chars): {content['summary']}")
|
||
print(f" ✅ Description ({len(content['description'])} chars)")
|
||
print(f" ✅ Labels: {content['labels']}")
|
||
print(f" ✅ Assignee hint: {content.get('assignee_name')}")
|
||
|
||
|
||
async def test_raise_single_ticket():
|
||
"""Test raising a single ticket for a real signal."""
|
||
from backend.agents.jira_agent import raise_ticket_for_signal
|
||
|
||
print("\nTesting raise_ticket_for_signal()...")
|
||
signal = SAMPLE_SIGNALS[0] # recurring_bug, high severity
|
||
group_id = "test_jira_m18"
|
||
|
||
result = await raise_ticket_for_signal(signal, group_id, force=True)
|
||
|
||
assert result.get("ok"), f"raise_ticket_for_signal failed: {result}"
|
||
print(f" ✅ Ticket raised: {result['key']}")
|
||
print(f" URL: {result['url']}")
|
||
print(f" Type: {result['issue_type']} | Priority: {result['priority']}")
|
||
print(f" Summary: {result['summary'][:90]}")
|
||
return result["key"]
|
||
|
||
|
||
async def test_dedup_prevents_double_raise():
|
||
"""Test that the same signal cannot be raised twice."""
|
||
from backend.agents.jira_agent import raise_ticket_for_signal
|
||
from backend.db.chroma import mark_signal_as_raised
|
||
|
||
print("\nTesting dedup — cannot raise the same signal twice...")
|
||
signal = SAMPLE_SIGNALS[1] # tech_debt
|
||
group_id = "test_jira_m18_dedup"
|
||
|
||
# First raise
|
||
result1 = await raise_ticket_for_signal(signal, group_id, force=True)
|
||
assert result1.get("ok"), f"First raise failed: {result1}"
|
||
print(f" ✅ First raise succeeded: {result1['key']}")
|
||
|
||
# Second raise of the same signal — should be blocked
|
||
result2 = await raise_ticket_for_signal(signal, group_id, force=False)
|
||
assert not result2.get("ok"), "Expected second raise to be blocked"
|
||
assert result2.get("reason") == "already_raised", f"Expected 'already_raised', got: {result2.get('reason')}"
|
||
print(f" ✅ Second raise correctly blocked: reason='{result2['reason']}'")
|
||
|
||
|
||
async def test_non_raiseable_signal():
|
||
"""Test that non-raiseable signal types are rejected."""
|
||
from backend.agents.jira_agent import raise_ticket_for_signal
|
||
|
||
print("\nTesting non-raiseable signal type rejection...")
|
||
result = await raise_ticket_for_signal(NON_RAISEABLE_SIGNAL, "test_group", force=True)
|
||
assert not result.get("ok")
|
||
assert result.get("reason") == "not_raiseable"
|
||
print(f" ✅ Non-raiseable type correctly rejected: {NON_RAISEABLE_SIGNAL['type']}")
|
||
|
||
|
||
async def test_bulk_raise():
|
||
"""Test bulk raising multiple signals at once."""
|
||
from backend.agents.jira_agent import bulk_raise_for_group
|
||
|
||
print("\nTesting bulk_raise_for_group()...")
|
||
group_id = "test_jira_m18_bulk"
|
||
|
||
# Mix of raiseable and non-raiseable, different severities
|
||
all_signals = SAMPLE_SIGNALS + [NON_RAISEABLE_SIGNAL]
|
||
results = await bulk_raise_for_group(
|
||
group_id=group_id,
|
||
signals=all_signals,
|
||
min_severity="medium", # low severity signals should be skipped
|
||
max_tickets=5,
|
||
)
|
||
|
||
raised = [r for r in results if r.get("ok")]
|
||
skipped_type = [r for r in results if r.get("reason") == "not_raiseable"]
|
||
|
||
assert len(raised) >= 1, "Expected at least 1 ticket raised from bulk"
|
||
print(f" ✅ Bulk raised {len(raised)} ticket(s) from {len(all_signals)} signals")
|
||
for r in raised:
|
||
print(f" [{r['key']}] {r.get('signal_type')} — {r.get('signal_summary', '')[:60]}")
|
||
if skipped_type:
|
||
print(f" ✅ {len(skipped_type)} non-raiseable signal(s) correctly skipped")
|
||
|
||
|
||
async def test_priority_mapping():
|
||
"""Test that signal severity maps to correct Jira priority."""
|
||
from backend.agents.jira_agent import SEVERITY_TO_PRIORITY, SIGNAL_TYPE_MAP
|
||
|
||
print("\nTesting priority and type mapping...")
|
||
assert SEVERITY_TO_PRIORITY["critical"] == "Highest"
|
||
assert SEVERITY_TO_PRIORITY["high"] == "High"
|
||
assert SEVERITY_TO_PRIORITY["medium"] == "Medium"
|
||
assert SEVERITY_TO_PRIORITY["low"] == "Low"
|
||
print(" ✅ Severity → Priority mapping correct")
|
||
|
||
assert SIGNAL_TYPE_MAP["recurring_bug"] == ("Bug", "High")
|
||
assert SIGNAL_TYPE_MAP["meet_blocker"] == ("Task", "Highest")
|
||
assert SIGNAL_TYPE_MAP["feature_request"] == ("Story", "Medium")
|
||
print(" ✅ Signal type → Jira type mapping correct")
|
||
|
||
|
||
async def main():
|
||
print("Running Milestone 18 tests...\n")
|
||
await test_priority_mapping()
|
||
await test_ticket_generation()
|
||
key = await test_raise_single_ticket()
|
||
await test_dedup_prevents_double_raise()
|
||
await test_non_raiseable_signal()
|
||
await test_bulk_raise()
|
||
print(f"\n🎉 MILESTONE 18 PASSED — Jira Signal Agent working. First ticket: {key}")
|
||
|
||
|
||
asyncio.run(main())
|
||
```
|
||
|
||
Run: `cd thirdeye && python scripts/test_m18.py`
|
||
|
||
**Expected output:**
|
||
```
|
||
✅ Summary (62 chars): Investigate recurring checkout timeout — third occurrence this sprint
|
||
✅ Description (340 chars)
|
||
✅ Labels: ['thirdeye', 'auto-raised', 'recurring-bug', 'urgent']
|
||
✅ Assignee hint: Sam
|
||
✅ Ticket raised: ENG-43
|
||
Type: Bug | Priority: High
|
||
✅ First raise succeeded: ENG-44
|
||
✅ Second raise correctly blocked: reason='already_raised'
|
||
✅ Non-raiseable type correctly rejected: meet_chunk_raw
|
||
✅ Bulk raised 2 ticket(s) from 4 signals
|
||
|
||
🎉 MILESTONE 18 PASSED — Jira Signal Agent working. First ticket: ENG-43
|
||
```
|
||
|
||
---
|
||
|
||
## MILESTONE 19: Telegram Commands + Auto-Raise (145%)
|
||
|
||
**Goal:** Five new Telegram commands that expose the full Jira workflow to your team directly in chat — raise tickets from signals, check ticket status, search Jira, see what ThirdEye has already raised, and enable auto-raise mode that files high-severity signals automatically as they are detected.
|
||
|
||
### Step 19.1 — Add Jira commands to commands.py
|
||
|
||
Add to `thirdeye/backend/bot/commands.py`:
|
||
```python
|
||
# ─────────────────────────────────────────────
|
||
# Jira Commands — add to existing commands.py
|
||
# ─────────────────────────────────────────────
|
||
|
||
async def cmd_jira(update, context):
|
||
"""
|
||
/jira
|
||
Shows unraised high-severity signals in this group and raises them as Jira tickets.
|
||
With no args: shows a preview of what would be raised and asks for confirmation.
|
||
With 'confirm': actually raises the tickets.
|
||
|
||
Usage:
|
||
/jira — preview unraised signals
|
||
/jira confirm — raise all unraised high+ severity signals now
|
||
/jira [signal_type] — raise only signals of that type (e.g. /jira recurring_bug)
|
||
"""
|
||
from backend.db.chroma import get_all_signals, get_raised_signal_ids
|
||
from backend.agents.jira_agent import (
|
||
bulk_raise_for_group, RAISEABLE_TYPES, SEVERITY_TO_PRIORITY, format_raise_result_for_telegram
|
||
)
|
||
from backend.integrations.jira_client import is_configured
|
||
from backend.config import JIRA_DEFAULT_PROJECT
|
||
|
||
chat_id = str(update.effective_chat.id)
|
||
args = context.args or []
|
||
confirm = "confirm" in [a.lower() for a in args]
|
||
type_filter = next((a for a in args if a.lower() not in ("confirm",) and "_" in a), None)
|
||
|
||
if not is_configured():
|
||
await update.message.reply_text(
|
||
"⚙️ Jira is not configured. Add `JIRA_BASE_URL`, `JIRA_EMAIL`, and `JIRA_API_TOKEN` to your .env and restart."
|
||
)
|
||
return
|
||
|
||
await update.message.reply_text("🔍 Scanning signals...", parse_mode="Markdown")
|
||
|
||
all_signals = get_all_signals(chat_id)
|
||
already_raised = get_raised_signal_ids(chat_id)
|
||
severity_rank = {"low": 0, "medium": 1, "high": 2, "critical": 3}
|
||
|
||
candidates = [
|
||
s for s in all_signals
|
||
if s.get("type") in RAISEABLE_TYPES
|
||
and s.get("id", "") not in already_raised
|
||
and severity_rank.get(s.get("severity", "low"), 0) >= 2 # high+
|
||
and (not type_filter or s.get("type") == type_filter)
|
||
]
|
||
|
||
if not candidates:
|
||
await update.message.reply_text(
|
||
"✅ Nothing to raise — no unraised high-severity signals found in this group.\n"
|
||
"Use `/ask` to query existing knowledge or wait for new signals.",
|
||
parse_mode="Markdown",
|
||
)
|
||
return
|
||
|
||
if not confirm:
|
||
# Preview mode — show what would be raised
|
||
lines = [f"📋 *{len(candidates)} signal(s) ready to raise in `{JIRA_DEFAULT_PROJECT}`*\n"]
|
||
for s in candidates[:8]:
|
||
lines.append(
|
||
f"• [{s.get('severity','?').upper()}] `{s.get('type','?')}` — "
|
||
f"_{s.get('summary','')[:80]}_"
|
||
)
|
||
if len(candidates) > 8:
|
||
lines.append(f"_...and {len(candidates) - 8} more_")
|
||
lines.append(f"\nSend `/jira confirm` to raise all of these as Jira tickets.")
|
||
await update.message.reply_text("\n".join(lines), parse_mode="Markdown")
|
||
return
|
||
|
||
# Confirm mode — raise the tickets
|
||
await update.message.reply_text(
|
||
f"🚀 Raising {len(candidates)} ticket(s) in `{JIRA_DEFAULT_PROJECT}`...",
|
||
parse_mode="Markdown",
|
||
)
|
||
results = await bulk_raise_for_group(
|
||
group_id=chat_id,
|
||
signals=candidates,
|
||
min_severity="high",
|
||
max_tickets=10,
|
||
)
|
||
|
||
raised = [r for r in results if r.get("ok")]
|
||
failed = [r for r in results if not r.get("ok") and r.get("reason") not in ("already_raised", "not_raiseable")]
|
||
|
||
lines = [f"🎫 *Jira Tickets Raised* ({len(raised)}/{len(candidates)})\n"]
|
||
for r in raised:
|
||
lines.append(format_raise_result_for_telegram(r))
|
||
if failed:
|
||
lines.append(f"\n⚠️ {len(failed)} failed — check logs")
|
||
|
||
await update.message.reply_text("\n".join(lines), parse_mode="Markdown")
|
||
|
||
|
||
async def cmd_jirastatus(update, context):
|
||
"""
|
||
/jirastatus [TICKET-KEY]
|
||
Get the current status of a specific Jira ticket.
|
||
|
||
Usage:
|
||
/jirastatus ENG-42
|
||
"""
|
||
from backend.integrations.jira_client import get_issue, is_configured
|
||
|
||
if not is_configured():
|
||
await update.message.reply_text("⚙️ Jira not configured.")
|
||
return
|
||
|
||
args = context.args or []
|
||
if not args:
|
||
await update.message.reply_text(
|
||
"Usage: `/jirastatus TICKET-KEY`\nExample: `/jirastatus ENG-42`",
|
||
parse_mode="Markdown",
|
||
)
|
||
return
|
||
|
||
issue_key = args[0].upper()
|
||
try:
|
||
issue = await get_issue(issue_key)
|
||
status_emoji = {"To Do": "🔵", "In Progress": "🟡", "Done": "✅", "Blocked": "🔴"}.get(
|
||
issue["status"], "⚪"
|
||
)
|
||
msg = (
|
||
f"🎫 *{issue['key']}*\n"
|
||
f"{status_emoji} Status: *{issue['status']}*\n"
|
||
f"📌 Type: {issue['issue_type']} | ⚡ Priority: {issue['priority']}\n"
|
||
f"👤 Assignee: {issue['assignee']}\n"
|
||
f"📝 {issue['summary']}\n\n"
|
||
f"🔗 [Open in Jira]({issue['url']})"
|
||
)
|
||
await update.message.reply_text(msg, parse_mode="Markdown")
|
||
except Exception as e:
|
||
await update.message.reply_text(f"❌ Could not fetch `{issue_key}`: {e}", parse_mode="Markdown")
|
||
|
||
|
||
async def cmd_jirasearch(update, context):
|
||
"""
|
||
/jirasearch [query]
|
||
Search Jira using natural language. ThirdEye converts it to JQL automatically.
|
||
|
||
Usage:
|
||
/jirasearch open bugs assigned to Alex
|
||
/jirasearch all thirdeye tickets from this sprint
|
||
"""
|
||
from backend.integrations.jira_client import search_issues, is_configured
|
||
from backend.providers import call_llm
|
||
from backend.config import JIRA_DEFAULT_PROJECT
|
||
|
||
if not is_configured():
|
||
await update.message.reply_text("⚙️ Jira not configured.")
|
||
return
|
||
|
||
args = context.args or []
|
||
if not args:
|
||
await update.message.reply_text(
|
||
"Usage: `/jirasearch [natural language query]`\n"
|
||
"Example: `/jirasearch open bugs assigned to Alex`",
|
||
parse_mode="Markdown",
|
||
)
|
||
return
|
||
|
||
query = " ".join(args)
|
||
await update.message.reply_text(f"🔍 Searching Jira for: _{query}_...", parse_mode="Markdown")
|
||
|
||
# Convert natural language → JQL via LLM
|
||
try:
|
||
jql_result = await call_llm(
|
||
task_type="fast_small",
|
||
messages=[
|
||
{
|
||
"role": "system",
|
||
"content": (
|
||
f"Convert the user's natural language query into a valid Jira JQL query. "
|
||
f"Default project is '{JIRA_DEFAULT_PROJECT}'. "
|
||
"Return ONLY the JQL string — no explanation, no quotes around it, no markdown."
|
||
),
|
||
},
|
||
{"role": "user", "content": query},
|
||
],
|
||
temperature=0.0,
|
||
max_tokens=150,
|
||
)
|
||
jql = jql_result["content"].strip().strip('"').strip("'")
|
||
except Exception:
|
||
# Fallback: simple text search JQL
|
||
jql = f'project = {JIRA_DEFAULT_PROJECT} AND text ~ "{query}" ORDER BY created DESC'
|
||
|
||
try:
|
||
results = await search_issues(jql, max_results=8)
|
||
except Exception as e:
|
||
await update.message.reply_text(
|
||
f"❌ JQL search failed.\nJQL used: `{jql}`\nError: {e}",
|
||
parse_mode="Markdown",
|
||
)
|
||
return
|
||
|
||
if not results:
|
||
await update.message.reply_text(
|
||
f"📭 No results found.\nJQL: `{jql}`",
|
||
parse_mode="Markdown",
|
||
)
|
||
return
|
||
|
||
status_emoji = {"To Do": "🔵", "In Progress": "🟡", "Done": "✅"}.get
|
||
lines = [f"🔍 *Jira Search Results* ({len(results)} found)\nQuery: _{query}_\n"]
|
||
for r in results:
|
||
emoji = {"To Do": "🔵", "In Progress": "🟡", "Done": "✅"}.get(r["status"], "⚪")
|
||
lines.append(
|
||
f"{emoji} [{r['key']}]({r['url']}) — _{r['summary'][:70]}_\n"
|
||
f" {r['status']} | {r['priority']} | {r['assignee']}"
|
||
)
|
||
await update.message.reply_text("\n".join(lines), parse_mode="Markdown")
|
||
|
||
|
||
async def cmd_jiraraised(update, context):
|
||
"""
|
||
/jiraraised
|
||
Shows all tickets ThirdEye has previously raised for this group.
|
||
"""
|
||
from backend.db.chroma import query_signals
|
||
from backend.integrations.jira_client import is_configured
|
||
from backend.config import JIRA_BASE_URL
|
||
|
||
if not is_configured():
|
||
await update.message.reply_text("⚙️ Jira not configured.")
|
||
return
|
||
|
||
chat_id = str(update.effective_chat.id)
|
||
raised_signals = query_signals(chat_id, "jira raised ticket", n_results=20, signal_type="jira_raised")
|
||
|
||
if not raised_signals:
|
||
await update.message.reply_text(
|
||
"📭 No tickets raised yet for this group. Use `/jira confirm` to raise tickets.",
|
||
parse_mode="Markdown",
|
||
)
|
||
return
|
||
|
||
lines = [f"🎫 *Tickets Raised by ThirdEye* ({len(raised_signals)} total)\n"]
|
||
for sig in raised_signals[:10]:
|
||
entities = sig.get("entities", [])
|
||
jira_key = entities[0] if entities else "Unknown"
|
||
url = f"{JIRA_BASE_URL}/browse/{jira_key}"
|
||
lines.append(f"• [{jira_key}]({url}) — _{sig.get('summary', '')[:80]}_")
|
||
|
||
if len(raised_signals) > 10:
|
||
lines.append(f"_...and {len(raised_signals) - 10} more_")
|
||
|
||
await update.message.reply_text("\n".join(lines), parse_mode="Markdown")
|
||
|
||
|
||
async def cmd_jirawatch(update, context):
|
||
"""
|
||
/jirawatch [on|off]
|
||
Enable or disable auto-raise mode for this group.
|
||
When ON: any new signal at or above JIRA_AUTO_RAISE_SEVERITY is automatically
|
||
filed as a Jira ticket without manual /jira confirm.
|
||
|
||
Usage:
|
||
/jirawatch on
|
||
/jirawatch off
|
||
/jirawatch — show current status
|
||
"""
|
||
import json
|
||
from telegram.ext import ContextTypes
|
||
|
||
args = context.args or []
|
||
chat_id = str(update.effective_chat.id)
|
||
|
||
# Store watch state in bot_data (in-memory; persists until restart)
|
||
if not hasattr(context, "bot_data"):
|
||
context.bot_data = {}
|
||
watch_key = f"jirawatch_{chat_id}"
|
||
|
||
if not args:
|
||
current = context.bot_data.get(watch_key, False)
|
||
status = "🟢 ON" if current else "🔴 OFF"
|
||
await update.message.reply_text(
|
||
f"👁️ *Jira Auto-Raise* — {status}\n\n"
|
||
"When ON, ThirdEye automatically raises Jira tickets for any high or critical "
|
||
"severity signal detected in this group — no `/jira confirm` needed.\n\n"
|
||
"Use `/jirawatch on` or `/jirawatch off` to toggle.",
|
||
parse_mode="Markdown",
|
||
)
|
||
return
|
||
|
||
mode = args[0].lower()
|
||
if mode == "on":
|
||
context.bot_data[watch_key] = True
|
||
await update.message.reply_text(
|
||
"🟢 *Jira Auto-Raise: ON*\n"
|
||
"Any new `high` or `critical` signal detected in this group will automatically "
|
||
"be raised as a Jira ticket. You'll be notified here.\n\n"
|
||
"Use `/jirawatch off` to disable.",
|
||
parse_mode="Markdown",
|
||
)
|
||
elif mode == "off":
|
||
context.bot_data[watch_key] = False
|
||
await update.message.reply_text(
|
||
"🔴 *Jira Auto-Raise: OFF*\n"
|
||
"Use `/jira confirm` to raise tickets manually.",
|
||
parse_mode="Markdown",
|
||
)
|
||
else:
|
||
await update.message.reply_text("Usage: `/jirawatch on` or `/jirawatch off`", parse_mode="Markdown")
|
||
```
|
||
|
||
### Step 19.2 — Add auto-raise hook to the signal processing pipeline
|
||
|
||
In `thirdeye/backend/pipeline.py`, find your `process_message_batch` function and add an auto-raise call after signals are stored. Add at the bottom of the function body (after `store_signals` is called):
|
||
|
||
```python
|
||
# Append inside process_message_batch(), after store_signals() call:
|
||
# ─── Auto-raise Jira tickets for critical signals ─────────────────────────────
|
||
from backend.config import JIRA_AUTO_RAISE, ENABLE_JIRA
|
||
|
||
if ENABLE_JIRA and JIRA_AUTO_RAISE and signals:
|
||
from backend.agents.jira_agent import bulk_raise_for_group
|
||
import asyncio
|
||
|
||
critical_signals = [
|
||
s for s in signals
|
||
if s.get("severity", "low") in ("high", "critical")
|
||
]
|
||
if critical_signals:
|
||
asyncio.create_task(
|
||
_auto_raise_and_notify(group_id, critical_signals)
|
||
)
|
||
```
|
||
|
||
Add this helper function anywhere in `pipeline.py`:
|
||
|
||
```python
|
||
async def _auto_raise_and_notify(group_id: str, signals: list[dict]):
|
||
"""
|
||
Background task: raise Jira tickets for critical signals and log results.
|
||
Called automatically when JIRA_AUTO_RAISE=true in .env.
|
||
Does NOT send Telegram messages (no bot context here) — check logs or /jiraraised.
|
||
"""
|
||
import logging
|
||
logger = logging.getLogger("thirdeye.pipeline.auto_raise")
|
||
|
||
try:
|
||
from backend.agents.jira_agent import bulk_raise_for_group
|
||
results = await bulk_raise_for_group(
|
||
group_id=group_id,
|
||
signals=signals,
|
||
min_severity="high",
|
||
max_tickets=5,
|
||
)
|
||
raised = [r for r in results if r.get("ok")]
|
||
if raised:
|
||
logger.info(
|
||
f"[Auto-raise] Group {group_id}: {len(raised)} ticket(s) raised — "
|
||
+ ", ".join(r.get("key", "?") for r in raised)
|
||
)
|
||
except Exception as e:
|
||
logging.getLogger("thirdeye.pipeline.auto_raise").error(f"Auto-raise failed: {e}")
|
||
```
|
||
|
||
### Step 19.3 — Register all five Jira commands in bot.py
|
||
|
||
In `thirdeye/backend/bot/bot.py`, add alongside existing command registrations:
|
||
|
||
```python
|
||
from backend.bot.commands import (
|
||
cmd_jira, cmd_jirastatus, cmd_jirasearch, cmd_jiraraised, cmd_jirawatch
|
||
)
|
||
|
||
application.add_handler(CommandHandler("jira", cmd_jira))
|
||
application.add_handler(CommandHandler("jirastatus", cmd_jirastatus))
|
||
application.add_handler(CommandHandler("jirasearch", cmd_jirasearch))
|
||
application.add_handler(CommandHandler("jiraraised", cmd_jiraraised))
|
||
application.add_handler(CommandHandler("jirawatch", cmd_jirawatch))
|
||
```
|
||
|
||
Update your `/start` message to list the new commands:
|
||
```python
|
||
"🎫 /jira — Preview & raise Jira tickets from signals\n"
|
||
"📋 /jirastatus [KEY] — Get ticket status (e.g. ENG-42)\n"
|
||
"🔍 /jirasearch [query] — Search Jira in plain English\n"
|
||
"📜 /jiraraised — See all tickets ThirdEye has raised\n"
|
||
"👁️ /jirawatch [on|off] — Toggle auto-raise mode\n"
|
||
```
|
||
|
||
### Step 19.4 — Register commands with BotFather (optional but recommended)
|
||
|
||
```
|
||
In BotFather → /setcommands → add:
|
||
jira - Preview and raise Jira tickets from group signals
|
||
jirastatus - Get the status of a specific Jira ticket
|
||
jirasearch - Search Jira using plain English
|
||
jiraraised - See all tickets ThirdEye has raised for this group
|
||
jirawatch - Toggle automatic Jira ticket creation for high-severity signals
|
||
```
|
||
|
||
### ✅ TEST MILESTONE 19
|
||
|
||
Create file: `thirdeye/scripts/test_m19.py`
|
||
```python
|
||
"""
|
||
Test Milestone 19: Telegram commands + auto-raise.
|
||
Tests command logic directly without a live bot context.
|
||
Requires Milestones 17 and 18 to be passing.
|
||
"""
|
||
import asyncio
|
||
import os
|
||
import sys
|
||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
||
|
||
|
||
async def test_all_commands_importable():
|
||
"""Test that all five Jira command handlers import without errors."""
|
||
print("Testing command imports...")
|
||
try:
|
||
from backend.bot.commands import (
|
||
cmd_jira, cmd_jirastatus, cmd_jirasearch,
|
||
cmd_jiraraised, cmd_jirawatch
|
||
)
|
||
for name in ["cmd_jira", "cmd_jirastatus", "cmd_jirasearch", "cmd_jiraraised", "cmd_jirawatch"]:
|
||
print(f" ✅ {name} importable")
|
||
except ImportError as e:
|
||
print(f" ❌ Import failed: {e}")
|
||
raise
|
||
|
||
|
||
async def test_jql_generation():
|
||
"""Test that natural language is converted to JQL correctly."""
|
||
from backend.providers import call_llm
|
||
from backend.config import JIRA_DEFAULT_PROJECT
|
||
|
||
print("\nTesting natural language → JQL conversion...")
|
||
queries = [
|
||
"open bugs assigned to Alex",
|
||
"all thirdeye tickets",
|
||
"high priority tasks created this week",
|
||
]
|
||
for query in queries:
|
||
try:
|
||
result = await call_llm(
|
||
task_type="fast_small",
|
||
messages=[
|
||
{
|
||
"role": "system",
|
||
"content": (
|
||
f"Convert the user's natural language query into a valid Jira JQL query. "
|
||
f"Default project is '{JIRA_DEFAULT_PROJECT}'. "
|
||
"Return ONLY the JQL string — no explanation, no quotes, no markdown."
|
||
),
|
||
},
|
||
{"role": "user", "content": query},
|
||
],
|
||
temperature=0.0,
|
||
max_tokens=100,
|
||
)
|
||
jql = result["content"].strip()
|
||
assert len(jql) > 5, f"JQL too short for query '{query}': {jql}"
|
||
assert "=" in jql or "~" in jql or "ORDER" in jql.upper(), \
|
||
f"JQL doesn't look valid for '{query}': {jql}"
|
||
print(f" ✅ '{query}'\n → {jql}")
|
||
except Exception as e:
|
||
print(f" ⚠️ JQL generation failed for '{query}': {e} (non-fatal — fallback exists)")
|
||
|
||
|
||
async def test_preview_mode_logic():
|
||
"""Test /jira preview — filters to unraised high-severity signals."""
|
||
from backend.db.chroma import store_signals, get_all_signals, get_raised_signal_ids
|
||
from backend.agents.jira_agent import RAISEABLE_TYPES
|
||
import chromadb
|
||
from backend.config import CHROMA_DB_PATH
|
||
import uuid
|
||
|
||
print("\nTesting /jira preview mode filtering...")
|
||
group_id = "test_jira_m19_preview"
|
||
|
||
# Seed signals at different severities
|
||
signals = [
|
||
{
|
||
"id": str(uuid.uuid4()), "type": "recurring_bug",
|
||
"summary": "Checkout timeout — HIGH severity", "raw_quote": "...",
|
||
"severity": "high", "status": "open", "sentiment": "negative",
|
||
"urgency": "high", "entities": [], "keywords": ["checkout", "timeout"],
|
||
"timestamp": "2026-03-21T10:00:00Z", "group_id": group_id, "lens": "dev",
|
||
},
|
||
{
|
||
"id": str(uuid.uuid4()), "type": "tech_debt",
|
||
"summary": "TODO comment in auth module — LOW severity", "raw_quote": "...",
|
||
"severity": "low", "status": "open", "sentiment": "neutral",
|
||
"urgency": "none", "entities": [], "keywords": ["todo", "auth"],
|
||
"timestamp": "2026-03-21T10:01:00Z", "group_id": group_id, "lens": "dev",
|
||
},
|
||
]
|
||
store_signals(group_id, signals)
|
||
|
||
all_sig = get_all_signals(group_id)
|
||
already_raised = get_raised_signal_ids(group_id)
|
||
severity_rank = {"low": 0, "medium": 1, "high": 2, "critical": 3}
|
||
|
||
candidates = [
|
||
s for s in all_sig
|
||
if s.get("type") in RAISEABLE_TYPES
|
||
and s.get("id", "") not in already_raised
|
||
and severity_rank.get(s.get("severity", "low"), 0) >= 2
|
||
]
|
||
|
||
assert len(candidates) == 1, f"Expected 1 high-severity candidate, got {len(candidates)}"
|
||
assert candidates[0].get("type") == "recurring_bug"
|
||
print(f" ✅ Preview filtered correctly: 1 high-severity signal, 1 low-severity skipped")
|
||
|
||
# Cleanup
|
||
client = chromadb.PersistentClient(path=CHROMA_DB_PATH)
|
||
try:
|
||
client.delete_collection(f"ll_{group_id}")
|
||
except Exception:
|
||
pass
|
||
|
||
|
||
async def test_format_raise_result():
|
||
"""Test the Telegram message formatter for raise results."""
|
||
from backend.agents.jira_agent import format_raise_result_for_telegram
|
||
from backend.config import JIRA_BASE_URL
|
||
|
||
print("\nTesting raise result formatter...")
|
||
|
||
# Successful raise
|
||
result_ok = {
|
||
"ok": True,
|
||
"key": "ENG-99",
|
||
"url": f"{JIRA_BASE_URL}/browse/ENG-99",
|
||
"summary": "Fix intermittent checkout timeout",
|
||
"issue_type": "Bug",
|
||
"priority": "High",
|
||
}
|
||
formatted_ok = format_raise_result_for_telegram(result_ok)
|
||
assert "ENG-99" in formatted_ok
|
||
assert "Bug" in formatted_ok
|
||
assert "High" in formatted_ok
|
||
print(f" ✅ Success format: {formatted_ok[:120]}")
|
||
|
||
# Already raised
|
||
result_dup = {"ok": False, "reason": "already_raised"}
|
||
formatted_dup = format_raise_result_for_telegram(result_dup)
|
||
assert "Already raised" in formatted_dup or "skipped" in formatted_dup.lower()
|
||
print(f" ✅ Duplicate format: {formatted_dup}")
|
||
|
||
# Not raiseable
|
||
result_no = {"ok": False, "reason": "not_raiseable", "signal_type": "meet_chunk_raw"}
|
||
formatted_no = format_raise_result_for_telegram(result_no)
|
||
assert "meet_chunk_raw" in formatted_no or "not" in formatted_no.lower()
|
||
print(f" ✅ Not-raiseable format: {formatted_no}")
|
||
|
||
|
||
async def test_auto_raise_pipeline_wiring():
|
||
"""Test that pipeline.py has the auto-raise hook without importing bot context."""
|
||
import inspect
|
||
import importlib
|
||
|
||
print("\nTesting auto-raise hook in pipeline.py...")
|
||
try:
|
||
import backend.pipeline as pipeline_module
|
||
source = inspect.getsource(pipeline_module)
|
||
assert "JIRA_AUTO_RAISE" in source, "JIRA_AUTO_RAISE check not found in pipeline.py"
|
||
assert "_auto_raise_and_notify" in source, "_auto_raise_and_notify not found in pipeline.py"
|
||
print(" ✅ JIRA_AUTO_RAISE hook present in pipeline.py")
|
||
print(" ✅ _auto_raise_and_notify function present")
|
||
except Exception as e:
|
||
print(f" ⚠️ Could not inspect pipeline.py: {e}")
|
||
print(" Make sure you added the auto-raise hook to backend/pipeline.py")
|
||
|
||
|
||
async def test_end_to_end_raise_from_pipeline():
|
||
"""
|
||
Integration test: process messages → signals extracted → Jira ticket raised automatically.
|
||
Uses JIRA_AUTO_RAISE=false (manual mode) but calls bulk_raise directly to verify the chain.
|
||
"""
|
||
from backend.pipeline import process_message_batch, set_lens
|
||
from backend.db.chroma import get_all_signals
|
||
from backend.agents.jira_agent import bulk_raise_for_group
|
||
import chromadb
|
||
from backend.config import CHROMA_DB_PATH
|
||
|
||
print("\nTesting end-to-end: chat → signals → Jira tickets...")
|
||
group_id = "test_jira_m19_e2e"
|
||
set_lens(group_id, "dev")
|
||
|
||
# Process messages that should generate raiseable signals
|
||
messages = [
|
||
{
|
||
"sender": "Sam",
|
||
"text": "The checkout timeout is happening again — fourth time. Production is affected. Critical bug.",
|
||
"timestamp": "2026-03-21T10:00:00Z",
|
||
},
|
||
{
|
||
"sender": "Alex",
|
||
"text": "OAuth secret is still hardcoded in config.py. We need to rotate it but nobody owns it.",
|
||
"timestamp": "2026-03-21T10:01:00Z",
|
||
},
|
||
]
|
||
extracted = await process_message_batch(group_id, messages)
|
||
print(f" ✅ {len(extracted)} signal(s) extracted from 2 messages")
|
||
|
||
all_sig = get_all_signals(group_id)
|
||
print(f" ✅ {len(all_sig)} total signal(s) in ChromaDB for group")
|
||
|
||
# Now raise tickets for the high-severity ones
|
||
results = await bulk_raise_for_group(
|
||
group_id=group_id,
|
||
signals=all_sig,
|
||
min_severity="high",
|
||
max_tickets=3,
|
||
)
|
||
raised = [r for r in results if r.get("ok")]
|
||
print(f" ✅ {len(raised)} ticket(s) raised from pipeline signals:")
|
||
for r in raised:
|
||
print(f" [{r['key']}] {r.get('signal_type')} — {r.get('signal_summary', '')[:60]}")
|
||
|
||
# Cleanup
|
||
client = chromadb.PersistentClient(path=CHROMA_DB_PATH)
|
||
try:
|
||
client.delete_collection(f"ll_{group_id}")
|
||
except Exception:
|
||
pass
|
||
|
||
assert len(raised) >= 0, "Test completed (0 raised is OK if signals were medium severity)"
|
||
print(" ✅ End-to-end pipeline → Jira raise verified")
|
||
|
||
|
||
async def main():
|
||
print("Running Milestone 19 tests...\n")
|
||
await test_all_commands_importable()
|
||
await test_jql_generation()
|
||
await test_preview_mode_logic()
|
||
await test_format_raise_result()
|
||
await test_auto_raise_pipeline_wiring()
|
||
await test_end_to_end_raise_from_pipeline()
|
||
print("\n🎉 MILESTONE 19 PASSED — All Jira commands working, auto-raise wired into pipeline")
|
||
|
||
|
||
asyncio.run(main())
|
||
```
|
||
|
||
Run: `cd thirdeye && python scripts/test_m19.py`
|
||
|
||
**Expected output:**
|
||
```
|
||
✅ cmd_jira importable
|
||
✅ cmd_jirastatus importable
|
||
✅ cmd_jirasearch importable
|
||
✅ cmd_jiraraised importable
|
||
✅ cmd_jirawatch importable
|
||
✅ 'open bugs assigned to Alex' → project = ENG AND assignee = "Alex" AND status != Done
|
||
✅ Preview filtered correctly: 1 high-severity signal, 1 low-severity skipped
|
||
✅ Success format: ✅ [ENG-99](...) — Bug | High priority ...
|
||
✅ JIRA_AUTO_RAISE hook present in pipeline.py
|
||
✅ 2 ticket(s) raised from pipeline signals
|
||
|
||
🎉 MILESTONE 19 PASSED — All Jira commands working, auto-raise wired into pipeline
|
||
```
|
||
|
||
---
|
||
|
||
## MILESTONE SUMMARY (Updated)
|
||
|
||
| # | Milestone | What You Have | % |
|
||
|---|---|---|---|
|
||
| 0 | Scaffolding | Folders, deps, env vars, all API keys | 0% |
|
||
| 1 | Provider Router | Multi-provider LLM calls with fallback | 10% |
|
||
| 2 | ChromaDB + Embeddings | Store and retrieve signals with vector search | 20% |
|
||
| 3 | Core Agents | Signal Extractor + Classifier + Context Detector | 30% |
|
||
| 4 | Full Pipeline | Messages → Extract → Classify → Store → Query | 45% |
|
||
| 5 | Intelligence Layer | Pattern detection + Cross-group analysis | 60% |
|
||
| 6 | Telegram Bot | Live bot processing group messages | 70% |
|
||
| 7 | FastAPI + Dashboard API | REST API serving all data | 85% |
|
||
| 8 | Unified Runner | Bot + API running together | 90% |
|
||
| 9 | Demo Data | 3 groups seeded with realistic data | 95% |
|
||
| 10 | Polish & Demo Ready | README, rehearsed demo, everything working | 100% |
|
||
| 11 | Document & PDF Ingestion | PDFs/DOCX/TXT shared in groups → chunked → stored in RAG | 105% |
|
||
| 12 | Tavily Web Search | Query Agent searches web when KB is empty | 110% |
|
||
| 13 | Link Fetch & Ingestion | URLs in messages → fetched → summarized → stored | 115% |
|
||
| 14 | Meet Chrome Extension | Extension captures Meet audio → transcribes → POSTs chunks | 120% |
|
||
| 15 | Meet Signal Processing | Transcript chunks → decisions/actions/blockers → ChromaDB | 125% |
|
||
| 16 | Meet Telegram Commands | /meetsum, /meetask, /meetmatch with cross-reference | 130% |
|
||
| **17** | **Jira API Client** | **Async Jira wrapper — connect, create, get, search, comment** | **135%** |
|
||
| **18** | **Jira Signal Agent** | **LLM converts ThirdEye signals → well-formed Jira tickets** | **140%** |
|
||
| **19** | **Jira Telegram Commands** | **/jira, /jirastatus, /jirasearch, /jiraraised, /jirawatch + auto-raise** | **145%** |
|
||
|
||
---
|
||
|
||
## FILE CHANGE SUMMARY
|
||
|
||
### New Files Created
|
||
```
|
||
thirdeye/backend/integrations/__init__.py # Milestone 17 — package init
|
||
thirdeye/backend/integrations/jira_client.py # Milestone 17 — Jira REST API client
|
||
thirdeye/backend/agents/jira_agent.py # Milestone 18 — signal → ticket agent
|
||
thirdeye/scripts/test_m17.py # Milestone 17 test
|
||
thirdeye/scripts/test_m18.py # Milestone 18 test
|
||
thirdeye/scripts/test_m19.py # Milestone 19 test
|
||
```
|
||
|
||
### Existing Files Modified
|
||
```
|
||
thirdeye/requirements.txt # Pre-work: verify httpx present
|
||
thirdeye/.env # Pre-work: 7 new JIRA_* vars
|
||
thirdeye/backend/config.py # Pre-work: new Jira config vars
|
||
thirdeye/backend/db/chroma.py # M17: mark_signal_as_raised(), get_raised_signal_ids()
|
||
thirdeye/backend/pipeline.py # M19: auto-raise hook + _auto_raise_and_notify()
|
||
thirdeye/backend/bot/commands.py # M19: 5 new Jira command handlers
|
||
thirdeye/backend/bot/bot.py # M19: register 5 CommandHandlers
|
||
```
|
||
|
||
### Updated Repo Structure (additions only)
|
||
```
|
||
thirdeye/
|
||
├── backend/
|
||
│ ├── integrations/ # NEW package
|
||
│ │ ├── __init__.py
|
||
│ │ └── jira_client.py # Async Jira REST API v3 client
|
||
│ │
|
||
│ ├── agents/
|
||
│ │ └── jira_agent.py # Signal → Jira ticket (LLM-powered)
|
||
│ │
|
||
│ ├── db/
|
||
│ │ └── chroma.py # MODIFIED — dedup helpers
|
||
│ │
|
||
│ ├── pipeline.py # MODIFIED — auto-raise hook
|
||
│ │
|
||
│ └── bot/
|
||
│ ├── commands.py # MODIFIED — 5 new Jira commands
|
||
│ └── bot.py # MODIFIED — 5 new CommandHandlers
|
||
│
|
||
└── scripts/
|
||
├── test_m17.py # Jira client tests
|
||
├── test_m18.py # Signal agent tests
|
||
└── test_m19.py # Command + auto-raise tests
|
||
```
|
||
|
||
---
|
||
|
||
## UPDATED COMMANDS REFERENCE
|
||
|
||
```
|
||
EXISTING (unchanged):
|
||
/start — Welcome message
|
||
/ask [q] — Query the knowledge base
|
||
/search [q] — Web search via Tavily
|
||
/digest — Intelligence summary
|
||
/lens [mode] — Set detection lens
|
||
/alerts — Active warnings
|
||
/meetsum [id] — Summarize a Google Meet recording
|
||
/meetask [id] [q]— Ask about a meeting
|
||
/meetmatch [id] — Match meeting to team chats
|
||
|
||
NEW — Jira Integration:
|
||
/jira — Preview unraised signals; /jira confirm to raise them all
|
||
/jirastatus [KEY] — Get live status of a ticket (e.g. /jirastatus ENG-42)
|
||
/jirasearch [query] — Search Jira in plain English, auto-converted to JQL
|
||
/jiraraised — See all tickets ThirdEye has already raised for this group
|
||
/jirawatch [on|off] — Toggle automatic ticket creation for high-severity signals
|
||
|
||
PASSIVE (no command needed, when JIRA_AUTO_RAISE=true):
|
||
• Any new high/critical severity signal → Jira ticket raised automatically
|
||
```
|
||
|
||
---
|
||
|
||
## HOW THE FULL JIRA FLOW WORKS (End-to-End)
|
||
|
||
```
|
||
1. ThirdEye detects a signal in any source:
|
||
Telegram chat → "Checkout timeout again, third time this sprint" → recurring_bug [HIGH]
|
||
Google Meet → "Dashboard blocked waiting on design specs" → meet_blocker [HIGH]
|
||
PDF shared → "JWT secret hardcoded in auth module" → tech_debt [MEDIUM]
|
||
|
||
2. Signal is stored in ChromaDB with type, severity, entities, keywords
|
||
|
||
3a. Manual flow (default):
|
||
Team member types /jira → preview of unraised high+ signals shown
|
||
Team member types /jira confirm → tickets created in Jira
|
||
|
||
3b. Auto-raise flow (JIRA_AUTO_RAISE=true):
|
||
Pipeline detects signal severity >= JIRA_AUTO_RAISE_SEVERITY
|
||
Calls bulk_raise_for_group() in background
|
||
Tickets created silently — check /jiraraised to see them
|
||
|
||
4. LLM generates the ticket:
|
||
- summary: "Fix intermittent checkout timeout — 3rd occurrence this sprint"
|
||
- description: What it is, what was said, why it matters, suggested next steps
|
||
- issue_type: Bug (for recurring_bug), Task (for blocker), Story (for feature_request)
|
||
- priority: High (for high severity), Highest (for blocker/critical)
|
||
- labels: [thirdeye, auto-raised, recurring-bug, urgent]
|
||
|
||
5. Ticket is created in Jira via REST API → team gets the key (ENG-42)
|
||
|
||
6. Signal is marked as raised in ChromaDB → no duplicate tickets ever
|
||
|
||
7. In Telegram:
|
||
/jirastatus ENG-42 → live status, assignee, priority
|
||
/jirasearch checkout bug → search with plain English
|
||
/jiraraised → full history of everything ThirdEye has raised
|
||
```
|
||
|
||
---
|
||
|
||
*Every milestone has a test. Every test must pass. No skipping.*
|