diff --git a/backend/app/routers/jira.py b/backend/app/routers/jira.py index ce5be9a..b369b01 100644 --- a/backend/app/routers/jira.py +++ b/backend/app/routers/jira.py @@ -72,14 +72,16 @@ def create_link( def list_links( entity_type: Optional[JiraLinkEntityType] = None, entity_id: Optional[UUID] = None, + entity_ids: Optional[list[UUID]] = Query(default=None, description="Filter by multiple entity IDs"), db: Session = Depends(get_db), user: User = Depends(get_current_user), ): - """List Jira links, optionally filtered by entity.""" + """List Jira links, optionally filtered by entity or a list of entity IDs.""" return jira_service.list_links( db, entity_type=entity_type, entity_id=entity_id, + entity_ids=entity_ids, ) diff --git a/backend/app/services/jira_service.py b/backend/app/services/jira_service.py index 3e1634c..4c3d55f 100644 --- a/backend/app/services/jira_service.py +++ b/backend/app/services/jira_service.py @@ -735,12 +735,15 @@ def list_links( *, entity_type: Optional[JiraLinkEntityType] = None, entity_id: Optional[UUID] = None, + entity_ids: Optional[list[UUID]] = None, ) -> list[JiraLink]: query = db.query(JiraLink) if entity_type: query = query.filter(JiraLink.entity_type == entity_type) if entity_id: query = query.filter(JiraLink.entity_id == entity_id) + elif entity_ids: + query = query.filter(JiraLink.entity_id.in_(entity_ids)) return query.order_by(JiraLink.created_at.desc()).all() diff --git a/frontend/src/api/jira.ts b/frontend/src/api/jira.ts index 1364912..916f30c 100644 --- a/frontend/src/api/jira.ts +++ b/frontend/src/api/jira.ts @@ -60,7 +60,17 @@ export async function createJiraLink( export async function listJiraLinks(params?: { entity_type?: JiraLinkEntityType; entity_id?: string; + /** Batch-fetch links for multiple entity IDs (passed as repeated query params). */ + entity_ids?: string[]; }): Promise { + // Axios doesn't repeat array params by default — build URLSearchParams manually + if (params?.entity_ids && params.entity_ids.length > 0) { + const p = new URLSearchParams(); + if (params.entity_type) p.append("entity_type", params.entity_type); + for (const id of params.entity_ids) p.append("entity_ids", id); + const { data } = await client.get(`/jira/links?${p.toString()}`); + return data; + } const { data } = await client.get("/jira/links", { params }); return data; } diff --git a/frontend/src/pages/TechniqueDetailPage.tsx b/frontend/src/pages/TechniqueDetailPage.tsx index 64751a6..b340913 100644 --- a/frontend/src/pages/TechniqueDetailPage.tsx +++ b/frontend/src/pages/TechniqueDetailPage.tsx @@ -21,9 +21,10 @@ import { } from "lucide-react"; import { getTechniqueByMitreId, markTechniqueReviewed } from "../api/techniques"; import { getTemplatesByTechnique } from "../api/test-templates"; +import { listJiraLinks, type JiraLink } from "../api/jira"; +import { getJiraConfig } from "../api/settings"; import { useAuth } from "../context/AuthContext"; import TestFromTemplateForm from "../components/TestFromTemplateForm"; -import JiraLinkPanel from "../components/JiraLinkPanel"; import type { TechniqueStatus, TestState, TestResult } from "../types/models"; const statusBadgeColors: Record = { @@ -50,6 +51,102 @@ const testResultBadgeColors: Record = { partially_detected: "bg-yellow-900/50 text-yellow-400 border-yellow-500/30", }; +/* ── TechniqueJiraSection ───────────────────────────────────────────── + Techniques don't have their own Jira tickets — tickets live on tests. + This component fetches all test-linked Jira tickets for this technique + in a single batched request and shows them read-only. + ─────────────────────────────────────────────────────────────────── */ +function TechniqueJiraSection({ technique }: { technique: ReturnType }) { + const tests: { id: string; name: string }[] = (technique as any).tests ?? []; + + const testIds = tests.map((t) => t.id); + + const { data: links = [], isLoading } = useQuery({ + queryKey: ["jira-links-technique", testIds], + queryFn: () => + listJiraLinks({ entity_type: "test", entity_ids: testIds }), + enabled: testIds.length > 0, + staleTime: 60_000, + }); + + const { data: jiraConfig } = useQuery({ + queryKey: ["jira-config"], + queryFn: getJiraConfig, + staleTime: 5 * 60 * 1000, + }); + + const jiraBase = jiraConfig?.url?.replace(/\/$/, "") ?? "https://jira.atlassian.com"; + + const statusColors: Record = { + "To Do": "bg-gray-700 text-gray-300", + "In Progress": "bg-blue-900/50 text-blue-400", + "Done": "bg-green-900/50 text-green-400", + }; + + // Build a map entity_id → test name + const testById = Object.fromEntries(tests.map((t) => [t.id, t.name])); + + // Only render if there's something to show (or we're loading) + if (!isLoading && links.length === 0) return null; + + return ( +
+

+ + Jira Tickets +

+ + {isLoading ? ( +
+ +
+ ) : ( +
+ {links.map((link: JiraLink) => ( +
+
+ {/* Test name */} +

+ {testById[link.entity_id] ?? "Test"} +

+ {/* Jira ticket info */} +
+ + {link.jira_issue_key} + + {link.jira_status && ( + + {link.jira_status} + + )} + {link.jira_priority && ( + {link.jira_priority} + )} + {link.jira_assignee && ( + → {link.jira_assignee} + )} +
+
+ + + +
+ ))} +
+ )} +
+ ); +} + export default function TechniqueDetailPage() { const { mitreId } = useParams<{ mitreId: string }>(); const navigate = useNavigate(); @@ -561,10 +658,8 @@ export default function TechniqueDetailPage() { )} - {/* Jira Integration */} - {technique && ( - - )} + {/* Jira Integration — shows tickets linked to tests of this technique */} + {technique && } {/* Template instantiation modal */} {templateFormId && technique && (