feat(tickets): add source resource links for tuleap and graylog
This commit is contained in:
parent
5b795c00b3
commit
b5ba10d857
4 changed files with 316 additions and 31 deletions
|
|
@ -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"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
155
src/lib/ticketResource.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue