6502307340
Gitea's POST /repos/{repo}/issues expects `labels` as an array of numeric
label IDs, not name strings — every issue creation was failing with a 422
("cannot unmarshal JSON string into Go int64"). Now fetches the repo's
label list and resolves "bug"/"enhancement"/"question" to IDs by name
first; a repo with no matching labels (or a failed lookup) just creates
the issue unlabeled instead of failing the whole request.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
89 lines
3.4 KiB
TypeScript
89 lines
3.4 KiB
TypeScript
import { getSiteSetting } from "@/lib/site-settings";
|
|
|
|
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 [];
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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 };
|
|
}
|
|
}
|