1 Commits

Author SHA1 Message Date
billisdead c915d5f2ad feat: delete incident, fix git push, title template, clarify AI settings
Release APK / build (push) Has been cancelled
- IncidentDetailScreen: add Delete button (confirm dialog) wired to deleteIncident()
- git.ts: fix Gitea POST vs PUT (use PUT when file exists/sha present); add
  pre-flight validation for missing URL/token/owner/repo; add 15s fetch timeout
  with AbortError handling; improve error messages with actionable hints
- [id].tsx: guard URL for Gitea/GitLab before calling pushIncidentToGit
- settings.tsx: move ToggleRow/SegmentRow/Field outside SettingsScreen to fix
  TextInput focus loss on each keystroke (component remount on rerender)
- settings.tsx: add Title Template setting with auto-increment info card
- settings.tsx: add AI base URL + model fields; expand AI section description
- new.tsx: pre-fill title from pref_title_template; increment counter after save

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 13:34:41 +02:00
4 changed files with 349 additions and 167 deletions
+39 -2
View File
@@ -10,7 +10,7 @@ import {
import { useLocalSearchParams, router, useNavigation } from "expo-router"; import { useLocalSearchParams, router, useNavigation } from "expo-router";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import * as SecureStore from "expo-secure-store"; import * as SecureStore from "expo-secure-store";
import { getIncidentById, updateIncident } from "@/lib/db"; import { getIncidentById, updateIncident, deleteIncident } from "@/lib/db";
import { renderMarkdown } from "@/lib/markdown"; import { renderMarkdown } from "@/lib/markdown";
import { pushIncidentToGit } from "@/lib/git"; import { pushIncidentToGit } from "@/lib/git";
import type { GitProvider } from "@/lib/git"; import type { GitProvider } from "@/lib/git";
@@ -54,6 +54,25 @@ export default function IncidentDetailScreen() {
} }
}, [id]); }, [id]);
async function handleDelete() {
if (!incident) return;
Alert.alert(
"Delete incident?",
"This action cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
await deleteIncident(incident.id);
router.replace("/");
},
},
]
);
}
async function handleResolve() { async function handleResolve() {
if (!incident) return; if (!incident) return;
await updateIncident(incident.id, { status: "resolved" }); await updateIncident(incident.id, { status: "resolved" });
@@ -102,7 +121,8 @@ export default function IncidentDetailScreen() {
repo = gitlabRepo; repo = gitlabRepo;
} }
if (!token || !owner || !repo) { const needsUrl = p === "gitea" || p === "gitlab";
if (!token || !owner || !repo || (needsUrl && !url)) {
Alert.alert("Git not configured", `Set up ${p.charAt(0).toUpperCase() + p.slice(1)} in Settings first.`); Alert.alert("Git not configured", `Set up ${p.charAt(0).toUpperCase() + p.slice(1)} in Settings first.`);
return; return;
} }
@@ -291,6 +311,23 @@ export default function IncidentDetailScreen() {
Edit Edit
</Text> </Text>
</Pressable> </Pressable>
<Pressable
onPress={handleDelete}
style={{
backgroundColor: "#ef444410",
borderWidth: 1,
borderColor: "#ef4444",
borderRadius: 10,
padding: 14,
alignItems: "center",
marginTop: 8,
}}
>
<Text style={{ color: "#ef4444", fontWeight: "600", fontSize: 15 }}>
Delete Incident
</Text>
</Pressable>
</View> </View>
</ScrollView> </ScrollView>
); );
+20
View File
@@ -10,6 +10,7 @@ import {
Alert, Alert,
} from "react-native"; } from "react-native";
import { router, useLocalSearchParams, useNavigation } from "expo-router"; import { router, useLocalSearchParams, useNavigation } from "expo-router";
import * as SecureStore from "expo-secure-store";
import { import {
createIncident, createIncident,
updateIncident, updateIncident,
@@ -48,6 +49,14 @@ const LABEL_STYLE = {
type VoiceField = "title" | "service" | "symptom" | "rootCause" | "fix"; type VoiceField = "title" | "service" | "symptom" | "rootCause" | "fix";
function incrementTemplate(template: string): string {
const match = template.match(/^([\s\S]*?)(\d+)$/);
if (!match) return template;
const [, prefix, numStr] = match;
const next = (parseInt(numStr, 10) + 1).toString().padStart(numStr.length, "0");
return prefix + next;
}
export default function NewIncidentScreen() { export default function NewIncidentScreen() {
const { editId } = useLocalSearchParams<{ editId?: string }>(); const { editId } = useLocalSearchParams<{ editId?: string }>();
const navigation = useNavigation(); const navigation = useNavigation();
@@ -74,6 +83,13 @@ export default function NewIncidentScreen() {
getDistinctServices().then(setKnownServices); getDistinctServices().then(setKnownServices);
}, []); }, []);
useEffect(() => {
if (editId) return;
SecureStore.getItemAsync("pref_title_template").then((tpl) => {
if (tpl) setForm((f) => ({ ...f, title: tpl }));
});
}, [editId]);
useEffect(() => { useEffect(() => {
if (!editId) return; if (!editId) return;
navigation.setOptions({ title: "Edit Incident" }); navigation.setOptions({ title: "Edit Incident" });
@@ -144,6 +160,10 @@ export default function NewIncidentScreen() {
await updateIncident(editId, form); await updateIncident(editId, form);
} else { } else {
await createIncident(form); await createIncident(form);
const tpl = await SecureStore.getItemAsync("pref_title_template");
if (tpl) {
await SecureStore.setItemAsync("pref_title_template", incrementTemplate(tpl));
}
} }
router.back(); router.back();
} catch { } catch {
+167 -95
View File
@@ -17,18 +17,18 @@ const KEYS = {
HOME_VIEW: "pref_home_view", HOME_VIEW: "pref_home_view",
AI_ENABLED: "pref_ai_enabled", AI_ENABLED: "pref_ai_enabled",
AI_KEY: "pref_ai_key", AI_KEY: "pref_ai_key",
AI_BASE_URL: "pref_ai_base_url",
AI_MODEL: "pref_ai_model",
TITLE_TEMPLATE: "pref_title_template",
MD_TEMPLATE: "md_template", MD_TEMPLATE: "md_template",
GIT_PROVIDER: "git_provider", GIT_PROVIDER: "git_provider",
// Gitea
GITEA_URL: "gitea_url", GITEA_URL: "gitea_url",
GITEA_TOKEN: "gitea_token", GITEA_TOKEN: "gitea_token",
GITEA_OWNER: "gitea_owner", GITEA_OWNER: "gitea_owner",
GITEA_REPO: "gitea_repo", GITEA_REPO: "gitea_repo",
// GitHub
GITHUB_TOKEN: "github_token", GITHUB_TOKEN: "github_token",
GITHUB_OWNER: "github_owner", GITHUB_OWNER: "github_owner",
GITHUB_REPO: "github_repo", GITHUB_REPO: "github_repo",
// GitLab
GITLAB_URL: "gitlab_url", GITLAB_URL: "gitlab_url",
GITLAB_TOKEN: "gitlab_token", GITLAB_TOKEN: "gitlab_token",
GITLAB_OWNER: "gitlab_owner", GITLAB_OWNER: "gitlab_owner",
@@ -70,96 +70,8 @@ const PROVIDER_LABELS: Record<GitProvider, string> = {
gitlab: "GitLab", gitlab: "GitLab",
}; };
export default function SettingsScreen() { // Defined outside SettingsScreen — prevents remount on every rerender, which
const [inputMode, setInputMode] = useState<"voice" | "form">("form"); // would cause TextInput to lose focus after each keystroke.
const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard");
const [aiEnabled, setAiEnabled] = useState(false);
const [aiKey, setAiKey] = useState("");
const [mdTemplate, setMdTemplate] = useState(DEFAULT_TEMPLATE);
const [saved, setSaved] = useState(false);
// Git provider
const [gitProvider, setGitProvider] = useState<GitProvider>("gitea");
// Gitea
const [giteaUrl, setGiteaUrl] = useState("");
const [giteaToken, setGiteaToken] = useState("");
const [giteaOwner, setGiteaOwner] = useState("");
const [giteaRepo, setGiteaRepo] = useState("");
// GitHub
const [githubToken, setGithubToken] = useState("");
const [githubOwner, setGithubOwner] = useState("");
const [githubRepo, setGithubRepo] = useState("");
// GitLab
const [gitlabUrl, setGitlabUrl] = useState("");
const [gitlabToken, setGitlabToken] = useState("");
const [gitlabOwner, setGitlabOwner] = useState("");
const [gitlabRepo, setGitlabRepo] = useState("");
useEffect(() => {
(async () => {
const [im, hv, ai, key, tpl, gp, gu, gt, go, gr, ghu, ghown, ghrepo, glu, glt, glo, glr] =
await Promise.all([
SecureStore.getItemAsync(KEYS.INPUT_MODE),
SecureStore.getItemAsync(KEYS.HOME_VIEW),
SecureStore.getItemAsync(KEYS.AI_ENABLED),
SecureStore.getItemAsync(KEYS.AI_KEY),
SecureStore.getItemAsync(KEYS.MD_TEMPLATE),
SecureStore.getItemAsync(KEYS.GIT_PROVIDER),
SecureStore.getItemAsync(KEYS.GITEA_URL),
SecureStore.getItemAsync(KEYS.GITEA_TOKEN),
SecureStore.getItemAsync(KEYS.GITEA_OWNER),
SecureStore.getItemAsync(KEYS.GITEA_REPO),
SecureStore.getItemAsync(KEYS.GITHUB_TOKEN),
SecureStore.getItemAsync(KEYS.GITHUB_OWNER),
SecureStore.getItemAsync(KEYS.GITHUB_REPO),
SecureStore.getItemAsync(KEYS.GITLAB_URL),
SecureStore.getItemAsync(KEYS.GITLAB_TOKEN),
SecureStore.getItemAsync(KEYS.GITLAB_OWNER),
SecureStore.getItemAsync(KEYS.GITLAB_REPO),
]);
if (im) setInputMode(im as "voice" | "form");
if (hv) setHomeView(hv as "dashboard" | "capture");
if (ai) setAiEnabled(ai === "true");
if (key) setAiKey(key);
if (tpl) setMdTemplate(tpl);
if (gp) setGitProvider(gp as GitProvider);
if (gu) setGiteaUrl(gu);
if (gt) setGiteaToken(gt);
if (go) setGiteaOwner(go);
if (gr) setGiteaRepo(gr);
if (ghu) setGithubToken(ghu);
if (ghown) setGithubOwner(ghown);
if (ghrepo) setGithubRepo(ghrepo);
if (glu) setGitlabUrl(glu);
if (glt) setGitlabToken(glt);
if (glo) setGitlabOwner(glo);
if (glr) setGitlabRepo(glr);
})();
}, []);
async function handleSave() {
await Promise.all([
SecureStore.setItemAsync(KEYS.INPUT_MODE, inputMode),
SecureStore.setItemAsync(KEYS.HOME_VIEW, homeView),
SecureStore.setItemAsync(KEYS.AI_ENABLED, String(aiEnabled)),
SecureStore.setItemAsync(KEYS.AI_KEY, aiKey),
SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate),
SecureStore.setItemAsync(KEYS.GIT_PROVIDER, gitProvider),
SecureStore.setItemAsync(KEYS.GITEA_URL, giteaUrl),
SecureStore.setItemAsync(KEYS.GITEA_TOKEN, giteaToken),
SecureStore.setItemAsync(KEYS.GITEA_OWNER, giteaOwner),
SecureStore.setItemAsync(KEYS.GITEA_REPO, giteaRepo),
SecureStore.setItemAsync(KEYS.GITHUB_TOKEN, githubToken),
SecureStore.setItemAsync(KEYS.GITHUB_OWNER, githubOwner),
SecureStore.setItemAsync(KEYS.GITHUB_REPO, githubRepo),
SecureStore.setItemAsync(KEYS.GITLAB_URL, gitlabUrl),
SecureStore.setItemAsync(KEYS.GITLAB_TOKEN, gitlabToken),
SecureStore.setItemAsync(KEYS.GITLAB_OWNER, gitlabOwner),
SecureStore.setItemAsync(KEYS.GITLAB_REPO, gitlabRepo),
]);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
function ToggleRow({ function ToggleRow({
label, label,
@@ -266,12 +178,116 @@ export default function SettingsScreen() {
placeholderTextColor={Colors.textDim} placeholderTextColor={Colors.textDim}
secureTextEntry={secure} secureTextEntry={secure}
autoCapitalize="none" autoCapitalize="none"
autoCorrect={false}
keyboardType={url ? "url" : "default"} keyboardType={url ? "url" : "default"}
/> />
</> </>
); );
} }
export default function SettingsScreen() {
const [inputMode, setInputMode] = useState<"voice" | "form">("form");
const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard");
const [aiEnabled, setAiEnabled] = useState(false);
const [aiKey, setAiKey] = useState("");
const [aiBaseUrl, setAiBaseUrl] = useState("");
const [aiModel, setAiModel] = useState("");
const [titleTemplate, setTitleTemplate] = useState("");
const [mdTemplate, setMdTemplate] = useState(DEFAULT_TEMPLATE);
const [saved, setSaved] = useState(false);
const [gitProvider, setGitProvider] = useState<GitProvider>("gitea");
const [giteaUrl, setGiteaUrl] = useState("");
const [giteaToken, setGiteaToken] = useState("");
const [giteaOwner, setGiteaOwner] = useState("");
const [giteaRepo, setGiteaRepo] = useState("");
const [githubToken, setGithubToken] = useState("");
const [githubOwner, setGithubOwner] = useState("");
const [githubRepo, setGithubRepo] = useState("");
const [gitlabUrl, setGitlabUrl] = useState("");
const [gitlabToken, setGitlabToken] = useState("");
const [gitlabOwner, setGitlabOwner] = useState("");
const [gitlabRepo, setGitlabRepo] = useState("");
useEffect(() => {
(async () => {
const [
im, hv, ai, key, aiUrl, aiMdl, titleTpl, tpl,
gp, gu, gt, go, gr,
ghu, ghown, ghrepo,
glu, glt, glo, glr,
] = await Promise.all([
SecureStore.getItemAsync(KEYS.INPUT_MODE),
SecureStore.getItemAsync(KEYS.HOME_VIEW),
SecureStore.getItemAsync(KEYS.AI_ENABLED),
SecureStore.getItemAsync(KEYS.AI_KEY),
SecureStore.getItemAsync(KEYS.AI_BASE_URL),
SecureStore.getItemAsync(KEYS.AI_MODEL),
SecureStore.getItemAsync(KEYS.TITLE_TEMPLATE),
SecureStore.getItemAsync(KEYS.MD_TEMPLATE),
SecureStore.getItemAsync(KEYS.GIT_PROVIDER),
SecureStore.getItemAsync(KEYS.GITEA_URL),
SecureStore.getItemAsync(KEYS.GITEA_TOKEN),
SecureStore.getItemAsync(KEYS.GITEA_OWNER),
SecureStore.getItemAsync(KEYS.GITEA_REPO),
SecureStore.getItemAsync(KEYS.GITHUB_TOKEN),
SecureStore.getItemAsync(KEYS.GITHUB_OWNER),
SecureStore.getItemAsync(KEYS.GITHUB_REPO),
SecureStore.getItemAsync(KEYS.GITLAB_URL),
SecureStore.getItemAsync(KEYS.GITLAB_TOKEN),
SecureStore.getItemAsync(KEYS.GITLAB_OWNER),
SecureStore.getItemAsync(KEYS.GITLAB_REPO),
]);
if (im) setInputMode(im as "voice" | "form");
if (hv) setHomeView(hv as "dashboard" | "capture");
if (ai) setAiEnabled(ai === "true");
if (key) setAiKey(key);
if (aiUrl) setAiBaseUrl(aiUrl);
if (aiMdl) setAiModel(aiMdl);
if (titleTpl) setTitleTemplate(titleTpl);
if (tpl) setMdTemplate(tpl);
if (gp) setGitProvider(gp as GitProvider);
if (gu) setGiteaUrl(gu);
if (gt) setGiteaToken(gt);
if (go) setGiteaOwner(go);
if (gr) setGiteaRepo(gr);
if (ghu) setGithubToken(ghu);
if (ghown) setGithubOwner(ghown);
if (ghrepo) setGithubRepo(ghrepo);
if (glu) setGitlabUrl(glu);
if (glt) setGitlabToken(glt);
if (glo) setGitlabOwner(glo);
if (glr) setGitlabRepo(glr);
})();
}, []);
async function handleSave() {
await Promise.all([
SecureStore.setItemAsync(KEYS.INPUT_MODE, inputMode),
SecureStore.setItemAsync(KEYS.HOME_VIEW, homeView),
SecureStore.setItemAsync(KEYS.AI_ENABLED, String(aiEnabled)),
SecureStore.setItemAsync(KEYS.AI_KEY, aiKey),
SecureStore.setItemAsync(KEYS.AI_BASE_URL, aiBaseUrl),
SecureStore.setItemAsync(KEYS.AI_MODEL, aiModel),
SecureStore.setItemAsync(KEYS.TITLE_TEMPLATE, titleTemplate),
SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate),
SecureStore.setItemAsync(KEYS.GIT_PROVIDER, gitProvider),
SecureStore.setItemAsync(KEYS.GITEA_URL, giteaUrl),
SecureStore.setItemAsync(KEYS.GITEA_TOKEN, giteaToken),
SecureStore.setItemAsync(KEYS.GITEA_OWNER, giteaOwner),
SecureStore.setItemAsync(KEYS.GITEA_REPO, giteaRepo),
SecureStore.setItemAsync(KEYS.GITHUB_TOKEN, githubToken),
SecureStore.setItemAsync(KEYS.GITHUB_OWNER, githubOwner),
SecureStore.setItemAsync(KEYS.GITHUB_REPO, githubRepo),
SecureStore.setItemAsync(KEYS.GITLAB_URL, gitlabUrl),
SecureStore.setItemAsync(KEYS.GITLAB_TOKEN, gitlabToken),
SecureStore.setItemAsync(KEYS.GITLAB_OWNER, gitlabOwner),
SecureStore.setItemAsync(KEYS.GITLAB_REPO, gitlabRepo),
]);
setSaved(true);
setTimeout(() => setSaved(false), 2000);
}
return ( return (
<ScrollView <ScrollView
style={{ flex: 1, backgroundColor: Colors.bg }} style={{ flex: 1, backgroundColor: Colors.bg }}
@@ -300,20 +316,76 @@ export default function SettingsScreen() {
]} ]}
/> />
<Text style={SECTION_TITLE}>AI (optional)</Text> <Field
label="Title Template"
value={titleTemplate}
onChange={setTitleTemplate}
placeholder="INC-001 (leave empty to disable)"
/>
<View
style={{
backgroundColor: Colors.surface,
borderRadius: 8,
padding: 10,
marginTop: -8,
marginBottom: 14,
borderLeftWidth: 3,
borderLeftColor: Colors.text2,
}}
>
<Text style={{ color: Colors.text2, fontSize: 12, lineHeight: 18 }}>
Pre-fills the Title field on new incidents. If the value ends with
digits, the number is auto-incremented after each save.{"\n"}
e.g. INC-001 INC-002 INC-003
</Text>
</View>
<Text style={SECTION_TITLE}>AI Root Cause Assistant</Text>
<View
style={{
backgroundColor: Colors.surface,
borderRadius: 8,
padding: 12,
marginBottom: 14,
borderLeftWidth: 3,
borderLeftColor: Colors.text2,
}}
>
<Text style={{ color: Colors.text2, fontSize: 13, lineHeight: 20 }}>
When enabled, a button appears on the capture form to suggest a root
cause and fix based on the incident title and symptom. Uses any
OpenAI-compatible API set the base URL below to point at a local
Ollama instance, or leave empty for OpenAI (api.openai.com).
</Text>
</View>
<ToggleRow <ToggleRow
label="Enable AI assistance" label="Enable AI assistance"
value={aiEnabled} value={aiEnabled}
onValueChange={setAiEnabled} onValueChange={setAiEnabled}
/> />
{aiEnabled && ( {aiEnabled && (
<>
<Field
label="API Base URL"
value={aiBaseUrl}
onChange={setAiBaseUrl}
placeholder="http://192.168.x.x:11434/v1 (empty = OpenAI)"
url
/>
<Field
label="Model"
value={aiModel}
onChange={setAiModel}
placeholder="gpt-4o-mini or llama3.2"
/>
<Field <Field
label="API Key" label="API Key"
value={aiKey} value={aiKey}
onChange={setAiKey} onChange={setAiKey}
placeholder="sk-..." placeholder="sk-... (leave empty for Ollama)"
secure secure
/> />
</>
)} )}
<Text style={SECTION_TITLE}>Git Repository</Text> <Text style={SECTION_TITLE}>Git Repository</Text>
+72 -19
View File
@@ -17,6 +17,16 @@ export interface PushResult {
error?: 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( export async function pushIncidentToGit(
incident: Incident, incident: Incident,
config: GitConfig, config: GitConfig,
@@ -37,43 +47,61 @@ async function pushGitea(
config: GitConfig, config: GitConfig,
markdownTemplate?: string markdownTemplate?: string
): Promise<PushResult> { ): 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 filepath = incidentToFilename(incident);
const content = renderMarkdown(incident, markdownTemplate); const content = renderMarkdown(incident, markdownTemplate);
const base64Content = btoa(unescape(encodeURIComponent(content))); const base64Content = btoa(unescape(encodeURIComponent(content)));
const apiBase = (config.url ?? "").replace(/\/$/, ""); const apiBase = config.url.replace(/\/$/, "");
const endpoint = `${apiBase}/api/v1/repos/${config.owner}/${config.repo}/contents/${filepath}`; const endpoint = `${apiBase}/api/v1/repos/${config.owner}/${config.repo}/contents/${filepath}`;
try { try {
const existing = await fetch(endpoint, { const existing = await fetchWithTimeout(endpoint, {
headers: { Authorization: `Bearer ${config.token}` }, headers: { Authorization: `token ${config.token}` },
}); });
let sha: string | undefined; let sha: string | undefined;
if (existing.ok) { if (existing.ok) {
const data = await existing.json() as { sha?: string }; const data = await existing.json() as { sha?: string };
sha = data.sha; 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 body: Record<string, string> = { const payload: Record<string, string> = {
message: `incident: ${incident.title}`, message: `incident: ${incident.title}`,
content: base64Content, content: base64Content,
}; };
if (sha) body.sha = sha; if (sha) payload.sha = sha;
const res = await fetch(endpoint, { // POST creates, PUT updates — Gitea requires the correct verb
method: "POST", const res = await fetchWithTimeout(endpoint, {
method: sha ? "PUT" : "POST",
headers: { headers: {
Authorization: `Bearer ${config.token}`, Authorization: `token ${config.token}`,
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify(body), body: JSON.stringify(payload),
}); });
if (!res.ok) { if (!res.ok) {
return { success: false, error: `HTTP ${res.status}: ${await res.text()}` }; 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 } }; const data = await res.json() as { content?: { html_url?: string } };
return { success: true, url: data.content?.html_url }; return { success: true, url: data.content?.html_url };
} catch (e) { } 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) }; return { success: false, error: e instanceof Error ? e.message : String(e) };
} }
} }
@@ -83,13 +111,20 @@ async function pushGitHub(
config: GitConfig, config: GitConfig,
markdownTemplate?: string markdownTemplate?: string
): Promise<PushResult> { ): 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 filepath = incidentToFilename(incident);
const content = renderMarkdown(incident, markdownTemplate); const content = renderMarkdown(incident, markdownTemplate);
const base64Content = btoa(unescape(encodeURIComponent(content))); const base64Content = btoa(unescape(encodeURIComponent(content)));
const endpoint = `https://api.github.com/repos/${config.owner}/${config.repo}/contents/${filepath}`; const endpoint = `https://api.github.com/repos/${config.owner}/${config.repo}/contents/${filepath}`;
try { try {
const existing = await fetch(endpoint, { const existing = await fetchWithTimeout(endpoint, {
headers: { headers: {
Authorization: `Bearer ${config.token}`, Authorization: `Bearer ${config.token}`,
Accept: "application/vnd.github+json", Accept: "application/vnd.github+json",
@@ -99,30 +134,37 @@ async function pushGitHub(
if (existing.ok) { if (existing.ok) {
const data = await existing.json() as { sha?: string }; const data = await existing.json() as { sha?: string };
sha = data.sha; 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 body: Record<string, string> = { const payload: Record<string, string> = {
message: `incident: ${incident.title}`, message: `incident: ${incident.title}`,
content: base64Content, content: base64Content,
}; };
if (sha) body.sha = sha; if (sha) payload.sha = sha;
const res = await fetch(endpoint, { const res = await fetchWithTimeout(endpoint, {
method: "PUT", method: "PUT",
headers: { headers: {
Authorization: `Bearer ${config.token}`, Authorization: `Bearer ${config.token}`,
Accept: "application/vnd.github+json", Accept: "application/vnd.github+json",
"Content-Type": "application/json", "Content-Type": "application/json",
}, },
body: JSON.stringify(body), body: JSON.stringify(payload),
}); });
if (!res.ok) { if (!res.ok) {
return { success: false, error: `HTTP ${res.status}: ${await res.text()}` }; 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 } }; const data = await res.json() as { content?: { html_url?: string } };
return { success: true, url: data.content?.html_url }; return { success: true, url: data.content?.html_url };
} catch (e) { } 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) }; return { success: false, error: e instanceof Error ? e.message : String(e) };
} }
} }
@@ -132,6 +174,13 @@ async function pushGitLab(
config: GitConfig, config: GitConfig,
markdownTemplate?: string markdownTemplate?: string
): Promise<PushResult> { ): 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 filepath = incidentToFilename(incident);
const content = renderMarkdown(incident, markdownTemplate); const content = renderMarkdown(incident, markdownTemplate);
const base64Content = btoa(unescape(encodeURIComponent(content))); const base64Content = btoa(unescape(encodeURIComponent(content)));
@@ -141,12 +190,12 @@ async function pushGitLab(
const endpoint = `${apiBase}/api/v4/projects/${projectId}/repository/files/${encodedPath}`; const endpoint = `${apiBase}/api/v4/projects/${projectId}/repository/files/${encodedPath}`;
try { try {
const existing = await fetch(`${endpoint}?ref=HEAD`, { const existing = await fetchWithTimeout(`${endpoint}?ref=HEAD`, {
headers: { "PRIVATE-TOKEN": config.token }, headers: { "PRIVATE-TOKEN": config.token },
}); });
const method = existing.ok ? "PUT" : "POST"; const method = existing.ok ? "PUT" : "POST";
const res = await fetch(endpoint, { const res = await fetchWithTimeout(endpoint, {
method, method,
headers: { headers: {
"PRIVATE-TOKEN": config.token, "PRIVATE-TOKEN": config.token,
@@ -161,7 +210,8 @@ async function pushGitLab(
}); });
if (!res.ok) { if (!res.ok) {
return { success: false, error: `HTTP ${res.status}: ${await res.text()}` }; 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 data = await res.json() as { file_path?: string };
const webUrl = data.file_path const webUrl = data.file_path
@@ -169,6 +219,9 @@ async function pushGitLab(
: undefined; : undefined;
return { success: true, url: webUrl }; return { success: true, url: webUrl };
} catch (e) { } 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) }; return { success: false, error: e instanceof Error ? e.message : String(e) };
} }
} }