fix(jira): show test Jira tickets on technique page (correct entity model)
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

Techniques don't have their own Jira tickets — tickets exist on tests
and campaigns. The previous JiraLinkPanel entityType='technique' always
returned empty.

Backend: add entity_ids (list) filter to GET /jira/links so multiple
  test IDs can be fetched in a single request.
Frontend API: listJiraLinks() accepts entity_ids[] and serialises them
  as repeated query params (required by FastAPI List[UUID] parsing).
TechniqueDetailPage: replace JiraLinkPanel with TechniqueJiraSection —
  a dedicated read-only component that:
  - Takes technique.tests (already loaded)
  - Batch-fetches all test Jira links in one request
  - Shows test name + ticket key + status + priority + open-in-Jira link
  - Hides itself when no tickets exist (avoids empty panel)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kitos
2026-05-29 11:48:55 +02:00
parent e9aa473a6b
commit 2238ca671b
4 changed files with 116 additions and 6 deletions

View File

@@ -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<JiraLink[]> {
// 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<JiraLink[]>(`/jira/links?${p.toString()}`);
return data;
}
const { data } = await client.get<JiraLink[]>("/jira/links", { params });
return data;
}

View File

@@ -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<TechniqueStatus, string> = {
@@ -50,6 +51,102 @@ const testResultBadgeColors: Record<TestResult, string> = {
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<typeof Object.create> }) {
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<string, string> = {
"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 (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<h2 className="mb-4 flex items-center gap-2 text-lg font-semibold text-white">
<ExternalLink className="h-5 w-5 text-blue-400" />
Jira Tickets
</h2>
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-gray-500" />
</div>
) : (
<div className="space-y-2">
{links.map((link: JiraLink) => (
<div
key={link.id}
className="flex items-center justify-between rounded-lg border border-gray-700 bg-gray-800/30 px-4 py-3"
>
<div className="flex-1 min-w-0">
{/* Test name */}
<p className="text-xs text-gray-500 truncate mb-1">
{testById[link.entity_id] ?? "Test"}
</p>
{/* Jira ticket info */}
<div className="flex items-center gap-2 flex-wrap">
<span className="font-mono text-sm font-medium text-blue-400">
{link.jira_issue_key}
</span>
{link.jira_status && (
<span className={`rounded px-1.5 py-0.5 text-xs ${statusColors[link.jira_status] ?? "bg-gray-700 text-gray-300"}`}>
{link.jira_status}
</span>
)}
{link.jira_priority && (
<span className="text-xs text-gray-500">{link.jira_priority}</span>
)}
{link.jira_assignee && (
<span className="text-xs text-gray-500"> {link.jira_assignee}</span>
)}
</div>
</div>
<a
href={`${jiraBase}/browse/${link.jira_issue_key}`}
target="_blank"
rel="noopener noreferrer"
className="ml-3 shrink-0 rounded p-1.5 text-gray-500 hover:bg-gray-700 hover:text-blue-400 transition-colors"
title="Open in Jira"
>
<ExternalLink className="h-4 w-4" />
</a>
</div>
))}
</div>
)}
</div>
);
}
export default function TechniqueDetailPage() {
const { mitreId } = useParams<{ mitreId: string }>();
const navigate = useNavigate();
@@ -561,10 +658,8 @@ export default function TechniqueDetailPage() {
</div>
)}
{/* Jira Integration */}
{technique && (
<JiraLinkPanel entityType="technique" entityId={technique.id} readOnly label={technique.name} />
)}
{/* Jira Integration — shows tickets linked to tests of this technique */}
{technique && <TechniqueJiraSection technique={technique} />}
{/* Template instantiation modal */}
{templateFormId && technique && (