fix(jira): show test Jira tickets on technique page (correct entity model)
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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:
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user