diff --git a/app/incident/[id].tsx b/app/incident/[id].tsx index 8980e14..14cc36b 100644 --- a/app/incident/[id].tsx +++ b/app/incident/[id].tsx @@ -10,7 +10,7 @@ import { import { useLocalSearchParams, router, useNavigation } from "expo-router"; import * as Clipboard from "expo-clipboard"; 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 { pushIncidentToGit } from "@/lib/git"; import type { GitProvider } from "@/lib/git"; @@ -54,6 +54,25 @@ export default function IncidentDetailScreen() { } }, [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() { if (!incident) return; await updateIncident(incident.id, { status: "resolved" }); @@ -102,7 +121,8 @@ export default function IncidentDetailScreen() { 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.`); return; } @@ -291,6 +311,23 @@ export default function IncidentDetailScreen() { Edit + + + + Delete Incident + + ); diff --git a/app/new.tsx b/app/new.tsx index 5311b24..0c75a00 100644 --- a/app/new.tsx +++ b/app/new.tsx @@ -10,6 +10,7 @@ import { Alert, } from "react-native"; import { router, useLocalSearchParams, useNavigation } from "expo-router"; +import * as SecureStore from "expo-secure-store"; import { createIncident, updateIncident, @@ -48,6 +49,14 @@ const LABEL_STYLE = { 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() { const { editId } = useLocalSearchParams<{ editId?: string }>(); const navigation = useNavigation(); @@ -74,6 +83,13 @@ export default function NewIncidentScreen() { getDistinctServices().then(setKnownServices); }, []); + useEffect(() => { + if (editId) return; + SecureStore.getItemAsync("pref_title_template").then((tpl) => { + if (tpl) setForm((f) => ({ ...f, title: tpl })); + }); + }, [editId]); + useEffect(() => { if (!editId) return; navigation.setOptions({ title: "Edit Incident" }); @@ -144,6 +160,10 @@ export default function NewIncidentScreen() { await updateIncident(editId, form); } else { await createIncident(form); + const tpl = await SecureStore.getItemAsync("pref_title_template"); + if (tpl) { + await SecureStore.setItemAsync("pref_title_template", incrementTemplate(tpl)); + } } router.back(); } catch { diff --git a/app/settings.tsx b/app/settings.tsx index 5e06173..9977f1e 100644 --- a/app/settings.tsx +++ b/app/settings.tsx @@ -17,18 +17,18 @@ const KEYS = { HOME_VIEW: "pref_home_view", AI_ENABLED: "pref_ai_enabled", 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", GIT_PROVIDER: "git_provider", - // Gitea GITEA_URL: "gitea_url", GITEA_TOKEN: "gitea_token", GITEA_OWNER: "gitea_owner", GITEA_REPO: "gitea_repo", - // GitHub GITHUB_TOKEN: "github_token", GITHUB_OWNER: "github_owner", GITHUB_REPO: "github_repo", - // GitLab GITLAB_URL: "gitlab_url", GITLAB_TOKEN: "gitlab_token", GITLAB_OWNER: "gitlab_owner", @@ -70,26 +70,140 @@ const PROVIDER_LABELS: Record = { gitlab: "GitLab", }; +// Defined outside SettingsScreen — prevents remount on every rerender, which +// would cause TextInput to lose focus after each keystroke. + +function ToggleRow({ + label, + value, + onValueChange, +}: { + label: string; + value: boolean; + onValueChange: (v: boolean) => void; +}) { + return ( + + {label} + + + ); +} + +function SegmentRow({ + label, + options, + value, + onChange, +}: { + label: string; + options: { key: string; label: string }[]; + value: string; + onChange: (v: string) => void; +}) { + return ( + + {label} + + {options.map((opt) => ( + onChange(opt.key)} + style={{ + flex: 1, + backgroundColor: + value === opt.key ? Colors.primary : Colors.surface, + borderRadius: 8, + padding: 12, + alignItems: "center", + borderWidth: 1, + borderColor: + value === opt.key ? Colors.primary : Colors.border, + }} + > + + {opt.label} + + + ))} + + + ); +} + +function Field({ + label, + value, + onChange, + placeholder, + secure, + url, +}: { + label: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; + secure?: boolean; + url?: boolean; +}) { + return ( + <> + {label} + + + ); +} + 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); - // Git provider const [gitProvider, setGitProvider] = useState("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(""); @@ -97,30 +211,40 @@ export default function SettingsScreen() { 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), - ]); + 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); @@ -143,6 +267,9 @@ export default function SettingsScreen() { 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), @@ -161,117 +288,6 @@ export default function SettingsScreen() { setTimeout(() => setSaved(false), 2000); } - function ToggleRow({ - label, - value, - onValueChange, - }: { - label: string; - value: boolean; - onValueChange: (v: boolean) => void; - }) { - return ( - - {label} - - - ); - } - - function SegmentRow({ - label, - options, - value, - onChange, - }: { - label: string; - options: { key: string; label: string }[]; - value: string; - onChange: (v: string) => void; - }) { - return ( - - {label} - - {options.map((opt) => ( - onChange(opt.key)} - style={{ - flex: 1, - backgroundColor: - value === opt.key ? Colors.primary : Colors.surface, - borderRadius: 8, - padding: 12, - alignItems: "center", - borderWidth: 1, - borderColor: - value === opt.key ? Colors.primary : Colors.border, - }} - > - - {opt.label} - - - ))} - - - ); - } - - function Field({ - label, - value, - onChange, - placeholder, - secure, - url, - }: { - label: string; - value: string; - onChange: (v: string) => void; - placeholder?: string; - secure?: boolean; - url?: boolean; - }) { - return ( - <> - {label} - - - ); - } - return ( - AI (optional) + + + + 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 + + + + AI — Root Cause Assistant + + + 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). + + {aiEnabled && ( - + <> + + + + )} Git Repository diff --git a/lib/git.ts b/lib/git.ts index 24af9b5..e9177af 100644 --- a/lib/git.ts +++ b/lib/git.ts @@ -17,6 +17,16 @@ export interface PushResult { 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, @@ -37,43 +47,61 @@ async function pushGitea( 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 apiBase = config.url.replace(/\/$/, ""); const endpoint = `${apiBase}/api/v1/repos/${config.owner}/${config.repo}/contents/${filepath}`; try { - const existing = await fetch(endpoint, { - headers: { Authorization: `Bearer ${config.token}` }, + 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 body: Record = { + const payload: Record = { message: `incident: ${incident.title}`, content: base64Content, }; - if (sha) body.sha = sha; + if (sha) payload.sha = sha; - const res = await fetch(endpoint, { - method: "POST", + // POST creates, PUT updates — Gitea requires the correct verb + const res = await fetchWithTimeout(endpoint, { + method: sha ? "PUT" : "POST", headers: { - Authorization: `Bearer ${config.token}`, + Authorization: `token ${config.token}`, "Content-Type": "application/json", }, - body: JSON.stringify(body), + body: JSON.stringify(payload), }); 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 } }; 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) }; } } @@ -83,13 +111,20 @@ async function pushGitHub( 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 fetch(endpoint, { + const existing = await fetchWithTimeout(endpoint, { headers: { Authorization: `Bearer ${config.token}`, Accept: "application/vnd.github+json", @@ -99,30 +134,37 @@ async function pushGitHub( 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 body: Record = { + const payload: Record = { message: `incident: ${incident.title}`, content: base64Content, }; - if (sha) body.sha = sha; + if (sha) payload.sha = sha; - const res = await fetch(endpoint, { + const res = await fetchWithTimeout(endpoint, { method: "PUT", headers: { Authorization: `Bearer ${config.token}`, Accept: "application/vnd.github+json", "Content-Type": "application/json", }, - body: JSON.stringify(body), + body: JSON.stringify(payload), }); 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 } }; 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) }; } } @@ -132,6 +174,13 @@ async function pushGitLab( 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))); @@ -141,12 +190,12 @@ async function pushGitLab( const endpoint = `${apiBase}/api/v4/projects/${projectId}/repository/files/${encodedPath}`; try { - const existing = await fetch(`${endpoint}?ref=HEAD`, { + const existing = await fetchWithTimeout(`${endpoint}?ref=HEAD`, { headers: { "PRIVATE-TOKEN": config.token }, }); const method = existing.ok ? "PUT" : "POST"; - const res = await fetch(endpoint, { + const res = await fetchWithTimeout(endpoint, { method, headers: { "PRIVATE-TOKEN": config.token, @@ -161,7 +210,8 @@ async function pushGitLab( }); 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 webUrl = data.file_path @@ -169,6 +219,9 @@ async function pushGitLab( : 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) }; } }