6 Commits

Author SHA1 Message Date
billisdead 9f163d3b76 feat: safe area fix, delete from repo, add Mistral provider
Release APK / build (push) Has been cancelled
- Wrap root layout with SafeAreaProvider, use useSafeAreaInsets in all
  screens so FAB/action bar/scroll content clear Android nav bar
- Add deleteIncidentFromGit() (Gitea/GitHub/GitLab) — detail screen now
  offers Cancel / Local only / Local + Repo when incident was pushed
- Add Mistral as AI provider (OpenAI-compat, api.mistral.ai/v1,
  mistral-small-latest default)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-24 09:54:02 +02:00
billisdead 83fe089ea5 ci: remove android-actions/setup-android, use preinstalled SDK
Release APK / build (push) Has been cancelled
The action consistently fails trying to re-download cmdline-tools
from Google CDN (corrupt zip). The ubuntu-latest runner already ships
a working Android SDK at $ANDROID_HOME — call sdkmanager directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 18:18:50 +02:00
billisdead 947ae5917c feat: git push status indicator + multi-select bulk push
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>
2026-06-23 18:11:23 +02:00
billisdead 1437e99cbb fix: title increment based on DB max, not stored template only
Release APK / build (push) Has been cancelled
After each incident save, query existing titles with the same prefix
to find the real max number, then use max(dbMax, tplNum) + 1.
Prevents duplicate counters when incidents were created before the
template was configured.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 15:26:10 +02:00
billisdead a74d27085b feat: multi-provider AI assistant, repo URL simplification
Release APK / build (push) Has been cancelled
- lib/ai.ts: unified AI client supporting Anthropic (native format) and
  OpenAI-compatible providers (OpenAI, Perplexity, Ollama, OpenRouter, custom)
- settings: 6-provider chip selector, base URL auto-filled on preset select,
  Anthropic URL hidden (fixed), model/key placeholders per provider
- settings: git repo config replaced by single Repo URL field (.git paste)
  auto-parsed to instance URL + owner + repo at save time
- new.tsx: Suggest button between Symptom and Root Cause, visible when AI
  enabled and title+symptom filled; pre-fills rootCause and fix inline

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 14:32:44 +02:00
billisdead fe1fdb9f9c fix: Gitea push 404 — add branch:main payload, handle 409 empty repo, clearer settings
- lib/git.ts: add branch:"main" to Gitea POST payload (required when repo has no
  branch yet or is freshly initialized); treat 409 on GET same as 404 (empty repo
  has no tree, Gitea returns 409 Conflict — proceed to create first file)
- lib/git.ts: improve 404 error hint with actionable checklist (URL format,
  owner/repo names, token scope, repo existence)
- settings.tsx: add Gitea info card clarifying URL format (no /api/v1), token
  format (raw value only, no "Bearer" prefix), repo initialization requirement

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 14:12:09 +02:00
10 changed files with 888 additions and 272 deletions
+3 -5
View File
@@ -42,15 +42,13 @@ jobs:
- name: Install dependencies - name: Install dependencies
run: npm ci run: npm ci
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- name: Accept Android SDK licenses - name: Accept Android SDK licenses
run: yes | sdkmanager --licenses || true run: yes | "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" --licenses || true
- name: Install SDK components - name: Install SDK components
run: | run: |
sdkmanager "platform-tools" "platforms;android-35" "build-tools;35.0.0" "$ANDROID_HOME/cmdline-tools/latest/bin/sdkmanager" \
"platform-tools" "platforms;android-35" "build-tools;35.0.0"
- name: Cache Gradle - name: Cache Gradle
uses: actions/cache@v4 uses: actions/cache@v4
+3
View File
@@ -3,6 +3,7 @@ import { Pressable, Text } from "react-native";
import { Stack, router } from "expo-router"; import { Stack, router } from "expo-router";
import { StatusBar } from "expo-status-bar"; import { StatusBar } from "expo-status-bar";
import { GestureHandlerRootView } from "react-native-gesture-handler"; import { GestureHandlerRootView } from "react-native-gesture-handler";
import { SafeAreaProvider } from "react-native-safe-area-context";
import "../global.css"; import "../global.css";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
@@ -18,6 +19,7 @@ export default function RootLayout() {
}, [ready, settings.onboardingDone]); }, [ready, settings.onboardingDone]);
return ( return (
<SafeAreaProvider>
<GestureHandlerRootView style={{ flex: 1, backgroundColor: Colors.bg }}> <GestureHandlerRootView style={{ flex: 1, backgroundColor: Colors.bg }}>
<StatusBar style="light" /> <StatusBar style="light" />
<Stack <Stack
@@ -49,5 +51,6 @@ export default function RootLayout() {
<Stack.Screen name="onboarding" options={{ headerShown: false }} /> <Stack.Screen name="onboarding" options={{ headerShown: false }} />
</Stack> </Stack>
</GestureHandlerRootView> </GestureHandlerRootView>
</SafeAreaProvider>
); );
} }
+52 -56
View File
@@ -8,12 +8,13 @@ import {
Share, Share,
} from "react-native"; } from "react-native";
import { useLocalSearchParams, router, useNavigation } from "expo-router"; import { useLocalSearchParams, router, useNavigation } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
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, deleteIncident } from "@/lib/db"; import { getIncidentById, updateIncident, deleteIncident, markIncidentPushed } from "@/lib/db";
import { renderMarkdown } from "@/lib/markdown"; import { renderMarkdown } from "@/lib/markdown";
import { pushIncidentToGit } from "@/lib/git"; import { pushIncidentToGit, loadGitConfig, deleteIncidentFromGit } from "@/lib/git";
import type { GitProvider } from "@/lib/git"; import type { GitConfig } from "@/lib/git";
import type { Incident } from "@/types/incident"; import type { Incident } from "@/types/incident";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
@@ -42,11 +43,14 @@ export default function IncidentDetailScreen() {
const navigation = useNavigation(); const navigation = useNavigation();
const [incident, setIncident] = useState<Incident | null>(null); const [incident, setIncident] = useState<Incident | null>(null);
const [pushing, setPushing] = useState(false); const [pushing, setPushing] = useState(false);
const [gitConfig, setGitConfig] = useState<GitConfig | null>(null);
const insets = useSafeAreaInsets();
useEffect(() => { useEffect(() => {
if (id) { if (id) {
getIncidentById(id).then((inc) => { Promise.all([getIncidentById(id), loadGitConfig()]).then(([inc, config]) => {
setIncident(inc); setIncident(inc);
setGitConfig(config);
if (inc) { if (inc) {
navigation.setOptions({ title: inc.title || "Incident" }); navigation.setOptions({ title: inc.title || "Incident" });
} }
@@ -56,6 +60,39 @@ export default function IncidentDetailScreen() {
async function handleDelete() { async function handleDelete() {
if (!incident) return; if (!incident) return;
const canDeleteFromRepo = !!incident.gitPushedAt && gitConfig !== null;
const doDelete = async (deleteFromRepo: boolean) => {
if (deleteFromRepo && gitConfig) {
const result = await deleteIncidentFromGit(incident, gitConfig);
if (!result.success) {
Alert.alert("Repo deletion failed", result.error ?? "Unknown error");
return;
}
}
await deleteIncident(incident.id);
router.replace("/");
};
if (canDeleteFromRepo) {
Alert.alert(
"Delete incident?",
"This incident was pushed to the repository. Do you also want to delete the file from the repo?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Local only",
onPress: () => doDelete(false),
},
{
text: "Local + Repo",
style: "destructive",
onPress: () => doDelete(true),
},
]
);
} else {
Alert.alert( Alert.alert(
"Delete incident?", "Delete incident?",
"This action cannot be undone.", "This action cannot be undone.",
@@ -64,14 +101,12 @@ export default function IncidentDetailScreen() {
{ {
text: "Delete", text: "Delete",
style: "destructive", style: "destructive",
onPress: async () => { onPress: () => doDelete(false),
await deleteIncident(incident.id);
router.replace("/");
},
}, },
] ]
); );
} }
}
async function handleResolve() { async function handleResolve() {
if (!incident) return; if (!incident) return;
@@ -81,60 +116,21 @@ export default function IncidentDetailScreen() {
async function handleGitPush() { async function handleGitPush() {
if (!incident) return; if (!incident) return;
const [provider, template, giteaUrl, giteaToken, giteaOwner, giteaRepo, githubToken, githubOwner, githubRepo, gitlabUrl, gitlabToken, gitlabOwner, gitlabRepo] = const [config, template] = await Promise.all([
await Promise.all([ loadGitConfig(),
SecureStore.getItemAsync("git_provider"),
SecureStore.getItemAsync("md_template"), SecureStore.getItemAsync("md_template"),
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"),
]); ]);
if (!config) {
const p = (provider ?? "gitea") as GitProvider; Alert.alert("Git not configured", "Set up a git provider in Settings first.");
let url: string | undefined;
let token: string | null;
let owner: string | null;
let repo: string | null;
if (p === "gitea") {
url = giteaUrl ?? undefined;
token = giteaToken;
owner = giteaOwner;
repo = giteaRepo;
} else if (p === "github") {
token = githubToken;
owner = githubOwner;
repo = githubRepo;
} else {
url = gitlabUrl ?? undefined;
token = gitlabToken;
owner = gitlabOwner;
repo = gitlabRepo;
}
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; return;
} }
setPushing(true); setPushing(true);
const result = await pushIncidentToGit( const result = await pushIncidentToGit(incident, config, template ?? undefined);
incident,
{ provider: p, url, token, owner, repo },
template ?? undefined
);
setPushing(false); setPushing(false);
if (result.success) { if (result.success) {
const now = new Date().toISOString();
await markIncidentPushed(incident.id);
setIncident((prev) => prev ? { ...prev, gitPushedAt: now } : prev);
Alert.alert("Pushed", result.url ?? "Push successful."); Alert.alert("Pushed", result.url ?? "Push successful.");
} else { } else {
Alert.alert("Push failed", result.error ?? "Unknown error"); Alert.alert("Push failed", result.error ?? "Unknown error");
@@ -165,7 +161,7 @@ export default function IncidentDetailScreen() {
return ( return (
<ScrollView <ScrollView
style={{ flex: 1, backgroundColor: Colors.bg }} style={{ flex: 1, backgroundColor: Colors.bg }}
contentContainerStyle={{ padding: 16, paddingBottom: 32 }} contentContainerStyle={{ padding: 16, paddingBottom: 32 + insets.bottom }}
> >
{/* Status badge */} {/* Status badge */}
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 4 }}> <View style={{ flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 4 }}>
+260 -22
View File
@@ -5,16 +5,33 @@ import {
FlatList, FlatList,
Pressable, Pressable,
ActivityIndicator, ActivityIndicator,
Alert,
} from "react-native"; } from "react-native";
import { router, useFocusEffect } from "expo-router"; import { router, useFocusEffect } from "expo-router";
import { getIncidents } from "@/lib/db"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as SecureStore from "expo-secure-store";
import { getIncidents, markIncidentPushed } from "@/lib/db";
import { pushIncidentToGit, loadGitConfig } from "@/lib/git";
import type { GitConfig } from "@/lib/git";
import { useSettings } from "@/hooks/useSettings"; import { useSettings } from "@/hooks/useSettings";
import type { Incident } from "@/types/incident"; import type { Incident } from "@/types/incident";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
function gitDotColor(inc: Incident): string {
if (!inc.gitPushedAt) return Colors.danger;
if (inc.gitPushedAt < inc.updatedAt) return Colors.warning;
return Colors.success;
}
export default function HomeScreen() { export default function HomeScreen() {
const [incidents, setIncidents] = useState<Incident[]>([]); const [incidents, setIncidents] = useState<Incident[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [gitConfig, setGitConfig] = useState<GitConfig | null>(null);
const [mdTemplate, setMdTemplate] = useState<string | undefined>(undefined);
const [selectionMode, setSelectionMode] = useState(false);
const [selected, setSelected] = useState<Set<string>>(new Set());
const [bulkPushing, setBulkPushing] = useState(false);
const [bulkProgress, setBulkProgress] = useState({ done: 0, total: 0 });
const { settings, ready } = useSettings(); const { settings, ready } = useSettings();
const didRedirect = useRef(false); const didRedirect = useRef(false);
@@ -28,29 +45,105 @@ export default function HomeScreen() {
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
loadIncidents(); load();
}, []) }, [])
); );
async function loadIncidents() { async function load() {
setLoading(true); setLoading(true);
const data = await getIncidents(10); const [data, config, tpl] = await Promise.all([
getIncidents(100),
loadGitConfig(),
SecureStore.getItemAsync("md_template"),
]);
setIncidents(data); setIncidents(data);
setGitConfig(config);
setMdTemplate(tpl ?? undefined);
setLoading(false); setLoading(false);
} }
function enterSelectionMode(id: string) {
setSelectionMode(true);
setSelected(new Set([id]));
}
function toggleSelection(id: string) {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
function exitSelectionMode() {
setSelectionMode(false);
setSelected(new Set());
}
function selectAllUnpushed() {
const ids = incidents
.filter((i) => !i.gitPushedAt || i.gitPushedAt < i.updatedAt)
.map((i) => i.id);
setSelected(new Set(ids));
}
const unpushedCount = incidents.filter(
(i) => !i.gitPushedAt || i.gitPushedAt < i.updatedAt
).length;
async function handleBulkPush() {
if (!gitConfig) {
Alert.alert("Git not configured", "Set up a git provider in Settings first.");
return;
}
const toPush = incidents.filter((i) => selected.has(i.id));
if (toPush.length === 0) return;
setBulkPushing(true);
setBulkProgress({ done: 0, total: toPush.length });
let ok = 0;
let fail = 0;
const now = new Date().toISOString();
for (let i = 0; i < toPush.length; i++) {
const inc = toPush[i];
const result = await pushIncidentToGit(inc, gitConfig, mdTemplate);
if (result.success) {
await markIncidentPushed(inc.id);
setIncidents((prev) =>
prev.map((x) => (x.id === inc.id ? { ...x, gitPushedAt: now } : x))
);
ok++;
} else {
fail++;
}
setBulkProgress({ done: i + 1, total: toPush.length });
}
setBulkPushing(false);
exitSelectionMode();
if (fail === 0) {
Alert.alert("Done", `${ok} incident${ok > 1 ? "s" : ""} pushed.`);
} else {
Alert.alert("Partial", `${ok} pushed, ${fail} failed.`);
}
}
const gitConfigured = gitConfig !== null;
const insets = useSafeAreaInsets();
return ( return (
<View style={{ flex: 1, backgroundColor: Colors.bg }}> <View style={{ flex: 1, backgroundColor: Colors.bg }}>
{loading ? ( {loading ? (
<ActivityIndicator <ActivityIndicator color={Colors.primary} style={{ marginTop: 48 }} />
color={Colors.primary}
style={{ marginTop: 48 }}
/>
) : ( ) : (
<FlatList <FlatList
data={incidents} data={incidents}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 16, paddingBottom: 96 }} contentContainerStyle={{ padding: 16, paddingBottom: 96 + insets.bottom }}
ListEmptyComponent={ ListEmptyComponent={
<Text <Text
style={{ style={{
@@ -63,19 +156,29 @@ export default function HomeScreen() {
No incidents. Press + to log one. No incidents. Press + to log one.
</Text> </Text>
} }
renderItem={({ item }) => ( renderItem={({ item }) => {
const isSelected = selected.has(item.id);
return (
<Pressable <Pressable
onPress={() => router.push(`/incident/${item.id}`)} onPress={() =>
selectionMode
? toggleSelection(item.id)
: router.push(`/incident/${item.id}`)
}
onLongPress={() => !selectionMode && enterSelectionMode(item.id)}
style={{ style={{
backgroundColor: Colors.surface, backgroundColor: isSelected ? Colors.surface2 : Colors.surface,
borderRadius: 8, borderRadius: 8,
padding: 16, padding: 16,
marginBottom: 10, marginBottom: 10,
borderLeftWidth: 3, borderLeftWidth: 3,
borderLeftColor: borderLeftColor:
item.status === "open" ? Colors.warning : Colors.success, item.status === "open" ? Colors.warning : Colors.success,
flexDirection: "row",
alignItems: "center",
}} }}
> >
<View style={{ flex: 1 }}>
<View <View
style={{ style={{
flexDirection: "row", flexDirection: "row",
@@ -95,16 +198,24 @@ export default function HomeScreen() {
> >
{item.title || "Untitled"} {item.title || "Untitled"}
</Text> </Text>
<View style={{ flexDirection: "row", alignItems: "center", marginLeft: 8, gap: 6 }}>
{gitConfigured && !selectionMode && (
<View
style={{
width: 8,
height: 8,
borderRadius: 4,
backgroundColor: gitDotColor(item),
}}
/>
)}
<View <View
style={{ style={{
backgroundColor: backgroundColor:
item.status === "open" item.status === "open" ? "#f59e0b20" : "#88D65620",
? "#f59e0b20"
: "#88D65620",
borderRadius: 4, borderRadius: 4,
paddingHorizontal: 8, paddingHorizontal: 8,
paddingVertical: 2, paddingVertical: 2,
marginLeft: 8,
}} }}
> >
<Text <Text
@@ -122,23 +233,51 @@ export default function HomeScreen() {
</Text> </Text>
</View> </View>
</View> </View>
</View>
<Text <Text
style={{ color: Colors.text2, fontSize: 12 }} style={{ color: Colors.text2, fontSize: 12 }}
numberOfLines={1} numberOfLines={1}
> >
{item.service || "—"} · {item.createdAt.slice(0, 10)} {item.service || "—"} · {item.createdAt.slice(0, 10)}
</Text> </Text>
</Pressable> </View>
{selectionMode && (
<View
style={{
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 2,
borderColor: isSelected ? Colors.primary : Colors.border,
backgroundColor: isSelected ? Colors.primary : "transparent",
alignItems: "center",
justifyContent: "center",
marginLeft: 14,
}}
>
{isSelected && (
<Text
style={{ color: Colors.bg, fontSize: 12, fontWeight: "700" }}
>
</Text>
)} )}
</View>
)}
</Pressable>
);
}}
/> />
)} )}
{/* FAB */} {/* FAB — hidden in selection mode */}
{!selectionMode && (
<Pressable <Pressable
onPress={() => router.push("/new")} onPress={() => router.push("/new")}
style={{ style={{
position: "absolute", position: "absolute",
bottom: 24, bottom: 24 + insets.bottom,
right: 24, right: 24,
width: 56, width: 56,
height: 56, height: 56,
@@ -149,12 +288,111 @@ export default function HomeScreen() {
elevation: 6, elevation: 6,
}} }}
> >
<Text <Text style={{ color: "#fff", fontSize: 28, lineHeight: 32 }}>+</Text>
style={{ color: "#fff", fontSize: 28, lineHeight: 32 }} </Pressable>
)}
{/* Selection mode action bar */}
{selectionMode && (
<View
style={{
position: "absolute",
bottom: 0,
left: 0,
right: 0,
backgroundColor: Colors.surface,
borderTopWidth: 1,
borderTopColor: Colors.border,
padding: 12,
paddingBottom: 12 + insets.bottom,
gap: 10,
}}
> >
+ {/* Quick-select shortcuts */}
<View style={{ flexDirection: "row", gap: 8 }}>
{gitConfigured && unpushedCount > 0 && (
<Pressable
onPress={selectAllUnpushed}
style={{
flex: 1,
borderRadius: 6,
borderWidth: 1,
borderColor: Colors.border,
padding: 8,
alignItems: "center",
}}
>
<Text style={{ color: Colors.text2, fontSize: 12, fontWeight: "600" }}>
Select unpushed ({unpushedCount})
</Text>
</Pressable>
)}
<Pressable
onPress={() => setSelected(new Set(incidents.map((i) => i.id)))}
style={{
flex: 1,
borderRadius: 6,
borderWidth: 1,
borderColor: Colors.border,
padding: 8,
alignItems: "center",
}}
>
<Text style={{ color: Colors.text2, fontSize: 12, fontWeight: "600" }}>
Select all ({incidents.length})
</Text> </Text>
</Pressable> </Pressable>
</View> </View>
{/* Main actions */}
<View style={{ flexDirection: "row", gap: 10 }}>
<Pressable
onPress={exitSelectionMode}
style={{
flex: 1,
borderRadius: 8,
borderWidth: 1,
borderColor: Colors.border,
padding: 14,
alignItems: "center",
}}
>
<Text style={{ color: Colors.text2, fontSize: 15, fontWeight: "600" }}>
Cancel
</Text>
</Pressable>
<Pressable
onPress={handleBulkPush}
disabled={selected.size === 0 || bulkPushing || !gitConfigured}
style={{
flex: 2,
borderRadius: 8,
backgroundColor:
selected.size === 0 || !gitConfigured
? Colors.surface2
: Colors.primary,
padding: 14,
alignItems: "center",
}}
>
<Text
style={{
color:
selected.size === 0 || !gitConfigured
? Colors.textDim
: Colors.bg,
fontSize: 15,
fontWeight: "700",
}}
>
{bulkPushing
? `Pushing ${bulkProgress.done}/${bulkProgress.total}`
: `Push ${selected.size > 0 ? selected.size : ""} selected`}
</Text>
</Pressable>
</View>
</View>
)}
</View>
); );
} }
+59 -9
View File
@@ -10,15 +10,18 @@ import {
Alert, Alert,
} from "react-native"; } from "react-native";
import { router, useLocalSearchParams, useNavigation } from "expo-router"; import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as SecureStore from "expo-secure-store"; import * as SecureStore from "expo-secure-store";
import { import {
createIncident, createIncident,
updateIncident, updateIncident,
getIncidentById, getIncidentById,
getDistinctServices, getDistinctServices,
getMaxTitleNumber,
} from "@/lib/db"; } from "@/lib/db";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
import { useVoice } from "@/hooks/useVoice"; import { useVoice } from "@/hooks/useVoice";
import { callAI } from "@/lib/ai";
import type { IncidentDraft } from "@/types/incident"; import type { IncidentDraft } from "@/types/incident";
const FIELD_STYLE = { const FIELD_STYLE = {
@@ -49,17 +52,11 @@ 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();
const insets = useSafeAreaInsets();
const [form, setForm] = useState<IncidentDraft>({ const [form, setForm] = useState<IncidentDraft>({
title: "", title: "",
@@ -72,6 +69,9 @@ export default function NewIncidentScreen() {
}); });
const [tagInput, setTagInput] = useState(""); const [tagInput, setTagInput] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [suggesting, setSuggesting] = useState(false);
const [suggestError, setSuggestError] = useState("");
const [aiEnabled, setAiEnabled] = useState(false);
const [knownServices, setKnownServices] = useState<string[]>([]); const [knownServices, setKnownServices] = useState<string[]>([]);
const [serviceFocused, setServiceFocused] = useState(false); const [serviceFocused, setServiceFocused] = useState(false);
@@ -81,6 +81,7 @@ export default function NewIncidentScreen() {
useEffect(() => { useEffect(() => {
getDistinctServices().then(setKnownServices); getDistinctServices().then(setKnownServices);
SecureStore.getItemAsync("pref_ai_enabled").then((v) => setAiEnabled(v === "true"));
}, []); }, []);
useEffect(() => { useEffect(() => {
@@ -149,6 +150,19 @@ export default function NewIncidentScreen() {
setForm((f) => ({ ...f, tags: f.tags.filter((t) => t !== tag) })); setForm((f) => ({ ...f, tags: f.tags.filter((t) => t !== tag) }));
} }
async function handleSuggest() {
setSuggesting(true);
setSuggestError("");
try {
const result = await callAI(form.title, form.symptom);
setForm((f) => ({ ...f, rootCause: result.rootCause, fix: result.fix }));
} catch (e) {
setSuggestError(e instanceof Error ? e.message : "AI call failed");
} finally {
setSuggesting(false);
}
}
async function handleSave() { async function handleSave() {
if (!form.title.trim()) { if (!form.title.trim()) {
Alert.alert("Required", "Title is required."); Alert.alert("Required", "Title is required.");
@@ -162,7 +176,14 @@ export default function NewIncidentScreen() {
await createIncident(form); await createIncident(form);
const tpl = await SecureStore.getItemAsync("pref_title_template"); const tpl = await SecureStore.getItemAsync("pref_title_template");
if (tpl) { if (tpl) {
await SecureStore.setItemAsync("pref_title_template", incrementTemplate(tpl)); const match = tpl.match(/^([\s\S]*?)(\d+)$/);
if (match) {
const [, prefix, numStr] = match;
const dbMax = await getMaxTitleNumber(prefix);
const tplNum = parseInt(numStr, 10);
const next = (Math.max(dbMax, tplNum) + 1).toString().padStart(numStr.length, "0");
await SecureStore.setItemAsync("pref_title_template", prefix + next);
}
} }
} }
router.back(); router.back();
@@ -231,7 +252,7 @@ export default function NewIncidentScreen() {
behavior={Platform.OS === "ios" ? "padding" : undefined} behavior={Platform.OS === "ios" ? "padding" : undefined}
> >
<ScrollView <ScrollView
contentContainerStyle={{ padding: 16, paddingBottom: 32 }} contentContainerStyle={{ padding: 16, paddingBottom: 32 + insets.bottom }}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
{labelRow("Title *", "title")} {labelRow("Title *", "title")}
@@ -298,6 +319,35 @@ export default function NewIncidentScreen() {
returnKeyType="next" returnKeyType="next"
/> />
{aiEnabled && form.title.trim() && form.symptom.trim() && (
<View style={{ marginBottom: 16 }}>
<Pressable
onPress={handleSuggest}
disabled={suggesting}
style={{
backgroundColor: suggesting ? Colors.surface2 : Colors.surface,
borderRadius: 8,
padding: 12,
alignItems: "center",
borderWidth: 1,
borderColor: Colors.primary,
flexDirection: "row",
justifyContent: "center",
gap: 8,
}}
>
<Text style={{ color: Colors.primary, fontSize: 14, fontWeight: "700" }}>
{suggesting ? "Analyzing…" : "✦ Suggest root cause & fix"}
</Text>
</Pressable>
{suggestError !== "" && (
<Text style={{ color: Colors.danger, fontSize: 12, marginTop: 6 }}>
{suggestError}
</Text>
)}
</View>
)}
{labelRow("Root Cause", "rootCause")} {labelRow("Root Cause", "rootCause")}
<TextInput <TextInput
style={MONO_FIELD_STYLE} style={MONO_FIELD_STYLE}
+115 -96
View File
@@ -7,15 +7,19 @@ import {
Switch, Switch,
Pressable, Pressable,
} from "react-native"; } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as SecureStore from "expo-secure-store"; import * as SecureStore from "expo-secure-store";
import { Colors } from "@/constants/theme"; import { Colors } from "@/constants/theme";
import { DEFAULT_TEMPLATE } from "@/lib/markdown"; import { DEFAULT_TEMPLATE } from "@/lib/markdown";
import type { GitProvider } from "@/lib/git"; import type { GitProvider } from "@/lib/git";
import type { AIProvider } from "@/lib/ai";
import { AI_PROVIDER_BASE_URLS, AI_PROVIDER_MODEL_PLACEHOLDERS } from "@/lib/ai";
const KEYS = { const KEYS = {
INPUT_MODE: "pref_input_mode", INPUT_MODE: "pref_input_mode",
HOME_VIEW: "pref_home_view", HOME_VIEW: "pref_home_view",
AI_ENABLED: "pref_ai_enabled", AI_ENABLED: "pref_ai_enabled",
AI_PROVIDER: "pref_ai_provider",
AI_KEY: "pref_ai_key", AI_KEY: "pref_ai_key",
AI_BASE_URL: "pref_ai_base_url", AI_BASE_URL: "pref_ai_base_url",
AI_MODEL: "pref_ai_model", AI_MODEL: "pref_ai_model",
@@ -70,6 +74,28 @@ const PROVIDER_LABELS: Record<GitProvider, string> = {
gitlab: "GitLab", gitlab: "GitLab",
}; };
function parseRepoUrl(raw: string, provider: GitProvider): { instanceUrl?: string; owner?: string; repo?: string } {
try {
const cleaned = raw.trim().replace(/\.git$/, "");
const u = new URL(cleaned);
const parts = u.pathname.split("/").filter(Boolean);
if (parts.length < 2) return {};
const repo = parts[parts.length - 1];
const owner = parts[parts.length - 2];
const instanceUrl = provider !== "github" ? `${u.protocol}//${u.host}` : undefined;
return { instanceUrl, owner, repo };
} catch {
return {};
}
}
function buildRepoUrl(instanceUrl: string | null, owner: string | null, repo: string | null, provider: GitProvider): string {
if (!owner || !repo) return "";
const base = provider === "github" ? "https://github.com" : (instanceUrl ?? "");
if (!base) return "";
return `${base}/${owner}/${repo}.git`;
}
// Defined outside SettingsScreen — prevents remount on every rerender, which // Defined outside SettingsScreen — prevents remount on every rerender, which
// would cause TextInput to lose focus after each keystroke. // would cause TextInput to lose focus after each keystroke.
@@ -110,22 +136,24 @@ function SegmentRow({
options, options,
value, value,
onChange, onChange,
wrap,
}: { }: {
label: string; label: string;
options: { key: string; label: string }[]; options: { key: string; label: string }[];
value: string; value: string;
onChange: (v: string) => void; onChange: (v: string) => void;
wrap?: boolean;
}) { }) {
return ( return (
<View style={{ marginBottom: 16 }}> <View style={{ marginBottom: 16 }}>
<Text style={LABEL}>{label}</Text> <Text style={LABEL}>{label}</Text>
<View style={{ flexDirection: "row", gap: 8 }}> <View style={{ flexDirection: "row", gap: 8, flexWrap: wrap ? "wrap" : "nowrap" }}>
{options.map((opt) => ( {options.map((opt) => (
<Pressable <Pressable
key={opt.key} key={opt.key}
onPress={() => onChange(opt.key)} onPress={() => onChange(opt.key)}
style={{ style={{
flex: 1, ...(wrap ? { width: "31%" } : { flex: 1 }),
backgroundColor: backgroundColor:
value === opt.key ? Colors.primary : Colors.surface, value === opt.key ? Colors.primary : Colors.surface,
borderRadius: 8, borderRadius: 8,
@@ -186,9 +214,11 @@ function Field({
} }
export default function SettingsScreen() { export default function SettingsScreen() {
const insets = useSafeAreaInsets();
const [inputMode, setInputMode] = useState<"voice" | "form">("form"); const [inputMode, setInputMode] = useState<"voice" | "form">("form");
const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard"); const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard");
const [aiEnabled, setAiEnabled] = useState(false); const [aiEnabled, setAiEnabled] = useState(false);
const [aiProvider, setAiProvider] = useState<AIProvider>("openai");
const [aiKey, setAiKey] = useState(""); const [aiKey, setAiKey] = useState("");
const [aiBaseUrl, setAiBaseUrl] = useState(""); const [aiBaseUrl, setAiBaseUrl] = useState("");
const [aiModel, setAiModel] = useState(""); const [aiModel, setAiModel] = useState("");
@@ -197,22 +227,17 @@ export default function SettingsScreen() {
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [gitProvider, setGitProvider] = useState<GitProvider>("gitea"); const [gitProvider, setGitProvider] = useState<GitProvider>("gitea");
const [giteaUrl, setGiteaUrl] = useState(""); const [giteaRepoUrl, setGiteaRepoUrl] = useState("");
const [giteaToken, setGiteaToken] = useState(""); const [giteaToken, setGiteaToken] = useState("");
const [giteaOwner, setGiteaOwner] = useState(""); const [githubRepoUrl, setGithubRepoUrl] = useState("");
const [giteaRepo, setGiteaRepo] = useState("");
const [githubToken, setGithubToken] = useState(""); const [githubToken, setGithubToken] = useState("");
const [githubOwner, setGithubOwner] = useState(""); const [gitlabRepoUrl, setGitlabRepoUrl] = useState("");
const [githubRepo, setGithubRepo] = useState("");
const [gitlabUrl, setGitlabUrl] = useState("");
const [gitlabToken, setGitlabToken] = useState(""); const [gitlabToken, setGitlabToken] = useState("");
const [gitlabOwner, setGitlabOwner] = useState("");
const [gitlabRepo, setGitlabRepo] = useState("");
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const [ const [
im, hv, ai, key, aiUrl, aiMdl, titleTpl, tpl, im, hv, ai, aip, key, aiUrl, aiMdl, titleTpl, tpl,
gp, gu, gt, go, gr, gp, gu, gt, go, gr,
ghu, ghown, ghrepo, ghu, ghown, ghrepo,
glu, glt, glo, glr, glu, glt, glo, glr,
@@ -220,6 +245,7 @@ export default function SettingsScreen() {
SecureStore.getItemAsync(KEYS.INPUT_MODE), SecureStore.getItemAsync(KEYS.INPUT_MODE),
SecureStore.getItemAsync(KEYS.HOME_VIEW), SecureStore.getItemAsync(KEYS.HOME_VIEW),
SecureStore.getItemAsync(KEYS.AI_ENABLED), SecureStore.getItemAsync(KEYS.AI_ENABLED),
SecureStore.getItemAsync(KEYS.AI_PROVIDER),
SecureStore.getItemAsync(KEYS.AI_KEY), SecureStore.getItemAsync(KEYS.AI_KEY),
SecureStore.getItemAsync(KEYS.AI_BASE_URL), SecureStore.getItemAsync(KEYS.AI_BASE_URL),
SecureStore.getItemAsync(KEYS.AI_MODEL), SecureStore.getItemAsync(KEYS.AI_MODEL),
@@ -241,48 +267,48 @@ export default function SettingsScreen() {
if (im) setInputMode(im as "voice" | "form"); if (im) setInputMode(im as "voice" | "form");
if (hv) setHomeView(hv as "dashboard" | "capture"); if (hv) setHomeView(hv as "dashboard" | "capture");
if (ai) setAiEnabled(ai === "true"); if (ai) setAiEnabled(ai === "true");
if (aip) setAiProvider(aip as AIProvider);
if (key) setAiKey(key); if (key) setAiKey(key);
if (aiUrl) setAiBaseUrl(aiUrl); if (aiUrl) setAiBaseUrl(aiUrl);
if (aiMdl) setAiModel(aiMdl); if (aiMdl) setAiModel(aiMdl);
if (titleTpl) setTitleTemplate(titleTpl); if (titleTpl) setTitleTemplate(titleTpl);
if (tpl) setMdTemplate(tpl); if (tpl) setMdTemplate(tpl);
if (gp) setGitProvider(gp as GitProvider); if (gp) setGitProvider(gp as GitProvider);
if (gu) setGiteaUrl(gu);
if (gt) setGiteaToken(gt); if (gt) setGiteaToken(gt);
if (go) setGiteaOwner(go); setGiteaRepoUrl(buildRepoUrl(gu, go, gr, "gitea"));
if (gr) setGiteaRepo(gr);
if (ghu) setGithubToken(ghu); if (ghu) setGithubToken(ghu);
if (ghown) setGithubOwner(ghown); setGithubRepoUrl(buildRepoUrl(null, ghown, ghrepo, "github"));
if (ghrepo) setGithubRepo(ghrepo);
if (glu) setGitlabUrl(glu);
if (glt) setGitlabToken(glt); if (glt) setGitlabToken(glt);
if (glo) setGitlabOwner(glo); setGitlabRepoUrl(buildRepoUrl(glu, glo, glr, "gitlab"));
if (glr) setGitlabRepo(glr);
})(); })();
}, []); }, []);
async function handleSave() { async function handleSave() {
const gitea = parseRepoUrl(giteaRepoUrl, "gitea");
const github = parseRepoUrl(githubRepoUrl, "github");
const gitlab = parseRepoUrl(gitlabRepoUrl, "gitlab");
await Promise.all([ await Promise.all([
SecureStore.setItemAsync(KEYS.INPUT_MODE, inputMode), SecureStore.setItemAsync(KEYS.INPUT_MODE, inputMode),
SecureStore.setItemAsync(KEYS.HOME_VIEW, homeView), SecureStore.setItemAsync(KEYS.HOME_VIEW, homeView),
SecureStore.setItemAsync(KEYS.AI_ENABLED, String(aiEnabled)), SecureStore.setItemAsync(KEYS.AI_ENABLED, String(aiEnabled)),
SecureStore.setItemAsync(KEYS.AI_PROVIDER, aiProvider),
SecureStore.setItemAsync(KEYS.AI_KEY, aiKey), SecureStore.setItemAsync(KEYS.AI_KEY, aiKey),
SecureStore.setItemAsync(KEYS.AI_BASE_URL, aiBaseUrl), SecureStore.setItemAsync(KEYS.AI_BASE_URL, aiBaseUrl),
SecureStore.setItemAsync(KEYS.AI_MODEL, aiModel), SecureStore.setItemAsync(KEYS.AI_MODEL, aiModel),
SecureStore.setItemAsync(KEYS.TITLE_TEMPLATE, titleTemplate), SecureStore.setItemAsync(KEYS.TITLE_TEMPLATE, titleTemplate),
SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate), SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate),
SecureStore.setItemAsync(KEYS.GIT_PROVIDER, gitProvider), SecureStore.setItemAsync(KEYS.GIT_PROVIDER, gitProvider),
SecureStore.setItemAsync(KEYS.GITEA_URL, giteaUrl), SecureStore.setItemAsync(KEYS.GITEA_URL, gitea.instanceUrl ?? ""),
SecureStore.setItemAsync(KEYS.GITEA_TOKEN, giteaToken), SecureStore.setItemAsync(KEYS.GITEA_TOKEN, giteaToken),
SecureStore.setItemAsync(KEYS.GITEA_OWNER, giteaOwner), SecureStore.setItemAsync(KEYS.GITEA_OWNER, gitea.owner ?? ""),
SecureStore.setItemAsync(KEYS.GITEA_REPO, giteaRepo), SecureStore.setItemAsync(KEYS.GITEA_REPO, gitea.repo ?? ""),
SecureStore.setItemAsync(KEYS.GITHUB_TOKEN, githubToken), SecureStore.setItemAsync(KEYS.GITHUB_TOKEN, githubToken),
SecureStore.setItemAsync(KEYS.GITHUB_OWNER, githubOwner), SecureStore.setItemAsync(KEYS.GITHUB_OWNER, github.owner ?? ""),
SecureStore.setItemAsync(KEYS.GITHUB_REPO, githubRepo), SecureStore.setItemAsync(KEYS.GITHUB_REPO, github.repo ?? ""),
SecureStore.setItemAsync(KEYS.GITLAB_URL, gitlabUrl), SecureStore.setItemAsync(KEYS.GITLAB_URL, gitlab.instanceUrl ?? ""),
SecureStore.setItemAsync(KEYS.GITLAB_TOKEN, gitlabToken), SecureStore.setItemAsync(KEYS.GITLAB_TOKEN, gitlabToken),
SecureStore.setItemAsync(KEYS.GITLAB_OWNER, gitlabOwner), SecureStore.setItemAsync(KEYS.GITLAB_OWNER, gitlab.owner ?? ""),
SecureStore.setItemAsync(KEYS.GITLAB_REPO, gitlabRepo), SecureStore.setItemAsync(KEYS.GITLAB_REPO, gitlab.repo ?? ""),
]); ]);
setSaved(true); setSaved(true);
setTimeout(() => setSaved(false), 2000); setTimeout(() => setSaved(false), 2000);
@@ -291,7 +317,7 @@ export default function SettingsScreen() {
return ( return (
<ScrollView <ScrollView
style={{ flex: 1, backgroundColor: Colors.bg }} style={{ flex: 1, backgroundColor: Colors.bg }}
contentContainerStyle={{ padding: 16, paddingBottom: 48 }} contentContainerStyle={{ padding: 16, paddingBottom: 48 + insets.bottom }}
keyboardShouldPersistTaps="handled" keyboardShouldPersistTaps="handled"
> >
<Text style={SECTION_TITLE}>Preferences</Text> <Text style={SECTION_TITLE}>Preferences</Text>
@@ -352,10 +378,10 @@ export default function SettingsScreen() {
}} }}
> >
<Text style={{ color: Colors.text2, fontSize: 13, lineHeight: 20 }}> <Text style={{ color: Colors.text2, fontSize: 13, lineHeight: 20 }}>
When enabled, a button appears on the capture form to suggest a root When enabled, a Suggest button appears between Symptom and Root Cause.
cause and fix based on the incident title and symptom. Uses any It sends the title and symptom to your AI provider and pre-fills the
OpenAI-compatible API set the base URL below to point at a local analysis fields. Supports Anthropic natively; all others use the
Ollama instance, or leave empty for OpenAI (api.openai.com). OpenAI-compatible API.
</Text> </Text>
</View> </View>
<ToggleRow <ToggleRow
@@ -365,24 +391,45 @@ export default function SettingsScreen() {
/> />
{aiEnabled && ( {aiEnabled && (
<> <>
<SegmentRow
label="Provider"
value={aiProvider}
wrap
onChange={(v) => {
const p = v as AIProvider;
setAiProvider(p);
setAiBaseUrl(AI_PROVIDER_BASE_URLS[p]);
}}
options={[
{ key: "anthropic", label: "Anthropic" },
{ key: "openai", label: "OpenAI" },
{ key: "mistral", label: "Mistral" },
{ key: "perplexity", label: "Perplexity" },
{ key: "ollama", label: "Ollama" },
{ key: "openrouter", label: "OpenRouter" },
{ key: "custom", label: "Custom" },
]}
/>
{aiProvider !== "anthropic" && (
<Field <Field
label="API Base URL" label="Base URL"
value={aiBaseUrl} value={aiBaseUrl}
onChange={setAiBaseUrl} onChange={setAiBaseUrl}
placeholder="http://192.168.x.x:11434/v1 (empty = OpenAI)" placeholder={AI_PROVIDER_BASE_URLS[aiProvider] || "https://your-api.example.com/v1"}
url url
/> />
)}
<Field <Field
label="Model" label="Model"
value={aiModel} value={aiModel}
onChange={setAiModel} onChange={setAiModel}
placeholder="gpt-4o-mini or llama3.2" placeholder={AI_PROVIDER_MODEL_PLACEHOLDERS[aiProvider]}
/> />
<Field <Field
label="API Key" label="API Key"
value={aiKey} value={aiKey}
onChange={setAiKey} onChange={setAiKey}
placeholder="sk-... (leave empty for Ollama)" placeholder={aiProvider === "ollama" ? "no key required" : "paste your API key"}
secure secure
/> />
</> </>
@@ -403,50 +450,46 @@ export default function SettingsScreen() {
{gitProvider === "gitea" && ( {gitProvider === "gitea" && (
<> <>
<Field <Field
label="Instance URL" label="Repo URL"
value={giteaUrl} value={giteaRepoUrl}
onChange={setGiteaUrl} onChange={setGiteaRepoUrl}
placeholder="https://homegit.example.com" placeholder="https://homegit.example.com/username/incidents.git"
url url
/> />
<Field <Field
label="Token" label="Token (PAT, write:repository)"
value={giteaToken} value={giteaToken}
onChange={setGiteaToken} onChange={setGiteaToken}
placeholder="Bearer token" placeholder="paste token value — no Bearer prefix"
secure secure
/> />
<Field <View
label="Owner" style={{
value={giteaOwner} backgroundColor: Colors.surface,
onChange={setGiteaOwner} borderRadius: 8,
placeholder="username or org" padding: 10,
/> marginTop: -8,
<Field marginBottom: 14,
label="Repository" borderLeftWidth: 3,
value={giteaRepo} borderLeftColor: Colors.text2,
onChange={setGiteaRepo} }}
placeholder="incidents" >
/> <Text style={{ color: Colors.text2, fontSize: 12, lineHeight: 18 }}>
The repo must have at least one commit initialize it with a README if empty.
</Text>
</View>
</> </>
)} )}
{gitProvider === "github" && ( {gitProvider === "github" && (
<> <>
<View <Field
style={{ label="Repo URL"
backgroundColor: Colors.surface, value={githubRepoUrl}
borderRadius: 8, onChange={setGithubRepoUrl}
padding: 12, placeholder="https://github.com/username/incidents.git"
marginBottom: 14, url
borderLeftWidth: 3, />
borderLeftColor: Colors.primary,
}}
>
<Text style={{ color: Colors.text2, fontSize: 13 }}>
Uses api.github.com no instance URL required.
</Text>
</View>
<Field <Field
label="Personal Access Token" label="Personal Access Token"
value={githubToken} value={githubToken}
@@ -454,28 +497,16 @@ export default function SettingsScreen() {
placeholder="ghp_..." placeholder="ghp_..."
secure secure
/> />
<Field
label="Owner (username or org)"
value={githubOwner}
onChange={setGithubOwner}
placeholder="your-username"
/>
<Field
label="Repository"
value={githubRepo}
onChange={setGithubRepo}
placeholder="incidents"
/>
</> </>
)} )}
{gitProvider === "gitlab" && ( {gitProvider === "gitlab" && (
<> <>
<Field <Field
label="Instance URL" label="Repo URL"
value={gitlabUrl} value={gitlabRepoUrl}
onChange={setGitlabUrl} onChange={setGitlabRepoUrl}
placeholder="https://gitlab.com" placeholder="https://gitlab.com/username/incidents.git"
url url
/> />
<Field <Field
@@ -485,18 +516,6 @@ export default function SettingsScreen() {
placeholder="glpat-..." placeholder="glpat-..."
secure secure
/> />
<Field
label="Namespace (user or group)"
value={gitlabOwner}
onChange={setGitlabOwner}
placeholder="your-username"
/>
<Field
label="Repository"
value={gitlabRepo}
onChange={setGitlabRepo}
placeholder="incidents"
/>
</> </>
)} )}
+131
View File
@@ -0,0 +1,131 @@
import * as SecureStore from "expo-secure-store";
export type AIProvider =
| "anthropic"
| "openai"
| "mistral"
| "perplexity"
| "ollama"
| "openrouter"
| "custom";
const KEYS = {
AI_PROVIDER: "pref_ai_provider",
AI_BASE_URL: "pref_ai_base_url",
AI_MODEL: "pref_ai_model",
AI_KEY: "pref_ai_key",
};
export const AI_PROVIDER_BASE_URLS: Record<AIProvider, string> = {
anthropic: "https://api.anthropic.com",
openai: "https://api.openai.com/v1",
mistral: "https://api.mistral.ai/v1",
perplexity: "https://api.perplexity.ai",
ollama: "http://localhost:11434/v1",
openrouter: "https://openrouter.ai/api/v1",
custom: "",
};
export const AI_PROVIDER_MODEL_PLACEHOLDERS: Record<AIProvider, string> = {
anthropic: "claude-haiku-4-5-20251001",
openai: "gpt-4o-mini",
mistral: "mistral-small-latest",
perplexity: "llama-3.1-sonar-small-128k-online",
ollama: "llama3.2",
openrouter: "openai/gpt-4o-mini",
custom: "model-name",
};
const SYSTEM_PROMPT =
"You are a systems reliability engineer. Analyze the incident and respond with JSON ONLY " +
"(no prose, no markdown fences):\n" +
'{"root_cause":"...","fix":"..."}\n' +
"Be concise and technical. Max 200 chars per field.";
async function getConfig() {
const [provider, baseUrl, model, key] = await Promise.all([
SecureStore.getItemAsync(KEYS.AI_PROVIDER),
SecureStore.getItemAsync(KEYS.AI_BASE_URL),
SecureStore.getItemAsync(KEYS.AI_MODEL),
SecureStore.getItemAsync(KEYS.AI_KEY),
]);
const p = (provider ?? "openai") as AIProvider;
return {
provider: p,
baseUrl: baseUrl || AI_PROVIDER_BASE_URLS[p],
model: model || AI_PROVIDER_MODEL_PLACEHOLDERS[p],
key: key ?? "",
};
}
export async function callAI(
title: string,
symptom: string
): Promise<{ rootCause: string; fix: string }> {
const config = await getConfig();
const userMsg = `Title: ${title}\nSymptom: ${symptom}`;
const raw =
config.provider === "anthropic"
? await callAnthropic(config, userMsg)
: await callOpenAICompat(config, userMsg);
try {
const parsed = JSON.parse(raw) as { root_cause?: string; fix?: string };
return {
rootCause: parsed.root_cause ?? "",
fix: parsed.fix ?? "",
};
} catch {
throw new Error(`Unexpected AI response: ${raw.slice(0, 120)}`);
}
}
async function callAnthropic(
config: { baseUrl: string; model: string; key: string },
userMsg: string
): Promise<string> {
const res = await fetch(`${config.baseUrl}/v1/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": config.key,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: config.model,
max_tokens: 512,
system: SYSTEM_PROMPT,
messages: [{ role: "user", content: userMsg }],
}),
});
if (!res.ok) throw new Error(`Anthropic ${res.status}: ${await res.text()}`);
const data = (await res.json()) as { content: { text: string }[] };
return data.content[0].text;
}
async function callOpenAICompat(
config: { baseUrl: string; model: string; key: string },
userMsg: string
): Promise<string> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (config.key) headers["Authorization"] = `Bearer ${config.key}`;
const res = await fetch(`${config.baseUrl}/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({
model: config.model,
max_tokens: 512,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userMsg },
],
}),
});
if (!res.ok) throw new Error(`AI ${res.status}: ${await res.text()}`);
const data = (await res.json()) as {
choices: { message: { content: string } }[];
};
return data.choices[0].message.content;
}
+32
View File
@@ -45,6 +45,13 @@ async function migrate(db: SQLite.SQLiteDatabase): Promise<void> {
INSERT INTO schema_version (version) VALUES (1); INSERT INTO schema_version (version) VALUES (1);
`); `);
} }
if (current < 2) {
await db.execAsync(`
ALTER TABLE incidents ADD COLUMN git_pushed_at TEXT;
INSERT INTO schema_version (version) VALUES (2);
`);
}
} }
// --- row mapper --- // --- row mapper ---
@@ -60,6 +67,7 @@ interface IncidentRow {
created_at: string; created_at: string;
updated_at: string; updated_at: string;
tags: string; tags: string;
git_pushed_at: string | null;
} }
function rowToIncident(row: IncidentRow): Incident { function rowToIncident(row: IncidentRow): Incident {
@@ -74,6 +82,7 @@ function rowToIncident(row: IncidentRow): Incident {
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
tags: JSON.parse(row.tags) as string[], tags: JSON.parse(row.tags) as string[],
gitPushedAt: row.git_pushed_at ?? null,
}; };
} }
@@ -107,6 +116,7 @@ export async function createIncident(draft: IncidentDraft): Promise<Incident> {
id, id,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
gitPushedAt: null,
}; };
} }
@@ -163,6 +173,28 @@ export async function deleteIncident(id: string): Promise<void> {
await db.runAsync("DELETE FROM incidents WHERE id = ?", [id]); await db.runAsync("DELETE FROM incidents WHERE id = ?", [id]);
} }
export async function markIncidentPushed(id: string): Promise<void> {
const db = await getDb();
await db.runAsync(
"UPDATE incidents SET git_pushed_at = ? WHERE id = ?",
[new Date().toISOString(), id]
);
}
export async function getMaxTitleNumber(prefix: string): Promise<number> {
const db = await getDb();
const rows = await db.getAllAsync<{ title: string }>(
"SELECT title FROM incidents WHERE title LIKE ?",
[`${prefix}%`]
);
let max = 0;
for (const row of rows) {
const n = parseInt(row.title.slice(prefix.length), 10);
if (!isNaN(n) && n > max) max = n;
}
return max;
}
export async function getDistinctServices(): Promise<string[]> { export async function getDistinctServices(): Promise<string[]> {
const db = await getDb(); const db = await getDb();
const rows = await db.getAllAsync<{ service: string }>( const rows = await db.getAllAsync<{ service: string }>(
+150 -2
View File
@@ -1,3 +1,4 @@
import * as SecureStore from "expo-secure-store";
import { incidentToFilename, renderMarkdown } from "./markdown"; import { incidentToFilename, renderMarkdown } from "./markdown";
import type { Incident } from "@/types/incident"; import type { Incident } from "@/types/incident";
@@ -17,8 +18,51 @@ export interface PushResult {
error?: string; error?: string;
} }
export interface DeleteResult {
success: boolean;
error?: string;
}
const TIMEOUT_MS = 15_000; 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> { function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
const controller = new AbortController(); const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS); const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
@@ -27,6 +71,20 @@ function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
); );
} }
export async function deleteIncidentFromGit(
incident: Incident,
config: GitConfig
): Promise<DeleteResult> {
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( export async function pushIncidentToGit(
incident: Incident, incident: Incident,
config: GitConfig, config: GitConfig,
@@ -71,7 +129,10 @@ async function pushGitea(
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) { } 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(); const body = await existing.text();
return { success: false, error: `GET ${existing.status}: ${body.slice(0, 200)}` }; return { success: false, error: `GET ${existing.status}: ${body.slice(0, 200)}` };
} }
@@ -79,6 +140,7 @@ async function pushGitea(
const payload: Record<string, string> = { const payload: Record<string, string> = {
message: `incident: ${incident.title}`, message: `incident: ${incident.title}`,
content: base64Content, content: base64Content,
branch: "main",
}; };
if (sha) payload.sha = sha; if (sha) payload.sha = sha;
@@ -94,7 +156,11 @@ async function pushGitea(
if (!res.ok) { if (!res.ok) {
const body = await res.text(); const body = await res.text();
return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 300)}` }; 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 } }; 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 };
@@ -169,6 +235,88 @@ async function pushGitHub(
} }
} }
async function deleteGitea(incident: Incident, config: GitConfig): Promise<DeleteResult> {
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<DeleteResult> {
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<DeleteResult> {
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( async function pushGitLab(
incident: Incident, incident: Incident,
config: GitConfig, config: GitConfig,
+2 -1
View File
@@ -11,6 +11,7 @@ export interface Incident {
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
tags: string[]; tags: string[];
gitPushedAt: string | null;
} }
export type IncidentDraft = Omit<Incident, "id" | "createdAt" | "updatedAt">; export type IncidentDraft = Omit<Incident, "id" | "createdAt" | "updatedAt" | "gitPushedAt">;