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 { 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 { 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 { 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) { const body = await existing.text(); return { success: false, error: `GET ${existing.status}: ${body.slice(0, 200)}` }; } const payload: Record = { message: `incident: ${incident.title}`, content: base64Content, }; 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(); 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. 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 { 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 = { 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 { 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) }; } }