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) };
}
}