import * as SecureStore from "expo-secure-store"; 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; } export interface DeleteResult { success: boolean; error?: string; } const TIMEOUT_MS = 15_000; export async function loadGitConfig(): Promise { const [provider, gu, gt, go, gr, ghu, ghown, ghrepo, glu, glt, glo, glr] = await Promise.all([ SecureStore.getItemAsync("git_provider"), SecureStore.getItemAsync("gitea_url"), SecureStore.getItemAsync("gitea_token"), SecureStore.getItemAsync("gitea_owner"), SecureStore.getItemAsync("gitea_repo"), SecureStore.getItemAsync("github_token"), SecureStore.getItemAsync("github_owner"), SecureStore.getItemAsync("github_repo"), SecureStore.getItemAsync("gitlab_url"), SecureStore.getItemAsync("gitlab_token"), SecureStore.getItemAsync("gitlab_owner"), SecureStore.getItemAsync("gitlab_repo"), ]); const p = (provider ?? "gitea") as GitProvider; let url: string | undefined; let token: string | null; let owner: string | null; let repo: string | null; if (p === "gitea") { url = gu ?? undefined; token = gt; owner = go; repo = gr; } else if (p === "github") { token = ghu; owner = ghown; repo = ghrepo; } else { url = glu ?? undefined; token = glt; owner = glo; repo = glr; } const needsUrl = p === "gitea" || p === "gitlab"; if (!token || !owner || !repo || (needsUrl && !url)) return null; return { provider: p, url, token, owner, repo }; } 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 deleteIncidentFromGit( incident: Incident, config: GitConfig ): Promise { switch (config.provider) { case "gitea": return deleteGitea(incident, config); case "github": return deleteGitHub(incident, config); case "gitlab": return deleteGitLab(incident, config); } } 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 || 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 = { 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 { 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 deleteGitea(incident: Incident, config: GitConfig): Promise { if (!config.url) return { success: false, error: "Gitea instance URL is not set." }; const filepath = incidentToFilename(incident); 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}` }, }); if (existing.status === 404) return { success: true }; if (!existing.ok) return { success: false, error: `GET ${existing.status}` }; const data = await existing.json() as { sha?: string }; if (!data.sha) return { success: false, error: "No SHA returned by API." }; const res = await fetchWithTimeout(endpoint, { method: "DELETE", headers: { Authorization: `token ${config.token}`, "Content-Type": "application/json" }, body: JSON.stringify({ message: `remove: ${incident.title}`, sha: data.sha, branch: "main" }), }); if (!res.ok) { const body = await res.text(); return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 200)}` }; } return { success: true }; } 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 deleteGitHub(incident: Incident, config: GitConfig): Promise { const filepath = incidentToFilename(incident); 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" }, }); if (existing.status === 404) return { success: true }; if (!existing.ok) return { success: false, error: `GET ${existing.status}` }; const data = await existing.json() as { sha?: string }; if (!data.sha) return { success: false, error: "No SHA returned by API." }; const res = await fetchWithTimeout(endpoint, { method: "DELETE", headers: { Authorization: `Bearer ${config.token}`, Accept: "application/vnd.github+json", "Content-Type": "application/json" }, body: JSON.stringify({ message: `remove: ${incident.title}`, sha: data.sha }), }); if (!res.ok) { const body = await res.text(); return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 200)}` }; } return { success: true }; } 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 deleteGitLab(incident: Incident, config: GitConfig): Promise { const filepath = incidentToFilename(incident); 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 res = await fetchWithTimeout(endpoint, { method: "DELETE", headers: { "PRIVATE-TOKEN": config.token, "Content-Type": "application/json" }, body: JSON.stringify({ branch: "main", commit_message: `remove: ${incident.title}` }), }); if (res.status === 404) return { success: true }; if (!res.ok) { const body = await res.text(); return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 200)}` }; } return { success: true }; } 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) }; } }