From b5ba10d857a48ef7b279cf6f2f86da8a339adaa4 Mon Sep 17 00:00:00 2001 From: thibaud-lclr Date: Mon, 20 Apr 2026 09:55:03 +0200 Subject: [PATCH] feat(tickets): add source resource links for tuleap and graylog --- src-tauri/src/services/graylog_poller.rs | 64 +++++++++- src/components/tickets/TicketDetail.tsx | 33 ++++- src/components/tickets/TicketList.tsx | 95 ++++++++++---- src/lib/ticketResource.ts | 155 +++++++++++++++++++++++ 4 files changed, 316 insertions(+), 31 deletions(-) create mode 100644 src/lib/ticketResource.ts diff --git a/src-tauri/src/services/graylog_poller.rs b/src-tauri/src/services/graylog_poller.rs index 076c230..9bbdf77 100644 --- a/src-tauri/src/services/graylog_poller.rs +++ b/src-tauri/src/services/graylog_poller.rs @@ -50,6 +50,45 @@ fn subject_payload(aggregate: &SubjectAggregate) -> String { .to_string() } +fn escape_graylog_query_value(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn build_graylog_subject_permalink( + base_url: &str, + source: &str, + normalized_message: &str, + lookback_minutes: i32, +) -> String { + let source = source.trim(); + let normalized_message = normalized_message.trim(); + let mut query_terms: Vec = Vec::new(); + + if !source.is_empty() { + query_terms.push(format!("source:\"{}\"", escape_graylog_query_value(source))); + } + if !normalized_message.is_empty() { + query_terms.push(format!( + "message:\"{}\"", + escape_graylog_query_value(normalized_message) + )); + } + + let query = if query_terms.is_empty() { + "*".to_string() + } else { + query_terms.join(" AND ") + }; + + let relative_seconds = (lookback_minutes.max(1) * 60).max(60); + format!( + "{}/search?q={}&rangetype=relative&relative={}", + base_url.trim_end_matches('/'), + urlencoding::encode(&query), + relative_seconds + ) +} + fn emit_polling_error(app_handle: &AppHandle, project_id: &str, error: &str) { let _ = app_handle.emit( "graylog-polling-error", @@ -228,11 +267,17 @@ pub async fn poll_project_once( let triggered_ticket_id = if should_trigger { let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?; + let source_permalink = build_graylog_subject_permalink( + &credentials.base_url, + &aggregate.source, + &aggregate.normalized_message, + credentials.lookback_minutes, + ); let ticket = ProcessedTicket::insert_external( &conn, project_id, "graylog", - Some(&subject.id), + Some(&source_permalink), synthetic_artifact_id(&aggregate.subject_key), &format!( "[Graylog] {} - {}", @@ -294,7 +339,7 @@ pub async fn poll_project_once( #[cfg(test)] mod tests { - use super::should_trigger_subject; + use super::{build_graylog_subject_permalink, should_trigger_subject}; #[test] fn test_should_trigger_subject_respects_active_ticket() { @@ -302,4 +347,19 @@ mod tests { assert!(!should_trigger_subject(82, 70, true)); assert!(!should_trigger_subject(60, 70, false)); } + + #[test] + fn test_build_graylog_subject_permalink_uses_relative_search_query() { + let url = build_graylog_subject_permalink( + "https://graylog.example.com/", + "api", + "timeout user ", + 30, + ); + + assert!(url.starts_with("https://graylog.example.com/search?")); + assert!(url.contains("rangetype=relative")); + assert!(url.contains("relative=1800")); + assert!(url.contains("q=source%3A%22api%22%20AND%20message%3A%22timeout%20user%20%3Cnum%3E%22")); + } } diff --git a/src/components/tickets/TicketDetail.tsx b/src/components/tickets/TicketDetail.tsx index 5a507db..e4b3aa5 100644 --- a/src/components/tickets/TicketDetail.tsx +++ b/src/components/tickets/TicketDetail.tsx @@ -12,6 +12,12 @@ import { retryTicket, } from "../../lib/api"; import { getErrorMessage } from "../../lib/errors"; +import { + buildTicketResourceLink, + DEFAULT_TICKET_RESOURCE_CONFIG, + fetchTicketResourceConfig, + type TicketResourceConfig, +} from "../../lib/ticketResource"; import type { ProcessedTicket, Worktree } from "../../lib/types"; import ConfirmModal from "../ui/ConfirmModal"; import TicketStatusBadge from "../ui/TicketStatusBadge"; @@ -53,6 +59,9 @@ export default function TicketDetail() { const { ticketId } = useParams(); const navigate = useNavigate(); const [ticket, setTicket] = useState(null); + const [resourceConfig, setResourceConfig] = useState( + DEFAULT_TICKET_RESOURCE_CONFIG + ); const [worktree, setWorktree] = useState(null); const [diff, setDiff] = useState(null); const [targetBranch, setTargetBranch] = useState(""); @@ -100,6 +109,12 @@ export default function TicketDetail() { try { const result = await getTicketResult(ticketId); setTicket(result.ticket); + try { + const config = await fetchTicketResourceConfig(result.ticket.project_id); + setResourceConfig(config); + } catch { + setResourceConfig(DEFAULT_TICKET_RESOURCE_CONFIG); + } setWorktree(result.worktree); setTab("info"); setDiff(null); @@ -270,6 +285,7 @@ export default function TicketDetail() { disabled: !worktree || worktree.status !== "Active", }, ]; + const sourceLink = buildTicketResourceLink(ticket, resourceConfig); return (
@@ -345,10 +361,23 @@ export default function TicketDetail() { Source: {ticket.source}
- {ticket.source_ref && ( + {sourceLink && ( +
+ Resource: + + {sourceLink.label} + +
+ )} + {ticket.source_ref && ticket.source_ref !== sourceLink?.href && (
Source ref: - {ticket.source_ref} + {ticket.source_ref}
)}
diff --git a/src/components/tickets/TicketList.tsx b/src/components/tickets/TicketList.tsx index 0eacec8..47142b7 100644 --- a/src/components/tickets/TicketList.tsx +++ b/src/components/tickets/TicketList.tsx @@ -1,6 +1,12 @@ import { useEffect, useState } from "react"; import { Link, useParams } from "react-router-dom"; import { getProject, listProcessedTickets } from "../../lib/api"; +import { + buildTicketResourceLink, + DEFAULT_TICKET_RESOURCE_CONFIG, + fetchTicketResourceConfig, + type TicketResourceConfig, +} from "../../lib/ticketResource"; import type { ProcessedTicket, Project } from "../../lib/types"; import TicketStatusBadge from "../ui/TicketStatusBadge"; import { @@ -14,16 +20,39 @@ export default function TicketList() { const { projectId } = useParams(); const [project, setProject] = useState(null); const [tickets, setTickets] = useState([]); + const [resourceConfig, setResourceConfig] = useState( + DEFAULT_TICKET_RESOURCE_CONFIG + ); const [filter, setFilter] = useState("all"); useEffect(() => { if (!projectId) return; - Promise.all([getProject(projectId), listProcessedTickets(projectId)]).then( - ([proj, tkts]) => { + let cancelled = false; + Promise.all([getProject(projectId), listProcessedTickets(projectId)]) + .then(([proj, tkts]) => { + if (cancelled) return; setProject(proj); setTickets(tkts); - } - ); + }) + .catch((error: unknown) => { + console.error("Failed to load ticket list", error); + }); + + void fetchTicketResourceConfig(projectId) + .then((config) => { + if (!cancelled) { + setResourceConfig(config); + } + }) + .catch(() => { + if (!cancelled) { + setResourceConfig(DEFAULT_TICKET_RESOURCE_CONFIG); + } + }); + + return () => { + cancelled = true; + }; }, [projectId]); const filtered = filter === "all" ? tickets : tickets.filter((t) => t.status === filter); @@ -60,34 +89,46 @@ export default function TicketList() {
No tickets found.
) : (
- {filtered.map((ticket) => ( - -
-
-
- #{ticket.artifact_id} - - {ticket.source} - - {ticket.artifact_title} -
-
- {new Date(ticket.detected_at).toLocaleString()} - {ticket.processed_at && ( - - Processed: {new Date(ticket.processed_at).toLocaleString()} + {filtered.map((ticket) => { + const resourceLink = buildTicketResourceLink(ticket, resourceConfig); + return ( +
+
+ +
+ #{ticket.artifact_id} + + {ticket.source} + {ticket.artifact_title} +
+
+ {new Date(ticket.detected_at).toLocaleString()} + {ticket.processed_at && ( + + Processed: {new Date(ticket.processed_at).toLocaleString()} + + )} +
+ +
+ {resourceLink && ( + + Ressource + )} +
-
- - ))} + ); + })}
)}
diff --git a/src/lib/ticketResource.ts b/src/lib/ticketResource.ts new file mode 100644 index 0000000..d0bb5bd --- /dev/null +++ b/src/lib/ticketResource.ts @@ -0,0 +1,155 @@ +import { getGraylogCredentials, getTuleapCredentials } from "./api"; +import type { ProcessedTicket } from "./types"; + +export interface TicketResourceConfig { + tuleapUrl: string | null; + graylogBaseUrl: string | null; + graylogLookbackMinutes: number; +} + +export interface TicketResourceLink { + href: string; + label: string; +} + +export const DEFAULT_TICKET_RESOURCE_CONFIG: TicketResourceConfig = { + tuleapUrl: null, + graylogBaseUrl: null, + graylogLookbackMinutes: 30, +}; + +function normalizeBaseUrl(value: string): string { + return value.trim().replace(/\/+$/, ""); +} + +function isAbsoluteUrl(value: string | null | undefined): boolean { + if (!value) return false; + return /^https?:\/\//i.test(value.trim()); +} + +function escapeGraylogQueryValue(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +} + +function parseGraylogSubjectPayload( + artifactData: string +): { source?: string; normalizedMessage?: string } { + try { + const parsed = JSON.parse(artifactData) as Record; + const source = typeof parsed.source === "string" ? parsed.source.trim() : ""; + const normalizedMessage = + typeof parsed.normalized_message === "string" ? parsed.normalized_message.trim() : ""; + return { + source: source || undefined, + normalizedMessage: normalizedMessage || undefined, + }; + } catch { + return {}; + } +} + +function buildGraylogPermalink( + baseUrl: string, + source: string | undefined, + normalizedMessage: string | undefined, + lookbackMinutes: number +): string { + const terms: string[] = []; + if (source) { + terms.push(`source:"${escapeGraylogQueryValue(source)}"`); + } + if (normalizedMessage) { + terms.push(`message:"${escapeGraylogQueryValue(normalizedMessage)}"`); + } + + const query = terms.length > 0 ? terms.join(" AND ") : "*"; + const relativeSeconds = Math.max(60, Math.max(1, lookbackMinutes) * 60); + const params = new URLSearchParams({ + q: query, + rangetype: "relative", + relative: String(relativeSeconds), + }); + + return `${normalizeBaseUrl(baseUrl)}/search?${params.toString()}`; +} + +function buildTuleapLink( + ticket: ProcessedTicket, + config: TicketResourceConfig +): TicketResourceLink | null { + if (isAbsoluteUrl(ticket.source_ref)) { + return { href: ticket.source_ref!.trim(), label: "Ouvrir dans Tuleap" }; + } + + if (!config.tuleapUrl) { + return null; + } + + const artifactId = + ticket.artifact_id > 0 + ? ticket.artifact_id + : Number.parseInt(ticket.source_ref ?? "", 10); + + if (!Number.isFinite(artifactId) || artifactId <= 0) { + return null; + } + + return { + href: `${normalizeBaseUrl(config.tuleapUrl)}/plugins/tracker/?aid=${artifactId}`, + label: "Ouvrir dans Tuleap", + }; +} + +function buildGraylogLink( + ticket: ProcessedTicket, + config: TicketResourceConfig +): TicketResourceLink | null { + if (isAbsoluteUrl(ticket.source_ref)) { + return { href: ticket.source_ref!.trim(), label: "Ouvrir dans Graylog" }; + } + + if (!config.graylogBaseUrl) { + return null; + } + + const payload = parseGraylogSubjectPayload(ticket.artifact_data); + const fallbackMessage = ticket.source_ref?.trim() || undefined; + + return { + href: buildGraylogPermalink( + config.graylogBaseUrl, + payload.source, + payload.normalizedMessage ?? fallbackMessage, + config.graylogLookbackMinutes + ), + label: "Ouvrir dans Graylog", + }; +} + +export function buildTicketResourceLink( + ticket: ProcessedTicket, + config: TicketResourceConfig +): TicketResourceLink | null { + if (ticket.source === "tuleap") { + return buildTuleapLink(ticket, config); + } + if (ticket.source === "graylog") { + return buildGraylogLink(ticket, config); + } + return null; +} + +export async function fetchTicketResourceConfig( + projectId: string +): Promise { + const [tuleapCredentials, graylogCredentials] = await Promise.all([ + getTuleapCredentials(projectId), + getGraylogCredentials(projectId), + ]); + + return { + tuleapUrl: tuleapCredentials?.tuleap_url ?? null, + graylogBaseUrl: graylogCredentials?.base_url ?? null, + graylogLookbackMinutes: graylogCredentials?.lookback_minutes ?? 30, + }; +}