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()
|
.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) {
|
fn emit_polling_error(app_handle: &AppHandle, project_id: &str, error: &str) {
|
||||||
let _ = app_handle.emit(
|
let _ = app_handle.emit(
|
||||||
"graylog-polling-error",
|
"graylog-polling-error",
|
||||||
|
|
@ -228,11 +267,17 @@ pub async fn poll_project_once(
|
||||||
|
|
||||||
let triggered_ticket_id = if should_trigger {
|
let triggered_ticket_id = if should_trigger {
|
||||||
let conn = db.lock().map_err(|e| format!("DB lock failed: {}", e))?;
|
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(
|
let ticket = ProcessedTicket::insert_external(
|
||||||
&conn,
|
&conn,
|
||||||
project_id,
|
project_id,
|
||||||
"graylog",
|
"graylog",
|
||||||
Some(&subject.id),
|
Some(&source_permalink),
|
||||||
synthetic_artifact_id(&aggregate.subject_key),
|
synthetic_artifact_id(&aggregate.subject_key),
|
||||||
&format!(
|
&format!(
|
||||||
"[Graylog] {} - {}",
|
"[Graylog] {} - {}",
|
||||||
|
|
@ -294,7 +339,7 @@ pub async fn poll_project_once(
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::should_trigger_subject;
|
use super::{build_graylog_subject_permalink, should_trigger_subject};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_should_trigger_subject_respects_active_ticket() {
|
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(82, 70, true));
|
||||||
assert!(!should_trigger_subject(60, 70, false));
|
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,
|
retryTicket,
|
||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import { getErrorMessage } from "../../lib/errors";
|
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 type { ProcessedTicket, Worktree } from "../../lib/types";
|
||||||
import ConfirmModal from "../ui/ConfirmModal";
|
import ConfirmModal from "../ui/ConfirmModal";
|
||||||
import TicketStatusBadge from "../ui/TicketStatusBadge";
|
import TicketStatusBadge from "../ui/TicketStatusBadge";
|
||||||
|
|
@ -53,6 +59,9 @@ export default function TicketDetail() {
|
||||||
const { ticketId } = useParams();
|
const { ticketId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [ticket, setTicket] = useState<ProcessedTicket | null>(null);
|
const [ticket, setTicket] = useState<ProcessedTicket | null>(null);
|
||||||
|
const [resourceConfig, setResourceConfig] = useState<TicketResourceConfig>(
|
||||||
|
DEFAULT_TICKET_RESOURCE_CONFIG
|
||||||
|
);
|
||||||
const [worktree, setWorktree] = useState<Worktree | null>(null);
|
const [worktree, setWorktree] = useState<Worktree | null>(null);
|
||||||
const [diff, setDiff] = useState<string | null>(null);
|
const [diff, setDiff] = useState<string | null>(null);
|
||||||
const [targetBranch, setTargetBranch] = useState("");
|
const [targetBranch, setTargetBranch] = useState("");
|
||||||
|
|
@ -100,6 +109,12 @@ export default function TicketDetail() {
|
||||||
try {
|
try {
|
||||||
const result = await getTicketResult(ticketId);
|
const result = await getTicketResult(ticketId);
|
||||||
setTicket(result.ticket);
|
setTicket(result.ticket);
|
||||||
|
try {
|
||||||
|
const config = await fetchTicketResourceConfig(result.ticket.project_id);
|
||||||
|
setResourceConfig(config);
|
||||||
|
} catch {
|
||||||
|
setResourceConfig(DEFAULT_TICKET_RESOURCE_CONFIG);
|
||||||
|
}
|
||||||
setWorktree(result.worktree);
|
setWorktree(result.worktree);
|
||||||
setTab("info");
|
setTab("info");
|
||||||
setDiff(null);
|
setDiff(null);
|
||||||
|
|
@ -270,6 +285,7 @@ export default function TicketDetail() {
|
||||||
disabled: !worktree || worktree.status !== "Active",
|
disabled: !worktree || worktree.status !== "Active",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
const sourceLink = buildTicketResourceLink(ticket, resourceConfig);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={pageClass}>
|
<div className={pageClass}>
|
||||||
|
|
@ -345,10 +361,23 @@ export default function TicketDetail() {
|
||||||
<span className="text-sm text-gray-500">Source:</span>
|
<span className="text-sm text-gray-500">Source:</span>
|
||||||
<span className="ml-2 text-sm uppercase">{ticket.source}</span>
|
<span className="ml-2 text-sm uppercase">{ticket.source}</span>
|
||||||
</div>
|
</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>
|
<div>
|
||||||
<span className="text-sm text-gray-500">Source ref:</span>
|
<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>
|
||||||
)}
|
)}
|
||||||
<div>
|
<div>
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Link, useParams } from "react-router-dom";
|
import { Link, useParams } from "react-router-dom";
|
||||||
import { getProject, listProcessedTickets } from "../../lib/api";
|
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 type { ProcessedTicket, Project } from "../../lib/types";
|
||||||
import TicketStatusBadge from "../ui/TicketStatusBadge";
|
import TicketStatusBadge from "../ui/TicketStatusBadge";
|
||||||
import {
|
import {
|
||||||
|
|
@ -14,16 +20,39 @@ export default function TicketList() {
|
||||||
const { projectId } = useParams();
|
const { projectId } = useParams();
|
||||||
const [project, setProject] = useState<Project | null>(null);
|
const [project, setProject] = useState<Project | null>(null);
|
||||||
const [tickets, setTickets] = useState<ProcessedTicket[]>([]);
|
const [tickets, setTickets] = useState<ProcessedTicket[]>([]);
|
||||||
|
const [resourceConfig, setResourceConfig] = useState<TicketResourceConfig>(
|
||||||
|
DEFAULT_TICKET_RESOURCE_CONFIG
|
||||||
|
);
|
||||||
const [filter, setFilter] = useState<string>("all");
|
const [filter, setFilter] = useState<string>("all");
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
Promise.all([getProject(projectId), listProcessedTickets(projectId)]).then(
|
let cancelled = false;
|
||||||
([proj, tkts]) => {
|
Promise.all([getProject(projectId), listProcessedTickets(projectId)])
|
||||||
|
.then(([proj, tkts]) => {
|
||||||
|
if (cancelled) return;
|
||||||
setProject(proj);
|
setProject(proj);
|
||||||
setTickets(tkts);
|
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]);
|
}, [projectId]);
|
||||||
|
|
||||||
const filtered = filter === "all" ? tickets : tickets.filter((t) => t.status === filter);
|
const filtered = filter === "all" ? tickets : tickets.filter((t) => t.status === filter);
|
||||||
|
|
@ -60,14 +89,12 @@ export default function TicketList() {
|
||||||
<div className="py-8 text-center text-sm text-gray-400">No tickets found.</div>
|
<div className="py-8 text-center text-sm text-gray-400">No tickets found.</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{filtered.map((ticket) => (
|
{filtered.map((ticket) => {
|
||||||
<Link
|
const resourceLink = buildTicketResourceLink(ticket, resourceConfig);
|
||||||
key={ticket.id}
|
return (
|
||||||
to={`/tickets/${ticket.id}`}
|
<div key={ticket.id} className={`transition-colors hover:border-blue-300 ${cardContentClass}`}>
|
||||||
className={`block transition-colors hover:border-blue-300 ${cardContentClass}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-4">
|
||||||
<div className="min-w-0 flex-1">
|
<Link to={`/tickets/${ticket.id}`} className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="font-mono text-xs text-gray-400">#{ticket.artifact_id}</span>
|
<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">
|
<span className="rounded bg-gray-100 px-2 py-0.5 text-[10px] uppercase tracking-wide text-gray-600">
|
||||||
|
|
@ -83,11 +110,25 @@ export default function TicketList() {
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</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" />
|
<TicketStatusBadge status={ticket.status} className="shrink-0" />
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</div>
|
||||||
))}
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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