feat(phase-35): Jira + Tempo integration with internal worklogs
Some checks failed
Aegis CI / lint-and-test (push) Has been cancelled
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:
274
frontend/src/components/JiraLinkPanel.tsx
Normal file
274
frontend/src/components/JiraLinkPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user