feat: two-way sync between support tickets and Gitea issues (v0.63.0)
Outbound (already existed one-way: ticket create -> Gitea issue) now also mirrors status changes: closing/reopening a ticket in the admin UI closes/reopens the linked Gitea issue, and replies posted from Epicure (by the ticket owner or an admin) post as a comment on the issue. Captures and stores the Gitea issue number at creation time to address these follow-up calls without re-parsing the issue URL. Inbound: new webhook receiver at /api/webhooks/gitea, verified via HMAC-SHA256 signature (X-Gitea-Signature) with a configurable GITEA_WEBHOOK_SECRET site setting, deduped by delivery id the same way the existing Stripe receiver dedupes events. Handles "issues" (closed/reopened -> ticket status) and "issue_comment" (created -> appends to the ticket's comment thread), skipping comments Epicura already posted itself (matched by Gitea comment id) to avoid loops. New support_ticket_comments table backs a lightweight conversation thread on both the user-facing support page and the admin support manager, each comment tagged by author (user/admin/gitea). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
+93
-6
@@ -1,3 +1,4 @@
|
||||
import crypto from "node:crypto";
|
||||
import { getSiteSetting } from "@/lib/site-settings";
|
||||
import { getPublicUrl } from "@/lib/storage";
|
||||
|
||||
@@ -58,7 +59,7 @@ export async function createGiteaIssue(opts: {
|
||||
type: string;
|
||||
title: string;
|
||||
body: string;
|
||||
}): Promise<{ url: string | null; error: string | null }> {
|
||||
}): Promise<{ url: string | null; number: number | null; error: string | null }> {
|
||||
const [rawBaseUrl, token, repo] = await Promise.all([
|
||||
getSiteSetting("GITEA_URL"),
|
||||
getSiteSetting("GITEA_TOKEN"),
|
||||
@@ -66,7 +67,7 @@ export async function createGiteaIssue(opts: {
|
||||
]);
|
||||
|
||||
if (!rawBaseUrl || !token || !repo) {
|
||||
return { url: null, error: null };
|
||||
return { url: null, number: null, error: null };
|
||||
}
|
||||
|
||||
const baseUrl = rawBaseUrl.replace(/\/$/, "");
|
||||
@@ -90,11 +91,15 @@ export async function createGiteaIssue(opts: {
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
return { url: null, error: `Gitea returned ${res.status}: ${text.slice(0, 300)}` };
|
||||
return { url: null, number: null, error: `Gitea returned ${res.status}: ${text.slice(0, 300)}` };
|
||||
}
|
||||
|
||||
const issue = (await res.json()) as { html_url?: string };
|
||||
return { url: issue.html_url ?? null, error: issue.html_url ? null : "Gitea response had no html_url" };
|
||||
const issue = (await res.json()) as { html_url?: string; number?: number };
|
||||
return {
|
||||
url: issue.html_url ?? null,
|
||||
number: issue.number ?? null,
|
||||
error: issue.html_url ? null : "Gitea response had no html_url",
|
||||
};
|
||||
} catch (err) {
|
||||
// A thrown fetch error (DNS failure, connection refused, timeout, TLS
|
||||
// error) never reaches the res.ok branch above, so its real cause would
|
||||
@@ -105,6 +110,88 @@ export async function createGiteaIssue(opts: {
|
||||
const cause = err instanceof Error && err.cause instanceof Error ? err.cause.message : undefined;
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
console.error("[gitea] createGiteaIssue failed", { baseUrl, repo, message, cause });
|
||||
return { url: null, error: cause ? `${message}: ${cause}` : message };
|
||||
return { url: null, number: null, error: cause ? `${message}: ${cause}` : message };
|
||||
}
|
||||
}
|
||||
|
||||
async function giteaCreds(): Promise<{ baseUrl: string; token: string; repo: string } | null> {
|
||||
const [rawBaseUrl, token, repo] = await Promise.all([
|
||||
getSiteSetting("GITEA_URL"),
|
||||
getSiteSetting("GITEA_TOKEN"),
|
||||
getSiteSetting("GITEA_REPO"),
|
||||
]);
|
||||
if (!rawBaseUrl || !token || !repo) return null;
|
||||
return { baseUrl: rawBaseUrl.replace(/\/$/, ""), token, repo };
|
||||
}
|
||||
|
||||
/** Posts a comment on the linked Gitea issue. Best-effort — callers must not
|
||||
* block ticket/comment saving on this succeeding. */
|
||||
export async function postGiteaComment(
|
||||
issueNumber: number,
|
||||
body: string
|
||||
): Promise<{ id: number | null; error: string | null }> {
|
||||
const creds = await giteaCreds();
|
||||
if (!creds) return { id: null, error: null };
|
||||
|
||||
try {
|
||||
const res = await fetch(`${creds.baseUrl}/api/v1/repos/${creds.repo}/issues/${issueNumber}/comments`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Authorization: `token ${creds.token}` },
|
||||
body: JSON.stringify({ body }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
return { id: null, error: `Gitea returned ${res.status}: ${text.slice(0, 300)}` };
|
||||
}
|
||||
const comment = (await res.json()) as { id?: number };
|
||||
return { id: comment.id ?? null, error: null };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
console.error("[gitea] postGiteaComment failed", { issueNumber, message });
|
||||
return { id: null, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Opens or closes the linked Gitea issue to mirror a ticket status change.
|
||||
* Best-effort, same reasoning as postGiteaComment. */
|
||||
export async function setGiteaIssueState(
|
||||
issueNumber: number,
|
||||
state: "open" | "closed"
|
||||
): Promise<{ ok: boolean; error: string | null }> {
|
||||
const creds = await giteaCreds();
|
||||
if (!creds) return { ok: false, error: null };
|
||||
|
||||
try {
|
||||
const res = await fetch(`${creds.baseUrl}/api/v1/repos/${creds.repo}/issues/${issueNumber}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json", Authorization: `token ${creds.token}` },
|
||||
body: JSON.stringify({ state }),
|
||||
signal: AbortSignal.timeout(10000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
return { ok: false, error: `Gitea returned ${res.status}: ${text.slice(0, 300)}` };
|
||||
}
|
||||
return { ok: true, error: null };
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
console.error("[gitea] setGiteaIssueState failed", { issueNumber, state, message });
|
||||
return { ok: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/** Gitea signs webhook deliveries with HMAC-SHA256 hex digest of the raw
|
||||
* body (X-Gitea-Signature) — no timestamp prefix, unlike Stripe. */
|
||||
export function verifyGiteaSignature(rawBody: string, signature: string, secret: string): boolean {
|
||||
const expected = crypto.createHmac("sha256", secret).update(rawBody, "utf8").digest("hex");
|
||||
const expectedBuf = Buffer.from(expected, "hex");
|
||||
let providedBuf: Buffer;
|
||||
try {
|
||||
providedBuf = Buffer.from(signature, "hex");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
if (providedBuf.length !== expectedBuf.length) return false;
|
||||
return crypto.timingSafeEqual(expectedBuf, providedBuf);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user