feat(phase-35): Jira + Tempo integration with internal worklogs
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled

Full Jira/Tempo pipeline: link Aegis entities to Jira issues, auto-sync
status hourly, log time internally with integrity hashing, and optionally
push worklogs to Tempo.

- 1.1 JiraLink model + Worklog model: Alembic migration b020 with indexes,
  enums (jiralinkentitytype, jirasyncdirection), and integrity_hash column
- 1.2 Jira service: atlassian-python-api wrapper with lazy singleton client,
  search/create/sync operations, feature-flagged via JIRA_ENABLED
- 1.3 Jira router: CRUD endpoints for /jira/links, /jira/search,
  /jira/create-issue with audit logging and entity-to-issue auto-creation
- 1.4 Tempo service: worklog push via tempo-api-python-client, auto-log from
  test completions when TEMPO_ENABLED, graceful fallback on failure
- 1.5 Worklog service + router: immutable internal time records with SHA-256
  integrity hash, CRUD at /worklogs, /worklogs/{id}/verify endpoint
- 1.6 Frontend: JiraLinkPanel component (search, link, sync, unlink) and
  WorklogTimeline component (timeline view, manual log form) integrated into
  TestDetailPage sidebar, CampaignDetailPage grid, TechniqueDetailPage
- 1.7 Jira sync job: APScheduler hourly job syncs all links from Jira,
  registered in background scheduler alongside existing jobs
This commit is contained in:
2026-02-17 15:57:39 +01:00
parent 6d18a5417d
commit 9b98f60a9a
23 changed files with 1605 additions and 1 deletions

90
frontend/src/api/jira.ts Normal file
View File

@@ -0,0 +1,90 @@
import client from "./client";
// ── Types ───────────────────────────────────────────────────────────
export type JiraLinkEntityType = "test" | "technique" | "campaign" | "evidence";
export type JiraSyncDirection = "aegis_to_jira" | "jira_to_aegis" | "bidirectional";
export interface JiraLink {
id: string;
entity_type: JiraLinkEntityType;
entity_id: string;
jira_issue_key: string;
jira_issue_id: string | null;
jira_project_key: string | null;
jira_status: string | null;
jira_priority: string | null;
jira_assignee: string | null;
jira_story_points: string | null;
last_synced_at: string | null;
created_at: string;
}
export interface JiraIssueResult {
issue_key: string;
summary: string;
status: string;
assignee: string | null;
priority: string | null;
}
export interface JiraLinkCreatePayload {
entity_type: JiraLinkEntityType;
entity_id: string;
jira_issue_key: string;
sync_direction?: JiraSyncDirection;
}
// ── API Functions ───────────────────────────────────────────────────
/** Search Jira issues by JQL or free text. */
export async function searchJiraIssues(
q: string,
maxResults: number = 10,
): Promise<JiraIssueResult[]> {
const { data } = await client.get<JiraIssueResult[]>("/jira/search", {
params: { q, max_results: maxResults },
});
return data;
}
/** Create a new Jira link for an Aegis entity. */
export async function createJiraLink(
payload: JiraLinkCreatePayload,
): Promise<JiraLink> {
const { data } = await client.post<JiraLink>("/jira/links", payload);
return data;
}
/** List Jira links, optionally filtered. */
export async function listJiraLinks(params?: {
entity_type?: JiraLinkEntityType;
entity_id?: string;
}): Promise<JiraLink[]> {
const { data } = await client.get<JiraLink[]>("/jira/links", { params });
return data;
}
/** Force sync a Jira link. */
export async function syncJiraLink(
linkId: string,
): Promise<{ message: string; jira_status: string }> {
const { data } = await client.post(`/jira/links/${linkId}/sync`);
return data;
}
/** Delete a Jira link. */
export async function deleteJiraLink(linkId: string): Promise<void> {
await client.delete(`/jira/links/${linkId}`);
}
/** Auto-create a Jira issue from an Aegis entity and link them. */
export async function createIssueFromEntity(
entityType: JiraLinkEntityType,
entityId: string,
): Promise<{ issue_key: string; link_id: string }> {
const { data } = await client.post("/jira/create-issue", null, {
params: { entity_type: entityType, entity_id: entityId },
});
return data;
}

View File

@@ -0,0 +1,62 @@
import client from "./client";
// ── Types ───────────────────────────────────────────────────────────
export interface Worklog {
id: string;
entity_type: string;
entity_id: string;
user_id: string;
activity_type: string;
started_at: string;
ended_at: string | null;
duration_seconds: number;
description: string | null;
tempo_synced: string | null;
integrity_hash: string | null;
created_at: string;
}
export interface WorklogCreatePayload {
entity_type: string;
entity_id: string;
activity_type: string;
started_at: string;
ended_at?: string;
duration_seconds: number;
description?: string;
}
// ── API Functions ───────────────────────────────────────────────────
/** Create a manual worklog entry. */
export async function createWorklog(
payload: WorklogCreatePayload,
): Promise<Worklog> {
const { data } = await client.post<Worklog>("/worklogs", payload);
return data;
}
/** List worklogs with optional filters. */
export async function listWorklogs(params?: {
entity_type?: string;
entity_id?: string;
user_id?: string;
}): Promise<Worklog[]> {
const { data } = await client.get<Worklog[]>("/worklogs", { params });
return data;
}
/** Get a single worklog. */
export async function getWorklog(worklogId: string): Promise<Worklog> {
const { data } = await client.get<Worklog>(`/worklogs/${worklogId}`);
return data;
}
/** Verify a worklog's integrity hash. */
export async function verifyWorklogIntegrity(
worklogId: string,
): Promise<{ worklog_id: string; integrity_valid: boolean }> {
const { data } = await client.get(`/worklogs/${worklogId}/verify`);
return data;
}

View File

@@ -0,0 +1,274 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import {
ExternalLink,
Link2,
RefreshCw,
Search,
Trash2,
Loader2,
Plus,
X,
} from "lucide-react";
import {
listJiraLinks,
searchJiraIssues,
createJiraLink,
syncJiraLink,
deleteJiraLink,
type JiraLink,
type JiraLinkEntityType,
type JiraIssueResult,
} from "../api/jira";
import { useDebounce } from "../hooks/useDebounce";
interface JiraLinkPanelProps {
entityType: JiraLinkEntityType;
entityId: string;
}
const priorityColors: Record<string, string> = {
Highest: "text-red-400",
High: "text-orange-400",
Medium: "text-yellow-400",
Low: "text-cyan-400",
Lowest: "text-gray-400",
};
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",
};
export default function JiraLinkPanel({ entityType, entityId }: JiraLinkPanelProps) {
const queryClient = useQueryClient();
const [showSearch, setShowSearch] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const debouncedQuery = useDebounce(searchQuery, 400);
// ── Queries ─────────────────────────────────────────────────────
const { data: links = [], isLoading: isLoadingLinks } = useQuery({
queryKey: ["jira-links", entityType, entityId],
queryFn: () => listJiraLinks({ entity_type: entityType, entity_id: entityId }),
});
const { data: searchResults = [], isFetching: isSearching } = useQuery({
queryKey: ["jira-search", debouncedQuery],
queryFn: () => searchJiraIssues(debouncedQuery),
enabled: debouncedQuery.length >= 2,
});
// ── Mutations ───────────────────────────────────────────────────
const invalidate = () => {
queryClient.invalidateQueries({
queryKey: ["jira-links", entityType, entityId],
});
};
const linkMutation = useMutation({
mutationFn: (issueKey: string) =>
createJiraLink({
entity_type: entityType,
entity_id: entityId,
jira_issue_key: issueKey,
}),
onSuccess: () => {
invalidate();
setShowSearch(false);
setSearchQuery("");
},
});
const syncMutation = useMutation({
mutationFn: (linkId: string) => syncJiraLink(linkId),
onSuccess: invalidate,
});
const deleteMutation = useMutation({
mutationFn: (linkId: string) => deleteJiraLink(linkId),
onSuccess: invalidate,
});
// ── Render helpers ──────────────────────────────────────────────
const formatDate = (dateStr: string | null) => {
if (!dateStr) return null;
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
};
const getStatusClass = (status: string | null) => {
if (!status) return "bg-gray-700 text-gray-400";
return statusColors[status] || "bg-gray-700 text-gray-300";
};
return (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="flex items-center gap-2 text-lg font-semibold text-white">
<Link2 className="h-5 w-5 text-blue-400" />
Jira
</h2>
<button
onClick={() => setShowSearch(!showSearch)}
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-xs text-gray-300 hover:border-blue-500/50 hover:text-blue-400 transition-colors"
>
{showSearch ? (
<>
<X className="h-3.5 w-3.5" /> Cancel
</>
) : (
<>
<Plus className="h-3.5 w-3.5" /> Link Issue
</>
)}
</button>
</div>
{/* Search panel */}
{showSearch && (
<div className="mb-4 rounded-lg border border-gray-700 bg-gray-800/50 p-3 space-y-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-500" />
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search Jira issues (e.g. SEC-1234 or keyword)..."
className="w-full rounded-lg border border-gray-700 bg-gray-900 py-2 pl-10 pr-3 text-sm text-gray-200 placeholder-gray-500 focus:border-blue-500 focus:outline-none"
autoFocus
/>
{isSearching && (
<Loader2 className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin text-gray-500" />
)}
</div>
{searchResults.length > 0 && (
<div className="max-h-48 space-y-1 overflow-y-auto">
{searchResults.map((issue: JiraIssueResult) => (
<button
key={issue.issue_key}
onClick={() => linkMutation.mutate(issue.issue_key)}
disabled={linkMutation.isPending}
className="flex w-full items-center justify-between rounded-lg px-3 py-2 text-left text-sm hover:bg-gray-700/50 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-blue-400">
{issue.issue_key}
</span>
<span className={`text-xs ${priorityColors[issue.priority || ""] || "text-gray-500"}`}>
{issue.priority}
</span>
</div>
<p className="mt-0.5 truncate text-xs text-gray-300">
{issue.summary}
</p>
</div>
<span className={`ml-2 shrink-0 rounded px-2 py-0.5 text-xs ${getStatusClass(issue.status)}`}>
{issue.status}
</span>
</button>
))}
</div>
)}
{debouncedQuery.length >= 2 && !isSearching && searchResults.length === 0 && (
<p className="text-center text-xs text-gray-500">No issues found</p>
)}
</div>
)}
{/* Linked issues */}
{isLoadingLinks ? (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-gray-500" />
</div>
) : links.length === 0 ? (
<p className="text-center text-sm text-gray-500 py-4">
No Jira issues linked
</p>
) : (
<div className="space-y-2">
{links.map((link: JiraLink) => (
<div
key={link.id}
className="rounded-lg border border-gray-700 bg-gray-800/30 p-3"
>
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<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 ${getStatusClass(link.jira_status)}`}
>
{link.jira_status}
</span>
)}
{link.jira_priority && (
<span
className={`text-xs ${priorityColors[link.jira_priority] || "text-gray-500"}`}
>
{link.jira_priority}
</span>
)}
</div>
{link.jira_assignee && (
<p className="mt-1 text-xs text-gray-400">
Assignee: {link.jira_assignee}
</p>
)}
{link.last_synced_at && (
<p className="mt-0.5 text-xs text-gray-500">
Synced: {formatDate(link.last_synced_at)}
</p>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
<button
onClick={() => syncMutation.mutate(link.id)}
disabled={syncMutation.isPending}
title="Sync from Jira"
className="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-blue-400 transition-colors"
>
<RefreshCw
className={`h-3.5 w-3.5 ${syncMutation.isPending ? "animate-spin" : ""}`}
/>
</button>
<a
href={`https://jira.atlassian.com/browse/${link.jira_issue_key}`}
target="_blank"
rel="noopener noreferrer"
title="Open in Jira"
className="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-blue-400 transition-colors"
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
<button
onClick={() => deleteMutation.mutate(link.id)}
disabled={deleteMutation.isPending}
title="Unlink"
className="rounded p-1 text-gray-500 hover:bg-gray-700 hover:text-red-400 transition-colors"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,217 @@
import { useState } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Clock, Plus, Loader2, ShieldCheck, ShieldAlert, X } from "lucide-react";
import { listWorklogs, createWorklog, type Worklog } from "../api/worklogs";
import { useAuth } from "../context/AuthContext";
interface WorklogTimelineProps {
entityType: string;
entityId: string;
}
const activityColors: Record<string, { bg: string; text: string; icon: string }> = {
red_team: { bg: "bg-orange-900/30", text: "text-orange-400", icon: "border-orange-500/40" },
blue_validation: { bg: "bg-indigo-900/30", text: "text-indigo-400", icon: "border-indigo-500/40" },
purple_review: { bg: "bg-purple-900/30", text: "text-purple-400", icon: "border-purple-500/40" },
reporting: { bg: "bg-cyan-900/30", text: "text-cyan-400", icon: "border-cyan-500/40" },
execution: { bg: "bg-orange-900/30", text: "text-orange-400", icon: "border-orange-500/40" },
};
const defaultActivity = { bg: "bg-gray-800/50", text: "text-gray-400", icon: "border-gray-600" };
function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return m > 0 ? `${h}h ${m}m` : `${h}h`;
}
function formatDate(dateStr: string): string {
return new Date(dateStr).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export default function WorklogTimeline({ entityType, entityId }: WorklogTimelineProps) {
const queryClient = useQueryClient();
const { user } = useAuth();
const [showForm, setShowForm] = useState(false);
const [form, setForm] = useState({
activity_type: "red_team",
duration_minutes: "60",
description: "",
});
// ── Query ───────────────────────────────────────────────────────
const { data: worklogs = [], isLoading } = useQuery({
queryKey: ["worklogs", entityType, entityId],
queryFn: () => listWorklogs({ entity_type: entityType, entity_id: entityId }),
});
const createMutation = useMutation({
mutationFn: () =>
createWorklog({
entity_type: entityType,
entity_id: entityId,
activity_type: form.activity_type,
started_at: new Date().toISOString(),
duration_seconds: parseInt(form.duration_minutes, 10) * 60,
description: form.description || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["worklogs", entityType, entityId],
});
setShowForm(false);
setForm({ activity_type: "red_team", duration_minutes: "60", description: "" });
},
});
// ── Total time ──────────────────────────────────────────────────
const totalSeconds = worklogs.reduce(
(sum: number, wl: Worklog) => sum + wl.duration_seconds,
0,
);
return (
<div className="rounded-xl border border-gray-800 bg-gray-900 p-6">
<div className="mb-4 flex items-center justify-between">
<h2 className="flex items-center gap-2 text-lg font-semibold text-white">
<Clock className="h-5 w-5 text-cyan-400" />
Time Log
</h2>
<div className="flex items-center gap-3">
{totalSeconds > 0 && (
<span className="text-xs text-gray-400">
Total: <span className="text-cyan-400 font-medium">{formatDuration(totalSeconds)}</span>
</span>
)}
<button
onClick={() => setShowForm(!showForm)}
className="flex items-center gap-1 rounded-lg border border-gray-700 px-3 py-1.5 text-xs text-gray-300 hover:border-cyan-500/50 hover:text-cyan-400 transition-colors"
>
{showForm ? (
<>
<X className="h-3.5 w-3.5" /> Cancel
</>
) : (
<>
<Plus className="h-3.5 w-3.5" /> Log Time
</>
)}
</button>
</div>
</div>
{/* New worklog form */}
{showForm && (
<div className="mb-4 rounded-lg border border-gray-700 bg-gray-800/50 p-3 space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-xs text-gray-400">Activity Type</label>
<select
value={form.activity_type}
onChange={(e) => setForm({ ...form, activity_type: e.target.value })}
className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
>
<option value="red_team">Red Team</option>
<option value="blue_validation">Blue Validation</option>
<option value="purple_review">Purple Review</option>
<option value="reporting">Reporting</option>
<option value="execution">Execution</option>
</select>
</div>
<div>
<label className="mb-1 block text-xs text-gray-400">Duration (minutes)</label>
<input
type="number"
min="1"
value={form.duration_minutes}
onChange={(e) => setForm({ ...form, duration_minutes: e.target.value })}
className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200 focus:border-cyan-500 focus:outline-none"
/>
</div>
</div>
<div>
<label className="mb-1 block text-xs text-gray-400">Description (optional)</label>
<input
type="text"
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="What did you work on?"
className="w-full rounded-lg border border-gray-700 bg-gray-900 px-3 py-2 text-sm text-gray-200 placeholder-gray-500 focus:border-cyan-500 focus:outline-none"
/>
</div>
<button
onClick={() => createMutation.mutate()}
disabled={createMutation.isPending || !form.duration_minutes}
className="flex items-center gap-1.5 rounded-lg bg-cyan-600 px-4 py-2 text-sm font-medium text-white hover:bg-cyan-500 disabled:opacity-50 transition-colors"
>
{createMutation.isPending && <Loader2 className="h-4 w-4 animate-spin" />}
Save Worklog
</button>
</div>
)}
{/* Timeline */}
{isLoading ? (
<div className="flex justify-center py-4">
<Loader2 className="h-5 w-5 animate-spin text-gray-500" />
</div>
) : worklogs.length === 0 ? (
<p className="text-center text-sm text-gray-500 py-4">No time logged yet</p>
) : (
<div className="relative space-y-0">
{/* Vertical line */}
<div className="absolute left-[15px] top-2 bottom-2 w-px bg-gray-700" />
{worklogs.map((wl: Worklog) => {
const style = activityColors[wl.activity_type] || defaultActivity;
return (
<div key={wl.id} className="relative flex gap-3 py-2">
{/* Dot */}
<div
className={`relative z-10 mt-1 h-[10px] w-[10px] shrink-0 rounded-full border-2 bg-gray-900 ${style.icon}`}
style={{ marginLeft: "6px" }}
/>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span
className={`rounded px-1.5 py-0.5 text-xs font-medium ${style.bg} ${style.text}`}
>
{wl.activity_type.replace(/_/g, " ")}
</span>
<span className="text-xs font-medium text-gray-200">
{formatDuration(wl.duration_seconds)}
</span>
{wl.tempo_synced && (
<span className="text-xs text-green-500" title="Synced to Tempo">
<ShieldCheck className="inline h-3 w-3" />
</span>
)}
</div>
{wl.description && (
<p className="mt-0.5 text-xs text-gray-400 truncate">
{wl.description}
</p>
)}
<p className="mt-0.5 text-xs text-gray-500">
{formatDate(wl.started_at)}
</p>
</div>
</div>
);
})}
</div>
)}
</div>
);
}

View File

@@ -28,6 +28,8 @@ import {
} from "../api/campaigns";
import { useAuth } from "../context/AuthContext";
import CampaignTimeline from "../components/CampaignTimeline";
import JiraLinkPanel from "../components/JiraLinkPanel";
import WorklogTimeline from "../components/WorklogTimeline";
const statusColors: Record<string, string> = {
draft: "bg-gray-800/50 text-gray-400 border-gray-600/30",
@@ -598,6 +600,12 @@ export default function CampaignDetailPage() {
)}
</div>
{/* Jira & Worklogs */}
<div className="grid gap-6 lg:grid-cols-2">
<JiraLinkPanel entityType="campaign" entityId={campaignId!} />
<WorklogTimeline entityType="campaign" entityId={campaignId!} />
</div>
{/* Toast notification */}
{toast && (
<div

View File

@@ -23,6 +23,7 @@ import { getTechniqueByMitreId, markTechniqueReviewed } from "../api/techniques"
import { getTemplatesByTechnique } from "../api/test-templates";
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> = {
@@ -447,6 +448,11 @@ export default function TechniqueDetailPage() {
</div>
)}
{/* Jira Integration */}
{technique && (
<JiraLinkPanel entityType="technique" entityId={technique.id} />
)}
{/* Template instantiation modal */}
{templateFormId && technique && (
<TestFromTemplateForm

View File

@@ -24,6 +24,8 @@ import TestDetailHeader from "../components/test-detail/TestDetailHeader";
import TeamTabs from "../components/test-detail/TeamTabs";
import ValidationModal from "../components/test-detail/ValidationModal";
import ConfirmDialog from "../components/ConfirmDialog";
import JiraLinkPanel from "../components/JiraLinkPanel";
import WorklogTimeline from "../components/WorklogTimeline";
// ── Page Component ─────────────────────────────────────────────────
@@ -498,6 +500,12 @@ export default function TestDetailPage() {
</div>
</div>
)}
{/* Jira Integration */}
<JiraLinkPanel entityType="test" entityId={testId!} />
{/* Time Tracking */}
<WorklogTimeline entityType="test" entityId={testId!} />
</div>
</div>

View File

@@ -202,3 +202,36 @@ export interface DefensiveTechnique {
tactic: string | null;
d3fend_url: string | null;
}
// ── Jira ──────────────────────────────────────────────────────────
export type JiraLinkEntityType = "test" | "technique" | "campaign" | "evidence";
export interface JiraLink {
id: string;
entity_type: JiraLinkEntityType;
entity_id: string;
jira_issue_key: string;
jira_status: string | null;
jira_priority: string | null;
jira_assignee: string | null;
last_synced_at: string | null;
created_at: string;
}
// ── Worklogs ──────────────────────────────────────────────────────
export interface Worklog {
id: string;
entity_type: string;
entity_id: string;
user_id: string;
activity_type: string;
started_at: string;
ended_at: string | null;
duration_seconds: number;
description: string | null;
tempo_synced: string | null;
integrity_hash: string | null;
created_at: string;
}