Files
Epicure/apps/web/lib/gitea.ts
T
Arnaud 43088759cf fix: embed images inline in Gitea issues, fix retry dropping attachments (v0.51.6)
Two related bugs in the Gitea issue body:

1. Attachments were always plain bullet links (`- url`), even for images —
   so they never rendered as a preview in the Gitea issue, just a clickable
   URL. Image content-types now use markdown image embed (`![](url)`).
2. The admin "Create Gitea issue" retry action built its own body from
   scratch (`ticket.description` only) and never queried attachments at
   all — a retry always lost them, regardless of point 1's fix.

Extracted buildGiteaIssueBody() to lib/gitea.ts so both the initial
ticket-creation path and the retry path share one body-building
implementation instead of two independent (and inevitably diverging) ones.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-19 11:58:50 +02:00

111 lines
4.4 KiB
TypeScript

import { getSiteSetting } from "@/lib/site-settings";
import { getPublicUrl } from "@/lib/storage";
const LABEL_NAMES: Record<string, string[]> = {
bug: ["bug"],
suggestion: ["enhancement"],
question: ["question"],
};
/** Gitea's issue-create endpoint takes label IDs, not names — resolve by
* fetching the repo's existing labels and matching case-insensitively.
* A repo with no matching labels (or a lookup failure) just means the
* issue is created unlabeled, not that creation fails outright. */
async function resolveLabelIds(baseUrl: string, token: string, repo: string, names: string[]): Promise<number[]> {
if (names.length === 0) return [];
try {
const res = await fetch(`${baseUrl}/api/v1/repos/${repo}/labels`, {
headers: { Authorization: `token ${token}` },
signal: AbortSignal.timeout(10000),
});
if (!res.ok) return [];
const labels = (await res.json()) as { id: number; name: string }[];
const wanted = new Set(names.map((n) => n.toLowerCase()));
return labels.filter((l) => wanted.has(l.name.toLowerCase())).map((l) => l.id);
} catch {
return [];
}
}
/** Shared by ticket creation and the admin retry action, so a retry embeds
* attachments exactly the same way the original attempt would have. Image
* attachments embed inline (`![](url)`) so they render as a preview in the
* Gitea issue instead of just a clickable link — everything else (PDF,
* text, zip) stays a plain link since Gitea can't preview those anyway. */
export function buildGiteaIssueBody(opts: {
description: string;
userId: string;
attachments: { key: string; contentType: string }[];
}): string {
const attachmentLines = opts.attachments.map((a) => {
const url = getPublicUrl(a.key);
return a.contentType.startsWith("image/") ? `![attachment](${url})` : `- ${url}`;
});
return [
opts.description,
attachmentLines.length > 0 ? `\n**Attachments:**\n${attachmentLines.join("\n")}` : "",
`\n---\nReported via Epicure support form by user \`${opts.userId}\`.`,
].join("\n");
}
/**
* Opens an issue on the configured Gitea repo for a support ticket. Returns
* the issue's HTML URL on success, or null if Gitea isn't configured or the
* request fails — callers must treat this as best-effort, not block on it,
* since the ticket itself is already saved regardless.
*/
export async function createGiteaIssue(opts: {
type: string;
title: string;
body: string;
}): Promise<{ url: string | null; error: string | null }> {
const [rawBaseUrl, token, repo] = await Promise.all([
getSiteSetting("GITEA_URL"),
getSiteSetting("GITEA_TOKEN"),
getSiteSetting("GITEA_REPO"),
]);
if (!rawBaseUrl || !token || !repo) {
return { url: null, error: null };
}
const baseUrl = rawBaseUrl.replace(/\/$/, "");
try {
const labelIds = await resolveLabelIds(baseUrl, token, repo, LABEL_NAMES[opts.type] ?? []);
const res = await fetch(`${baseUrl}/api/v1/repos/${repo}/issues`, {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `token ${token}`,
},
body: JSON.stringify({
title: opts.title,
body: opts.body,
labels: labelIds,
}),
signal: AbortSignal.timeout(10000),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
return { url: 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" };
} 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
// otherwise be lost — err.message alone is often just "fetch failed"
// with the actual reason nested in err.cause. Log server-side with the
// full cause chain, and fold a short form into the returned message so
// it also shows up in the admin support view's tooltip, not just logs.
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 };
}
}