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