Files
SheetHappens/lib/git.ts
T
billisdead fe1fdb9f9c fix: Gitea push 404 — add branch:main payload, handle 409 empty repo, clearer settings
- lib/git.ts: add branch:"main" to Gitea POST payload (required when repo has no
  branch yet or is freshly initialized); treat 409 on GET same as 404 (empty repo
  has no tree, Gitea returns 409 Conflict — proceed to create first file)
- lib/git.ts: improve 404 error hint with actionable checklist (URL format,
  owner/repo names, token scope, repo existence)
- settings.tsx: add Gitea info card clarifying URL format (no /api/v1), token
  format (raw value only, no "Bearer" prefix), repo initialization requirement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 14:12:09 +02:00

236 lines
8.0 KiB
TypeScript

import { incidentToFilename, renderMarkdown } from "./markdown";
import type { Incident } from "@/types/incident";
export type GitProvider = "gitea" | "github" | "gitlab";
export interface GitConfig {
provider: GitProvider;
url?: string; // required for gitea and self-hosted gitlab
token: string;
owner: string;
repo: string;
}
export interface PushResult {
success: boolean;
url?: string;
error?: string;
}
const TIMEOUT_MS = 15_000;
function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
return fetch(url, { ...init, signal: controller.signal }).finally(() =>
clearTimeout(timer)
);
}
export async function pushIncidentToGit(
incident: Incident,
config: GitConfig,
markdownTemplate?: string
): Promise<PushResult> {
switch (config.provider) {
case "gitea":
return pushGitea(incident, config, markdownTemplate);
case "github":
return pushGitHub(incident, config, markdownTemplate);
case "gitlab":
return pushGitLab(incident, config, markdownTemplate);
}
}
async function pushGitea(
incident: Incident,
config: GitConfig,
markdownTemplate?: string
): Promise<PushResult> {
if (!config.url) {
return { success: false, error: "Gitea instance URL is not set. Check Settings → Git Repository." };
}
if (!config.token) {
return { success: false, error: "Gitea token is not set. Check Settings → Git Repository." };
}
if (!config.owner || !config.repo) {
return { success: false, error: "Gitea owner or repository is not set. Check Settings → Git Repository." };
}
const filepath = incidentToFilename(incident);
const content = renderMarkdown(incident, markdownTemplate);
const base64Content = btoa(unescape(encodeURIComponent(content)));
const apiBase = config.url.replace(/\/$/, "");
const endpoint = `${apiBase}/api/v1/repos/${config.owner}/${config.repo}/contents/${filepath}`;
try {
const existing = await fetchWithTimeout(endpoint, {
headers: { Authorization: `token ${config.token}` },
});
let sha: string | undefined;
if (existing.ok) {
const data = await existing.json() as { sha?: string };
sha = data.sha;
} else if (existing.status === 404 || existing.status === 409) {
// 404 = file not found (normal for first push)
// 409 = repo is empty / no branch yet — proceed, we'll create with branch:"main"
} else {
const body = await existing.text();
return { success: false, error: `GET ${existing.status}: ${body.slice(0, 200)}` };
}
const payload: Record<string, string> = {
message: `incident: ${incident.title}`,
content: base64Content,
branch: "main",
};
if (sha) payload.sha = sha;
// POST creates, PUT updates — Gitea requires the correct verb
const res = await fetchWithTimeout(endpoint, {
method: sha ? "PUT" : "POST",
headers: {
Authorization: `token ${config.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.text();
let hint = "";
if (res.status === 404) {
hint = "\n\nCheck: instance URL (no /api/v1), owner and repo names are exact, token has write:repository scope, and the repo exists on Gitea.";
}
return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 300)}${hint}` };
}
const data = await res.json() as { content?: { html_url?: string } };
return { success: true, url: data.content?.html_url };
} catch (e) {
if (e instanceof Error && e.name === "AbortError") {
return { success: false, error: `Request timed out after ${TIMEOUT_MS / 1000}s. Check that the Gitea URL is reachable.` };
}
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
}
async function pushGitHub(
incident: Incident,
config: GitConfig,
markdownTemplate?: string
): Promise<PushResult> {
if (!config.token) {
return { success: false, error: "GitHub token is not set. Check Settings → Git Repository." };
}
if (!config.owner || !config.repo) {
return { success: false, error: "GitHub owner or repository is not set. Check Settings → Git Repository." };
}
const filepath = incidentToFilename(incident);
const content = renderMarkdown(incident, markdownTemplate);
const base64Content = btoa(unescape(encodeURIComponent(content)));
const endpoint = `https://api.github.com/repos/${config.owner}/${config.repo}/contents/${filepath}`;
try {
const existing = await fetchWithTimeout(endpoint, {
headers: {
Authorization: `Bearer ${config.token}`,
Accept: "application/vnd.github+json",
},
});
let sha: string | undefined;
if (existing.ok) {
const data = await existing.json() as { sha?: string };
sha = data.sha;
} else if (existing.status !== 404) {
const body = await existing.text();
return { success: false, error: `GET ${existing.status}: ${body.slice(0, 200)}` };
}
const payload: Record<string, string> = {
message: `incident: ${incident.title}`,
content: base64Content,
};
if (sha) payload.sha = sha;
const res = await fetchWithTimeout(endpoint, {
method: "PUT",
headers: {
Authorization: `Bearer ${config.token}`,
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.text();
return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 300)}` };
}
const data = await res.json() as { content?: { html_url?: string } };
return { success: true, url: data.content?.html_url };
} catch (e) {
if (e instanceof Error && e.name === "AbortError") {
return { success: false, error: `Request timed out after ${TIMEOUT_MS / 1000}s.` };
}
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
}
async function pushGitLab(
incident: Incident,
config: GitConfig,
markdownTemplate?: string
): Promise<PushResult> {
if (!config.token) {
return { success: false, error: "GitLab token is not set. Check Settings → Git Repository." };
}
if (!config.owner || !config.repo) {
return { success: false, error: "GitLab namespace or repository is not set. Check Settings → Git Repository." };
}
const filepath = incidentToFilename(incident);
const content = renderMarkdown(incident, markdownTemplate);
const base64Content = btoa(unescape(encodeURIComponent(content)));
const apiBase = (config.url ?? "https://gitlab.com").replace(/\/$/, "");
const projectId = encodeURIComponent(`${config.owner}/${config.repo}`);
const encodedPath = encodeURIComponent(filepath);
const endpoint = `${apiBase}/api/v4/projects/${projectId}/repository/files/${encodedPath}`;
try {
const existing = await fetchWithTimeout(`${endpoint}?ref=HEAD`, {
headers: { "PRIVATE-TOKEN": config.token },
});
const method = existing.ok ? "PUT" : "POST";
const res = await fetchWithTimeout(endpoint, {
method,
headers: {
"PRIVATE-TOKEN": config.token,
"Content-Type": "application/json",
},
body: JSON.stringify({
branch: "main",
content: base64Content,
encoding: "base64",
commit_message: `incident: ${incident.title}`,
}),
});
if (!res.ok) {
const body = await res.text();
return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 300)}` };
}
const data = await res.json() as { file_path?: string };
const webUrl = data.file_path
? `${apiBase}/${config.owner}/${config.repo}/-/blob/main/${data.file_path}`
: undefined;
return { success: true, url: webUrl };
} catch (e) {
if (e instanceof Error && e.name === "AbortError") {
return { success: false, error: `Request timed out after ${TIMEOUT_MS / 1000}s.` };
}
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
}