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

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>
);
}