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
275 lines
9.9 KiB
TypeScript
275 lines
9.9 KiB
TypeScript
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>
|
|
);
|
|
}
|