947ae5917c
Release APK / build (push) Has been cancelled
- DB migration v2: git_pushed_at column on incidents - markIncidentPushed(id): stamps the push timestamp - loadGitConfig(): shared helper in lib/git.ts, removes duplication - Home screen: colored dot per incident (red=never pushed, orange=dirty/modified after push, green=up to date); dot visible only when git is configured - Home screen: long-press enters selection mode, tap toggles; action bar with Select unpushed / Select all shortcuts + bulk push with sequential progress counter - [id].tsx: marks incident pushed after successful push, updates local state without reload Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
275 lines
9.4 KiB
TypeScript
275 lines
9.4 KiB
TypeScript
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;
|
|
}
|
|
|
|
const TIMEOUT_MS = 15_000;
|
|
|
|
export async function loadGitConfig(): Promise<GitConfig | null> {
|
|
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<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) };
|
|
}
|
|
}
|