feat(tickets): add source resource links for tuleap and graylog

This commit is contained in:
thibaud-lclr 2026-04-20 09:55:03 +02:00
parent 5b795c00b3
commit b5ba10d857
4 changed files with 316 additions and 31 deletions

View file

@ -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<String> = 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 <num>",
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"));
}
}

View file

@ -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<ProcessedTicket | null>(null);
const [resourceConfig, setResourceConfig] = useState<TicketResourceConfig>(
DEFAULT_TICKET_RESOURCE_CONFIG
);
const [worktree, setWorktree] = useState<Worktree | null>(null);
const [diff, setDiff] = useState<string | null>(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 (
<div className={pageClass}>
@ -345,10 +361,23 @@ export default function TicketDetail() {
<span className="text-sm text-gray-500">Source:</span>
<span className="ml-2 text-sm uppercase">{ticket.source}</span>
</div>
{ticket.source_ref && (
{sourceLink && (
<div>
<span className="text-sm text-gray-500">Resource:</span>
<a
href={sourceLink.href}
target="_blank"
rel="noreferrer"
className="ml-2 text-sm text-blue-600 hover:underline"
>
{sourceLink.label}
</a>
</div>
)}
{ticket.source_ref && ticket.source_ref !== sourceLink?.href && (
<div>
<span className="text-sm text-gray-500">Source ref:</span>
<span className="ml-2 font-mono text-sm">{ticket.source_ref}</span>
<span className="ml-2 break-all font-mono text-sm">{ticket.source_ref}</span>
</div>
)}
<div>

View file

@ -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<Project | null>(null);
const [tickets, setTickets] = useState<ProcessedTicket[]>([]);
const [resourceConfig, setResourceConfig] = useState<TicketResourceConfig>(
DEFAULT_TICKET_RESOURCE_CONFIG
);
const [filter, setFilter] = useState<string>("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() {
<div className="py-8 text-center text-sm text-gray-400">No tickets found.</div>
) : (
<div className="space-y-2">
{filtered.map((ticket) => (
<Link
key={ticket.id}
to={`/tickets/${ticket.id}`}
className={`block transition-colors hover:border-blue-300 ${cardContentClass}`}
>
<div className="flex items-center justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-gray-400">#{ticket.artifact_id}</span>
<span className="rounded bg-gray-100 px-2 py-0.5 text-[10px] uppercase tracking-wide text-gray-600">
{ticket.source}
</span>
<span className="truncate text-sm font-medium">{ticket.artifact_title}</span>
</div>
<div className="mt-1 text-xs text-gray-400">
{new Date(ticket.detected_at).toLocaleString()}
{ticket.processed_at && (
<span className="ml-2">
Processed: {new Date(ticket.processed_at).toLocaleString()}
{filtered.map((ticket) => {
const resourceLink = buildTicketResourceLink(ticket, resourceConfig);
return (
<div key={ticket.id} className={`transition-colors hover:border-blue-300 ${cardContentClass}`}>
<div className="flex items-center justify-between gap-4">
<Link to={`/tickets/${ticket.id}`} className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-mono text-xs text-gray-400">#{ticket.artifact_id}</span>
<span className="rounded bg-gray-100 px-2 py-0.5 text-[10px] uppercase tracking-wide text-gray-600">
{ticket.source}
</span>
<span className="truncate text-sm font-medium">{ticket.artifact_title}</span>
</div>
<div className="mt-1 text-xs text-gray-400">
{new Date(ticket.detected_at).toLocaleString()}
{ticket.processed_at && (
<span className="ml-2">
Processed: {new Date(ticket.processed_at).toLocaleString()}
</span>
)}
</div>
</Link>
<div className="flex shrink-0 items-center gap-3">
{resourceLink && (
<a
href={resourceLink.href}
target="_blank"
rel="noreferrer"
className="text-xs text-blue-600 hover:underline"
title={resourceLink.label}
>
Ressource
</a>
)}
<TicketStatusBadge status={ticket.status} className="shrink-0" />
</div>
</div>
<TicketStatusBadge status={ticket.status} className="shrink-0" />
</div>
</Link>
))}
);
})}
</div>
)}
</div>

155
src/lib/ticketResource.ts Normal file
View file

@ -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<string, unknown>;
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<TicketResourceConfig> {
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,
};
}