13 Commits

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 13:34:41 +02:00
billisdead fee283b5d6 feat: apply Charbon·Lime design system, add gear icon, multi-provider git settings
Release APK / build (push) Has been cancelled
- Replace app icon with new Charbon·Lime brand assets (icon.png + adaptive-icon.png)
- Apply design tokens to theme.ts: bg #1B2433, primary/success #88D656 (lime), text1 #EEF2F5 (cream)
- Add ⚙ gear button (lime) in HomeScreen header → navigates to /settings
- Expand settings: git provider selector (Gitea / GitHub / GitLab) with per-provider fields
- Add lib/git.ts: unified pushIncidentToGit() for Gitea, GitHub (PUT), and GitLab APIs
- Update incident detail screen to use lib/git.ts and dynamic provider routing

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 10:00:49 +02:00
billisdead 67c132d88b fix: add mic to service field, refresh list on screen focus
Release APK / build (push) Has been cancelled
new.tsx: service field was missing its mic button — VoiceField type
excluded "service" and the label used a plain Text instead of labelRow().

index.tsx: incident list only loaded on mount; router.back() does not
remount the screen so new incidents never appeared. useFocusEffect
replaces the one-shot useEffect so the list reloads on every focus.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 18:51:43 +02:00
billisdead f1a4b58234 refactor: remove crash reporter from _layout.tsx
Release APK / build (push) Has been cancelled
CrashBoundary and ErrorUtils debug handler were temporary scaffolding
to identify the launch crash (react version mismatch). Removing now
that the root cause is fixed.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 18:50:15 +02:00
billisdead dca76ea394 fix: add npm overrides to align react + react-dom to 19.2.3
Release APK / build (push) Has been cancelled
react-dom@19.2.7 has a hard peer dep on react@^19.2.7, breaking
npm ci when react is pinned to 19.2.3. npm overrides force both
packages to 19.2.3 so the lock file resolves cleanly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 18:20:49 +02:00
billisdead af8ec98bb4 fix: pin react to 19.2.3 to match react-native-renderer
Release APK / build (push) Has been cancelled
react-native@0.85.3 bundles react-native-renderer@19.2.3. Having
react@19.2.7 in package.json caused a version mismatch error at
runtime ("Incompatible React versions") that killed the process
before any JS could render, appearing as a native crash.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 18:18:03 +02:00
billisdead c43b913dde fix: exclude reanimated/worklets native build, disable New Architecture
Release APK / build (push) Has been cancelled
react-native.config.js excludes react-native-reanimated and
react-native-worklets from Android native linking — neither is used in
the app and react-native-gesture-handler has a dedicated noreanimated
codepath for exactly this case.

build-apk.sh step 4.6 forces newArchEnabled=false in gradle.properties
after expo prebuild, as a fallback for New Architecture init issues
on Android 15.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 15:16:37 +02:00
billisdead 256b338ae3 fix: patch worklets and reanimated for Android 15 16KB page size support
Release APK / build (push) Has been cancelled
react-native-worklets and react-native-reanimated compile .so files
without -Wl,-z,max-page-size=16384, causing a native crash at startup
on Android 15 devices that use 16KB memory pages (Pixel 9+, Galaxy S25+).
Patch applied in build-apk.sh and CI workflow after npm install, before
the Gradle build. Guard prevents double-patching on repeated runs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-22 14:23:38 +02:00
18 changed files with 1504 additions and 496 deletions
+14 -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
@@ -89,6 +87,17 @@ jobs:
[ -n "$KEYSTORE_STORE_PASSWORD" ] && echo "KEYSTORE_STORE_PASSWORD: set" || echo "KEYSTORE_STORE_PASSWORD: EMPTY" [ -n "$KEYSTORE_STORE_PASSWORD" ] && echo "KEYSTORE_STORE_PASSWORD: set" || echo "KEYSTORE_STORE_PASSWORD: EMPTY"
[ -n "$KEYSTORE_KEY_PASSWORD" ] && echo "KEYSTORE_KEY_PASSWORD: set" || echo "KEYSTORE_KEY_PASSWORD: EMPTY" [ -n "$KEYSTORE_KEY_PASSWORD" ] && echo "KEYSTORE_KEY_PASSWORD: set" || echo "KEYSTORE_KEY_PASSWORD: EMPTY"
- name: Patch native modules for Android 15 16KB page size
run: |
WORKLETS="node_modules/react-native-worklets/android/CMakeLists.txt"
REANIMATED="node_modules/react-native-reanimated/android/CMakeLists.txt"
if [ -f "$WORKLETS" ] && ! grep -q "max-page-size" "$WORKLETS"; then
printf '\ntarget_link_options(worklets PRIVATE "-Wl,-z,max-page-size=16384")\n' >> "$WORKLETS"
fi
if [ -f "$REANIMATED" ] && ! grep -q "max-page-size" "$REANIMATED"; then
printf '\ntarget_link_options(reanimated PRIVATE "-Wl,-z,max-page-size=16384")\n' >> "$REANIMATED"
fi
- name: Build signed APK - name: Build signed APK
run: ./build-apk.sh run: ./build-apk.sh
+3 -2
View File
@@ -5,12 +5,13 @@
"version": "0.1.0", "version": "0.1.0",
"orientation": "portrait", "orientation": "portrait",
"userInterfaceStyle": "dark", "userInterfaceStyle": "dark",
"backgroundColor": "#0f1117", "icon": "./assets/icon.png",
"backgroundColor": "#1B2433",
"scheme": "sheethappens", "scheme": "sheethappens",
"android": { "android": {
"adaptiveIcon": { "adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png", "foregroundImage": "./assets/adaptive-icon.png",
"backgroundColor": "#0f1117" "backgroundColor": "#1B2433"
}, },
"package": "fr.gyozamancave.sheethappens", "package": "fr.gyozamancave.sheethappens",
"permissions": [ "permissions": [
+32 -77
View File
@@ -1,55 +1,14 @@
import { useEffect, useState, Component } from "react"; import { useEffect } from "react";
import type { ReactNode } from "react"; 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 { View, Text, ScrollView } from "react-native";
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";
// Catch JS errors in release mode and show them on-screen instead of
// silently crashing to desktop. Remove once the crash is identified.
class CrashBoundary extends Component<
{ children: ReactNode },
{ error: Error | null }
> {
state = { error: null };
static getDerivedStateFromError(e: Error) {
return { error: e };
}
render() {
const { error } = this.state;
if (error) {
return (
<View style={{ flex: 1, backgroundColor: "#0f1117", padding: 20, paddingTop: 60 }}>
<Text style={{ color: "#ef4444", fontSize: 16, fontWeight: "700", marginBottom: 12 }}>
CRASH copie ce texte
</Text>
<ScrollView>
<Text style={{ color: "#fff", fontSize: 11, fontFamily: "monospace" }}>
{String(error)}{"\n\n"}{(error as any).stack}
</Text>
</ScrollView>
</View>
);
}
return this.props.children;
}
}
export default function RootLayout() { export default function RootLayout() {
const { settings, ready } = useSettings(); const { settings, ready } = useSettings();
const [jsError, setJsError] = useState<string | null>(null);
useEffect(() => {
const prev = (ErrorUtils as any).getGlobalHandler();
(ErrorUtils as any).setGlobalHandler((e: Error, isFatal: boolean) => {
if (isFatal) setJsError(`${String(e)}\n\n${(e as any).stack ?? ""}`);
prev?.(e, isFatal);
});
return () => (ErrorUtils as any).setGlobalHandler(prev);
}, []);
useEffect(() => { useEffect(() => {
if (!ready) return; if (!ready) return;
@@ -58,41 +17,37 @@ export default function RootLayout() {
} }
}, [ready, settings.onboardingDone]); }, [ready, settings.onboardingDone]);
if (jsError) {
return (
<View style={{ flex: 1, backgroundColor: "#0f1117", padding: 20, paddingTop: 60 }}>
<Text style={{ color: "#ef4444", fontSize: 16, fontWeight: "700", marginBottom: 12 }}>
FATAL JS ERROR copie ce texte
</Text>
<ScrollView>
<Text style={{ color: "#fff", fontSize: 11, fontFamily: "monospace" }}>
{jsError}
</Text>
</ScrollView>
</View>
);
}
return ( return (
<CrashBoundary> <GestureHandlerRootView style={{ flex: 1, backgroundColor: Colors.bg }}>
<GestureHandlerRootView style={{ flex: 1, backgroundColor: Colors.bg }}> <StatusBar style="light" />
<StatusBar style="light" /> <Stack
<Stack screenOptions={{
screenOptions={{ headerStyle: { backgroundColor: Colors.surface },
headerStyle: { backgroundColor: Colors.surface }, headerTintColor: Colors.text1,
headerTintColor: Colors.text1, headerTitleStyle: { color: Colors.text1 },
headerTitleStyle: { color: Colors.text1 }, contentStyle: { backgroundColor: Colors.bg },
contentStyle: { backgroundColor: Colors.bg }, animation: "slide_from_right",
animation: "slide_from_right", }}
>
<Stack.Screen
name="index"
options={{
title: "SheetHappens",
headerRight: () => (
<Pressable
onPress={() => router.push("/settings")}
style={{ paddingLeft: 12, paddingRight: 4, paddingVertical: 6 }}
>
<Text style={{ color: Colors.primary, fontSize: 22, lineHeight: 26 }}></Text>
</Pressable>
),
}} }}
> />
<Stack.Screen name="index" options={{ title: "SheetHappens" }} /> <Stack.Screen name="new" options={{ title: "New Incident", presentation: "modal" }} />
<Stack.Screen name="new" options={{ title: "New Incident", presentation: "modal" }} /> <Stack.Screen name="incident/[id]" options={{ title: "Incident" }} />
<Stack.Screen name="incident/[id]" options={{ title: "Incident" }} /> <Stack.Screen name="settings" options={{ title: "Settings" }} />
<Stack.Screen name="settings" options={{ title: "Settings" }} /> <Stack.Screen name="onboarding" options={{ headerShown: false }} />
<Stack.Screen name="onboarding" options={{ headerShown: false }} /> </Stack>
</Stack> </GestureHandlerRootView>
</GestureHandlerRootView>
</CrashBoundary>
); );
} }
+52 -20
View File
@@ -10,9 +10,9 @@ import {
import { useLocalSearchParams, router, useNavigation } from "expo-router"; import { useLocalSearchParams, router, useNavigation } from "expo-router";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import * as SecureStore from "expo-secure-store"; import * as SecureStore from "expo-secure-store";
import { getIncidentById, updateIncident } from "@/lib/db"; import { getIncidentById, updateIncident, deleteIncident, markIncidentPushed } from "@/lib/db";
import { renderMarkdown } from "@/lib/markdown"; import { renderMarkdown } from "@/lib/markdown";
import { pushIncidentToGitea } from "@/lib/gitea"; import { pushIncidentToGit, loadGitConfig } 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";
@@ -53,34 +53,49 @@ export default function IncidentDetailScreen() {
} }
}, [id]); }, [id]);
async function handleDelete() {
if (!incident) return;
Alert.alert(
"Delete incident?",
"This action cannot be undone.",
[
{ text: "Cancel", style: "cancel" },
{
text: "Delete",
style: "destructive",
onPress: async () => {
await deleteIncident(incident.id);
router.replace("/");
},
},
]
);
}
async function handleResolve() { async function handleResolve() {
if (!incident) return; if (!incident) return;
await updateIncident(incident.id, { status: "resolved" }); await updateIncident(incident.id, { status: "resolved" });
setIncident({ ...incident, status: "resolved" }); setIncident({ ...incident, status: "resolved" });
} }
async function handleGiteaPush() { async function handleGitPush() {
if (!incident) return; if (!incident) return;
const [url, token, owner, repo, template] = await Promise.all([ const [config, template] = await Promise.all([
SecureStore.getItemAsync("gitea_url"), loadGitConfig(),
SecureStore.getItemAsync("gitea_token"),
SecureStore.getItemAsync("gitea_owner"),
SecureStore.getItemAsync("gitea_repo"),
SecureStore.getItemAsync("md_template"), SecureStore.getItemAsync("md_template"),
]); ]);
if (!url || !token || !owner || !repo) { if (!config) {
Alert.alert("Gitea not configured", "Set up Gitea in Settings first."); Alert.alert("Git not configured", "Set up a git provider in Settings first.");
return; return;
} }
setPushing(true); setPushing(true);
const result = await pushIncidentToGitea( const result = await pushIncidentToGit(incident, config, template ?? undefined);
incident,
{ url, token, owner, repo },
template ?? undefined
);
setPushing(false); setPushing(false);
if (result.success) { if (result.success) {
Alert.alert("Pushed", result.url ? `${result.url}` : "Push successful."); const now = new Date().toISOString();
await markIncidentPushed(incident.id);
setIncident((prev) => prev ? { ...prev, gitPushedAt: now } : prev);
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");
} }
@@ -117,7 +132,7 @@ export default function IncidentDetailScreen() {
<View <View
style={{ style={{
backgroundColor: backgroundColor:
incident.status === "open" ? "#f59e0b20" : "#22c55e20", incident.status === "open" ? "#f59e0b20" : "#88D65620",
borderRadius: 6, borderRadius: 6,
paddingHorizontal: 10, paddingHorizontal: 10,
paddingVertical: 4, paddingVertical: 4,
@@ -188,7 +203,7 @@ export default function IncidentDetailScreen() {
<Pressable <Pressable
onPress={handleResolve} onPress={handleResolve}
style={{ style={{
backgroundColor: "#22c55e20", backgroundColor: "#88D65620",
borderWidth: 1, borderWidth: 1,
borderColor: Colors.success, borderColor: Colors.success,
borderRadius: 10, borderRadius: 10,
@@ -219,7 +234,7 @@ export default function IncidentDetailScreen() {
</Pressable> </Pressable>
<Pressable <Pressable
onPress={handleGiteaPush} onPress={handleGitPush}
disabled={pushing} disabled={pushing}
style={{ style={{
backgroundColor: pushing ? Colors.surface2 : Colors.surface, backgroundColor: pushing ? Colors.surface2 : Colors.surface,
@@ -237,7 +252,7 @@ export default function IncidentDetailScreen() {
fontSize: 15, fontSize: 15,
}} }}
> >
{pushing ? "Pushing…" : "Push to Gitea"} {pushing ? "Pushing…" : "Push to Git"}
</Text> </Text>
</Pressable> </Pressable>
@@ -256,6 +271,23 @@ export default function IncidentDetailScreen() {
Edit Edit
</Text> </Text>
</Pressable> </Pressable>
<Pressable
onPress={handleDelete}
style={{
backgroundColor: "#ef444410",
borderWidth: 1,
borderColor: "#ef4444",
borderRadius: 10,
padding: 14,
alignItems: "center",
marginTop: 8,
}}
>
<Text style={{ color: "#ef4444", fontWeight: "600", fontSize: 15 }}>
Delete Incident
</Text>
</Pressable>
</View> </View>
</ScrollView> </ScrollView>
); );
+327 -90
View File
@@ -1,20 +1,36 @@
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { import {
View, View,
Text, Text,
FlatList, FlatList,
Pressable, Pressable,
ActivityIndicator, ActivityIndicator,
Alert,
} from "react-native"; } from "react-native";
import { router } from "expo-router"; import { router, useFocusEffect } from "expo-router";
import { getIncidents } from "@/lib/db"; 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);
@@ -26,24 +42,101 @@ export default function HomeScreen() {
} }
}, [ready, settings.homeView]); }, [ready, settings.homeView]);
useEffect(() => { useFocusEffect(
loadIncidents(); useCallback(() => {
}, []); 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;
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}
@@ -61,98 +154,242 @@ export default function HomeScreen() {
No incidents. Press + to log one. No incidents. Press + to log one.
</Text> </Text>
} }
renderItem={({ item }) => ( renderItem={({ item }) => {
<Pressable const isSelected = selected.has(item.id);
onPress={() => router.push(`/incident/${item.id}`)} return (
style={{ <Pressable
backgroundColor: Colors.surface, onPress={() =>
borderRadius: 8, selectionMode
padding: 16, ? toggleSelection(item.id)
marginBottom: 10, : router.push(`/incident/${item.id}`)
borderLeftWidth: 3, }
borderLeftColor: onLongPress={() => !selectionMode && enterSelectionMode(item.id)}
item.status === "open" ? Colors.warning : Colors.success,
}}
>
<View
style={{ style={{
backgroundColor: isSelected ? Colors.surface2 : Colors.surface,
borderRadius: 8,
padding: 16,
marginBottom: 10,
borderLeftWidth: 3,
borderLeftColor:
item.status === "open" ? Colors.warning : Colors.success,
flexDirection: "row", flexDirection: "row",
justifyContent: "space-between",
alignItems: "center", alignItems: "center",
marginBottom: 4,
}} }}
> >
<Text <View style={{ flex: 1 }}>
style={{ <View
color: Colors.text1,
fontSize: 15,
fontWeight: "600",
flex: 1,
}}
numberOfLines={1}
>
{item.title || "Untitled"}
</Text>
<View
style={{
backgroundColor:
item.status === "open"
? "#f59e0b20"
: "#22c55e20",
borderRadius: 4,
paddingHorizontal: 8,
paddingVertical: 2,
marginLeft: 8,
}}
>
<Text
style={{ style={{
color: flexDirection: "row",
item.status === "open" justifyContent: "space-between",
? Colors.warning alignItems: "center",
: Colors.success, marginBottom: 4,
fontSize: 11,
fontWeight: "700",
textTransform: "uppercase",
}} }}
> >
{item.status} <Text
style={{
color: Colors.text1,
fontSize: 15,
fontWeight: "600",
flex: 1,
}}
numberOfLines={1}
>
{item.title || "Untitled"}
</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
style={{
backgroundColor:
item.status === "open" ? "#f59e0b20" : "#88D65620",
borderRadius: 4,
paddingHorizontal: 8,
paddingVertical: 2,
}}
>
<Text
style={{
color:
item.status === "open"
? Colors.warning
: Colors.success,
fontSize: 11,
fontWeight: "700",
textTransform: "uppercase",
}}
>
{item.status}
</Text>
</View>
</View>
</View>
<Text
style={{ color: Colors.text2, fontSize: 12 }}
numberOfLines={1}
>
{item.service || "—"} · {item.createdAt.slice(0, 10)}
</Text> </Text>
</View> </View>
</View>
<Text {selectionMode && (
style={{ color: Colors.text2, fontSize: 12 }} <View
numberOfLines={1} style={{
> width: 22,
{item.service || "—"} · {item.createdAt.slice(0, 10)} height: 22,
</Text> borderRadius: 11,
</Pressable> 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 */}
<Pressable {!selectionMode && (
onPress={() => router.push("/new")} <Pressable
style={{ onPress={() => router.push("/new")}
position: "absolute", style={{
bottom: 24, position: "absolute",
right: 24, bottom: 24,
width: 56, right: 24,
height: 56, width: 56,
borderRadius: 28, height: 56,
backgroundColor: Colors.primary, borderRadius: 28,
alignItems: "center", backgroundColor: Colors.primary,
justifyContent: "center", alignItems: "center",
elevation: 6, justifyContent: "center",
}} elevation: 6,
> }}
<Text
style={{ color: "#fff", fontSize: 28, lineHeight: 32 }}
> >
+ <Text style={{ color: "#fff", fontSize: 28, lineHeight: 32 }}>+</Text>
</Text> </Pressable>
</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,
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>
</Pressable>
</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> </View>
); );
} }
+70 -2
View File
@@ -10,14 +10,17 @@ import {
Alert, Alert,
} from "react-native"; } from "react-native";
import { router, useLocalSearchParams, useNavigation } from "expo-router"; import { router, useLocalSearchParams, useNavigation } from "expo-router";
import * as SecureStore from "expo-secure-store";
import { import {
createIncident, createIncident,
updateIncident, updateIncident,
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 = {
@@ -46,7 +49,8 @@ const LABEL_STYLE = {
letterSpacing: 0.5, letterSpacing: 0.5,
} as const; } as const;
type VoiceField = "title" | "symptom" | "rootCause" | "fix"; type VoiceField = "title" | "service" | "symptom" | "rootCause" | "fix";
export default function NewIncidentScreen() { export default function NewIncidentScreen() {
const { editId } = useLocalSearchParams<{ editId?: string }>(); const { editId } = useLocalSearchParams<{ editId?: string }>();
@@ -63,6 +67,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);
@@ -72,8 +79,16 @@ export default function NewIncidentScreen() {
useEffect(() => { useEffect(() => {
getDistinctServices().then(setKnownServices); getDistinctServices().then(setKnownServices);
SecureStore.getItemAsync("pref_ai_enabled").then((v) => setAiEnabled(v === "true"));
}, []); }, []);
useEffect(() => {
if (editId) return;
SecureStore.getItemAsync("pref_title_template").then((tpl) => {
if (tpl) setForm((f) => ({ ...f, title: tpl }));
});
}, [editId]);
useEffect(() => { useEffect(() => {
if (!editId) return; if (!editId) return;
navigation.setOptions({ title: "Edit Incident" }); navigation.setOptions({ title: "Edit Incident" });
@@ -133,6 +148,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.");
@@ -144,6 +172,17 @@ export default function NewIncidentScreen() {
await updateIncident(editId, form); await updateIncident(editId, form);
} else { } else {
await createIncident(form); await createIncident(form);
const tpl = await SecureStore.getItemAsync("pref_title_template");
if (tpl) {
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();
} catch { } catch {
@@ -224,7 +263,7 @@ export default function NewIncidentScreen() {
returnKeyType="next" returnKeyType="next"
/> />
<Text style={[LABEL_STYLE, { marginBottom: 6 }]}>Service</Text> {labelRow("Service", "service")}
<TextInput <TextInput
style={FIELD_STYLE} style={FIELD_STYLE}
value={form.service} value={form.service}
@@ -278,6 +317,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}
+385 -143
View File
@@ -6,22 +6,36 @@ import {
ScrollView, ScrollView,
Switch, Switch,
Pressable, Pressable,
Alert,
} from "react-native"; } from "react-native";
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 { 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_MODEL: "pref_ai_model",
TITLE_TEMPLATE: "pref_title_template",
MD_TEMPLATE: "md_template",
GIT_PROVIDER: "git_provider",
GITEA_URL: "gitea_url", GITEA_URL: "gitea_url",
GITEA_TOKEN: "gitea_token", GITEA_TOKEN: "gitea_token",
GITEA_OWNER: "gitea_owner", GITEA_OWNER: "gitea_owner",
GITEA_REPO: "gitea_repo", GITEA_REPO: "gitea_repo",
MD_TEMPLATE: "md_template", GITHUB_TOKEN: "github_token",
GITHUB_OWNER: "github_owner",
GITHUB_REPO: "github_repo",
GITLAB_URL: "gitlab_url",
GITLAB_TOKEN: "gitlab_token",
GITLAB_OWNER: "gitlab_owner",
GITLAB_REPO: "gitlab_repo",
} as const; } as const;
const LABEL = { const LABEL = {
@@ -53,138 +67,251 @@ const INPUT = {
fontFamily: "monospace", fontFamily: "monospace",
} as const; } as const;
const PROVIDER_LABELS: Record<GitProvider, string> = {
gitea: "Gitea",
github: "GitHub",
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
// would cause TextInput to lose focus after each keystroke.
function ToggleRow({
label,
value,
onValueChange,
}: {
label: string;
value: boolean;
onValueChange: (v: boolean) => void;
}) {
return (
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
backgroundColor: Colors.surface,
borderRadius: 8,
padding: 14,
marginBottom: 10,
}}
>
<Text style={{ color: Colors.text1, fontSize: 15 }}>{label}</Text>
<Switch
value={value}
onValueChange={onValueChange}
trackColor={{ true: Colors.primary, false: Colors.border }}
thumbColor="#fff"
/>
</View>
);
}
function SegmentRow({
label,
options,
value,
onChange,
wrap,
}: {
label: string;
options: { key: string; label: string }[];
value: string;
onChange: (v: string) => void;
wrap?: boolean;
}) {
return (
<View style={{ marginBottom: 16 }}>
<Text style={LABEL}>{label}</Text>
<View style={{ flexDirection: "row", gap: 8, flexWrap: wrap ? "wrap" : "nowrap" }}>
{options.map((opt) => (
<Pressable
key={opt.key}
onPress={() => onChange(opt.key)}
style={{
...(wrap ? { width: "31%" } : { 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,
}}
>
<Text
style={{
color: value === opt.key ? Colors.bg : Colors.text2,
fontWeight: "700",
fontSize: 13,
}}
>
{opt.label}
</Text>
</Pressable>
))}
</View>
</View>
);
}
function Field({
label,
value,
onChange,
placeholder,
secure,
url,
}: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
secure?: boolean;
url?: boolean;
}) {
return (
<>
<Text style={LABEL}>{label}</Text>
<TextInput
style={INPUT}
value={value}
onChangeText={onChange}
placeholder={placeholder}
placeholderTextColor={Colors.textDim}
secureTextEntry={secure}
autoCapitalize="none"
autoCorrect={false}
keyboardType={url ? "url" : "default"}
/>
</>
);
}
export default function SettingsScreen() { export default function SettingsScreen() {
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 [giteaUrl, setGiteaUrl] = useState(""); const [aiBaseUrl, setAiBaseUrl] = useState("");
const [giteaToken, setGiteaToken] = useState(""); const [aiModel, setAiModel] = useState("");
const [giteaOwner, setGiteaOwner] = useState(""); const [titleTemplate, setTitleTemplate] = useState("");
const [giteaRepo, setGiteaRepo] = useState("");
const [mdTemplate, setMdTemplate] = useState(DEFAULT_TEMPLATE); const [mdTemplate, setMdTemplate] = useState(DEFAULT_TEMPLATE);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
const [gitProvider, setGitProvider] = useState<GitProvider>("gitea");
const [giteaRepoUrl, setGiteaRepoUrl] = useState("");
const [giteaToken, setGiteaToken] = useState("");
const [githubRepoUrl, setGithubRepoUrl] = useState("");
const [githubToken, setGithubToken] = useState("");
const [gitlabRepoUrl, setGitlabRepoUrl] = useState("");
const [gitlabToken, setGitlabToken] = useState("");
useEffect(() => { useEffect(() => {
(async () => { (async () => {
const [im, hv, ai, key, gu, gt, go, gr, tpl] = await Promise.all([ const [
im, hv, ai, aip, 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.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_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_URL),
SecureStore.getItemAsync(KEYS.GITEA_TOKEN), SecureStore.getItemAsync(KEYS.GITEA_TOKEN),
SecureStore.getItemAsync(KEYS.GITEA_OWNER), SecureStore.getItemAsync(KEYS.GITEA_OWNER),
SecureStore.getItemAsync(KEYS.GITEA_REPO), SecureStore.getItemAsync(KEYS.GITEA_REPO),
SecureStore.getItemAsync(KEYS.MD_TEMPLATE), 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 (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 (gu) setGiteaUrl(gu); if (aiUrl) setAiBaseUrl(aiUrl);
if (gt) setGiteaToken(gt); if (aiMdl) setAiModel(aiMdl);
if (go) setGiteaOwner(go); if (titleTpl) setTitleTemplate(titleTpl);
if (gr) setGiteaRepo(gr);
if (tpl) setMdTemplate(tpl); if (tpl) setMdTemplate(tpl);
if (gp) setGitProvider(gp as GitProvider);
if (gt) setGiteaToken(gt);
setGiteaRepoUrl(buildRepoUrl(gu, go, gr, "gitea"));
if (ghu) setGithubToken(ghu);
setGithubRepoUrl(buildRepoUrl(null, ghown, ghrepo, "github"));
if (glt) setGitlabToken(glt);
setGitlabRepoUrl(buildRepoUrl(glu, glo, glr, "gitlab"));
})(); })();
}, []); }, []);
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.GITEA_URL, giteaUrl), SecureStore.setItemAsync(KEYS.AI_BASE_URL, aiBaseUrl),
SecureStore.setItemAsync(KEYS.GITEA_TOKEN, giteaToken), SecureStore.setItemAsync(KEYS.AI_MODEL, aiModel),
SecureStore.setItemAsync(KEYS.GITEA_OWNER, giteaOwner), SecureStore.setItemAsync(KEYS.TITLE_TEMPLATE, titleTemplate),
SecureStore.setItemAsync(KEYS.GITEA_REPO, giteaRepo),
SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate), SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate),
SecureStore.setItemAsync(KEYS.GIT_PROVIDER, gitProvider),
SecureStore.setItemAsync(KEYS.GITEA_URL, gitea.instanceUrl ?? ""),
SecureStore.setItemAsync(KEYS.GITEA_TOKEN, giteaToken),
SecureStore.setItemAsync(KEYS.GITEA_OWNER, gitea.owner ?? ""),
SecureStore.setItemAsync(KEYS.GITEA_REPO, gitea.repo ?? ""),
SecureStore.setItemAsync(KEYS.GITHUB_TOKEN, githubToken),
SecureStore.setItemAsync(KEYS.GITHUB_OWNER, github.owner ?? ""),
SecureStore.setItemAsync(KEYS.GITHUB_REPO, github.repo ?? ""),
SecureStore.setItemAsync(KEYS.GITLAB_URL, gitlab.instanceUrl ?? ""),
SecureStore.setItemAsync(KEYS.GITLAB_TOKEN, gitlabToken),
SecureStore.setItemAsync(KEYS.GITLAB_OWNER, gitlab.owner ?? ""),
SecureStore.setItemAsync(KEYS.GITLAB_REPO, gitlab.repo ?? ""),
]); ]);
setSaved(true); setSaved(true);
setTimeout(() => setSaved(false), 2000); setTimeout(() => setSaved(false), 2000);
} }
function ToggleRow({
label,
value,
onValueChange,
}: {
label: string;
value: boolean;
onValueChange: (v: boolean) => void;
}) {
return (
<View
style={{
flexDirection: "row",
justifyContent: "space-between",
alignItems: "center",
backgroundColor: Colors.surface,
borderRadius: 8,
padding: 14,
marginBottom: 10,
}}
>
<Text style={{ color: Colors.text1, fontSize: 15 }}>{label}</Text>
<Switch
value={value}
onValueChange={onValueChange}
trackColor={{ true: Colors.primary, false: Colors.border }}
thumbColor="#fff"
/>
</View>
);
}
function SegmentRow({
label,
options,
value,
onChange,
}: {
label: string;
options: { key: string; label: string }[];
value: string;
onChange: (v: string) => void;
}) {
return (
<View style={{ marginBottom: 16 }}>
<Text style={LABEL}>{label}</Text>
<View style={{ flexDirection: "row", gap: 8 }}>
{options.map((opt) => (
<Pressable
key={opt.key}
onPress={() => 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,
}}
>
<Text
style={{
color: value === opt.key ? "#fff" : Colors.text2,
fontWeight: "600",
fontSize: 13,
}}
>
{opt.label}
</Text>
</Pressable>
))}
</View>
</View>
);
}
return ( return (
<ScrollView <ScrollView
style={{ flex: 1, backgroundColor: Colors.bg }} style={{ flex: 1, backgroundColor: Colors.bg }}
@@ -213,7 +340,48 @@ export default function SettingsScreen() {
]} ]}
/> />
<Text style={SECTION_TITLE}>AI (optional)</Text> <Field
label="Title Template"
value={titleTemplate}
onChange={setTitleTemplate}
placeholder="INC-001 (leave empty to disable)"
/>
<View
style={{
backgroundColor: Colors.surface,
borderRadius: 8,
padding: 10,
marginTop: -8,
marginBottom: 14,
borderLeftWidth: 3,
borderLeftColor: Colors.text2,
}}
>
<Text style={{ color: Colors.text2, fontSize: 12, lineHeight: 18 }}>
Pre-fills the Title field on new incidents. If the value ends with
digits, the number is auto-incremented after each save.{"\n"}
e.g. INC-001 INC-002 INC-003
</Text>
</View>
<Text style={SECTION_TITLE}>AI Root Cause Assistant</Text>
<View
style={{
backgroundColor: Colors.surface,
borderRadius: 8,
padding: 12,
marginBottom: 14,
borderLeftWidth: 3,
borderLeftColor: Colors.text2,
}}
>
<Text style={{ color: Colors.text2, fontSize: 13, lineHeight: 20 }}>
When enabled, a Suggest button appears between Symptom and Root Cause.
It sends the title and symptom to your AI provider and pre-fills the
analysis fields. Supports Anthropic natively; all others use the
OpenAI-compatible API.
</Text>
</View>
<ToggleRow <ToggleRow
label="Enable AI assistance" label="Enable AI assistance"
value={aiEnabled} value={aiEnabled}
@@ -221,59 +389,133 @@ export default function SettingsScreen() {
/> />
{aiEnabled && ( {aiEnabled && (
<> <>
<Text style={LABEL}>API Key</Text> <SegmentRow
<TextInput label="Provider"
style={INPUT} 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: "perplexity", label: "Perplexity" },
{ key: "ollama", label: "Ollama" },
{ key: "openrouter", label: "OpenRouter" },
{ key: "custom", label: "Custom" },
]}
/>
{aiProvider !== "anthropic" && (
<Field
label="Base URL"
value={aiBaseUrl}
onChange={setAiBaseUrl}
placeholder={AI_PROVIDER_BASE_URLS[aiProvider] || "https://your-api.example.com/v1"}
url
/>
)}
<Field
label="Model"
value={aiModel}
onChange={setAiModel}
placeholder={AI_PROVIDER_MODEL_PLACEHOLDERS[aiProvider]}
/>
<Field
label="API Key"
value={aiKey} value={aiKey}
onChangeText={setAiKey} onChange={setAiKey}
placeholder="sk-..." placeholder={aiProvider === "ollama" ? "no key required" : "paste your API key"}
placeholderTextColor={Colors.textDim} secure
secureTextEntry
autoCapitalize="none"
/> />
</> </>
)} )}
<Text style={SECTION_TITLE}>Gitea</Text> <Text style={SECTION_TITLE}>Git Repository</Text>
<Text style={LABEL}>Instance URL</Text>
<TextInput <SegmentRow
style={INPUT} label="Provider"
value={giteaUrl} value={gitProvider}
onChangeText={setGiteaUrl} onChange={(v) => setGitProvider(v as GitProvider)}
placeholder="https://homegit.gyozamancave.fr" options={(["gitea", "github", "gitlab"] as GitProvider[]).map((p) => ({
placeholderTextColor={Colors.textDim} key: p,
autoCapitalize="none" label: PROVIDER_LABELS[p],
keyboardType="url" }))}
/>
<Text style={LABEL}>Token</Text>
<TextInput
style={INPUT}
value={giteaToken}
onChangeText={setGiteaToken}
placeholder="Bearer token"
placeholderTextColor={Colors.textDim}
secureTextEntry
autoCapitalize="none"
/>
<Text style={LABEL}>Owner</Text>
<TextInput
style={INPUT}
value={giteaOwner}
onChangeText={setGiteaOwner}
placeholder="username or org"
placeholderTextColor={Colors.textDim}
autoCapitalize="none"
/>
<Text style={LABEL}>Repository</Text>
<TextInput
style={INPUT}
value={giteaRepo}
onChangeText={setGiteaRepo}
placeholder="incidents"
placeholderTextColor={Colors.textDim}
autoCapitalize="none"
/> />
{gitProvider === "gitea" && (
<>
<Field
label="Repo URL"
value={giteaRepoUrl}
onChange={setGiteaRepoUrl}
placeholder="https://homegit.example.com/username/incidents.git"
url
/>
<Field
label="Token (PAT, write:repository)"
value={giteaToken}
onChange={setGiteaToken}
placeholder="paste token value — no Bearer prefix"
secure
/>
<View
style={{
backgroundColor: Colors.surface,
borderRadius: 8,
padding: 10,
marginTop: -8,
marginBottom: 14,
borderLeftWidth: 3,
borderLeftColor: Colors.text2,
}}
>
<Text style={{ color: Colors.text2, fontSize: 12, lineHeight: 18 }}>
The repo must have at least one commit initialize it with a README if empty.
</Text>
</View>
</>
)}
{gitProvider === "github" && (
<>
<Field
label="Repo URL"
value={githubRepoUrl}
onChange={setGithubRepoUrl}
placeholder="https://github.com/username/incidents.git"
url
/>
<Field
label="Personal Access Token"
value={githubToken}
onChange={setGithubToken}
placeholder="ghp_..."
secure
/>
</>
)}
{gitProvider === "gitlab" && (
<>
<Field
label="Repo URL"
value={gitlabRepoUrl}
onChange={setGitlabRepoUrl}
placeholder="https://gitlab.com/username/incidents.git"
url
/>
<Field
label="Personal Access Token"
value={gitlabToken}
onChange={setGitlabToken}
placeholder="glpat-..."
secure
/>
</>
)}
<Text style={SECTION_TITLE}>Markdown Template</Text> <Text style={SECTION_TITLE}>Markdown Template</Text>
<TextInput <TextInput
style={{ ...INPUT, minHeight: 200, textAlignVertical: "top" }} style={{ ...INPUT, minHeight: 200, textAlignVertical: "top" }}
@@ -294,7 +536,7 @@ export default function SettingsScreen() {
marginTop: 8, marginTop: 8,
}} }}
> >
<Text style={{ color: "#fff", fontSize: 16, fontWeight: "700" }}> <Text style={{ color: Colors.bg, fontSize: 16, fontWeight: "700" }}>
{saved ? "Saved ✓" : "Save Settings"} {saved ? "Saved ✓" : "Save Settings"}
</Text> </Text>
</Pressable> </Pressable>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 10 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

+26
View File
@@ -77,6 +77,32 @@ info "Running expo prebuild (Android)…"
cd "$SCRIPT_DIR" cd "$SCRIPT_DIR"
npx expo prebuild --platform android --clean --no-install npx expo prebuild --platform android --clean --no-install
# ─── 4.5. Patch native modules — 16KB page size (Android 15) ──────────────
info "Patching native modules for 16KB page size support (Android 15)…"
WORKLETS_CMAKE="$SCRIPT_DIR/node_modules/react-native-worklets/android/CMakeLists.txt"
REANIMATED_CMAKE="$SCRIPT_DIR/node_modules/react-native-reanimated/android/CMakeLists.txt"
if [ -f "$WORKLETS_CMAKE" ] && ! grep -q "max-page-size" "$WORKLETS_CMAKE"; then
printf '\ntarget_link_options(worklets PRIVATE "-Wl,-z,max-page-size=16384")\n' >> "$WORKLETS_CMAKE"
info "Patched react-native-worklets for 16KB pages"
fi
if [ -f "$REANIMATED_CMAKE" ] && ! grep -q "max-page-size" "$REANIMATED_CMAKE"; then
printf '\ntarget_link_options(reanimated PRIVATE "-Wl,-z,max-page-size=16384")\n' >> "$REANIMATED_CMAKE"
info "Patched react-native-reanimated for 16KB pages"
fi
# ─── 4.6. Disable New Architecture (Android 15 compat) ────────────────────
GRADLE_PROPS_NEWARCH="$SCRIPT_DIR/android/gradle.properties"
if [ -f "$GRADLE_PROPS_NEWARCH" ]; then
sed -i 's/^newArchEnabled=true/newArchEnabled=false/' "$GRADLE_PROPS_NEWARCH"
if ! grep -q "^newArchEnabled=" "$GRADLE_PROPS_NEWARCH"; then
echo "newArchEnabled=false" >> "$GRADLE_PROPS_NEWARCH"
fi
info "New Architecture disabled in gradle.properties"
fi
# ─── 5. Patch build.gradle — inject release signingConfig ────────────────── # ─── 5. Patch build.gradle — inject release signingConfig ──────────────────
info "Patching android/app/build.gradle for release signing…" info "Patching android/app/build.gradle for release signing…"
+12 -12
View File
@@ -1,16 +1,16 @@
export const Colors = { export const Colors = {
bg: "#0f1117", bg: "#1B2433",
surface: "#1a1d27", surface: "#222D3D",
surface2: "#252836", surface2: "#293648",
primary: "#6366f1", primary: "#88D656",
primaryDim: "#4338ca", primaryDim: "#5FA33A",
success: "#22c55e", success: "#88D656",
warning: "#f59e0b", warning: "#F5A623",
danger: "#ef4444", danger: "#EF4444",
text1: "#f1f5f9", text1: "#EEF2F5",
text2: "#94a3b8", text2: "#8FA5BF",
textDim: "#64748b", textDim: "#4E6480",
border: "#2d3147", border: "#2E3F55",
} as const; } as const;
export const FontFamily = { export const FontFamily = {
+128
View File
@@ -0,0 +1,128 @@
import * as SecureStore from "expo-secure-store";
export type AIProvider =
| "anthropic"
| "openai"
| "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",
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",
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 }>(
+274
View File
@@ -0,0 +1,274 @@
import * as SecureStore from "expo-secure-store";
import { incidentToFilename, renderMarkdown } from "./markdown";
import type { Incident } from "@/types/incident";
export type GitProvider = "gitea" | "github" | "gitlab";
export interface GitConfig {
provider: GitProvider;
url?: string; // required for gitea and self-hosted gitlab
token: string;
owner: string;
repo: string;
}
export interface PushResult {
success: boolean;
url?: string;
error?: string;
}
const TIMEOUT_MS = 15_000;
export async function loadGitConfig(): Promise<GitConfig | null> {
const [provider, gu, gt, go, gr, ghu, ghown, ghrepo, glu, glt, glo, glr] =
await Promise.all([
SecureStore.getItemAsync("git_provider"),
SecureStore.getItemAsync("gitea_url"),
SecureStore.getItemAsync("gitea_token"),
SecureStore.getItemAsync("gitea_owner"),
SecureStore.getItemAsync("gitea_repo"),
SecureStore.getItemAsync("github_token"),
SecureStore.getItemAsync("github_owner"),
SecureStore.getItemAsync("github_repo"),
SecureStore.getItemAsync("gitlab_url"),
SecureStore.getItemAsync("gitlab_token"),
SecureStore.getItemAsync("gitlab_owner"),
SecureStore.getItemAsync("gitlab_repo"),
]);
const p = (provider ?? "gitea") as GitProvider;
let url: string | undefined;
let token: string | null;
let owner: string | null;
let repo: string | null;
if (p === "gitea") {
url = gu ?? undefined;
token = gt; owner = go; repo = gr;
} else if (p === "github") {
token = ghu; owner = ghown; repo = ghrepo;
} else {
url = glu ?? undefined;
token = glt; owner = glo; repo = glr;
}
const needsUrl = p === "gitea" || p === "gitlab";
if (!token || !owner || !repo || (needsUrl && !url)) return null;
return { provider: p, url, token, owner, repo };
}
function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
return fetch(url, { ...init, signal: controller.signal }).finally(() =>
clearTimeout(timer)
);
}
export async function pushIncidentToGit(
incident: Incident,
config: GitConfig,
markdownTemplate?: string
): Promise<PushResult> {
switch (config.provider) {
case "gitea":
return pushGitea(incident, config, markdownTemplate);
case "github":
return pushGitHub(incident, config, markdownTemplate);
case "gitlab":
return pushGitLab(incident, config, markdownTemplate);
}
}
async function pushGitea(
incident: Incident,
config: GitConfig,
markdownTemplate?: string
): Promise<PushResult> {
if (!config.url) {
return { success: false, error: "Gitea instance URL is not set. Check Settings → Git Repository." };
}
if (!config.token) {
return { success: false, error: "Gitea token is not set. Check Settings → Git Repository." };
}
if (!config.owner || !config.repo) {
return { success: false, error: "Gitea owner or repository is not set. Check Settings → Git Repository." };
}
const filepath = incidentToFilename(incident);
const content = renderMarkdown(incident, markdownTemplate);
const base64Content = btoa(unescape(encodeURIComponent(content)));
const apiBase = config.url.replace(/\/$/, "");
const endpoint = `${apiBase}/api/v1/repos/${config.owner}/${config.repo}/contents/${filepath}`;
try {
const existing = await fetchWithTimeout(endpoint, {
headers: { Authorization: `token ${config.token}` },
});
let sha: string | undefined;
if (existing.ok) {
const data = await existing.json() as { sha?: string };
sha = data.sha;
} else if (existing.status === 404 || existing.status === 409) {
// 404 = file not found (normal for first push)
// 409 = repo is empty / no branch yet — proceed, we'll create with branch:"main"
} else {
const body = await existing.text();
return { success: false, error: `GET ${existing.status}: ${body.slice(0, 200)}` };
}
const payload: Record<string, string> = {
message: `incident: ${incident.title}`,
content: base64Content,
branch: "main",
};
if (sha) payload.sha = sha;
// POST creates, PUT updates — Gitea requires the correct verb
const res = await fetchWithTimeout(endpoint, {
method: sha ? "PUT" : "POST",
headers: {
Authorization: `token ${config.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.text();
let hint = "";
if (res.status === 404) {
hint = "\n\nCheck: instance URL (no /api/v1), owner and repo names are exact, token has write:repository scope, and the repo exists on Gitea.";
}
return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 300)}${hint}` };
}
const data = await res.json() as { content?: { html_url?: string } };
return { success: true, url: data.content?.html_url };
} catch (e) {
if (e instanceof Error && e.name === "AbortError") {
return { success: false, error: `Request timed out after ${TIMEOUT_MS / 1000}s. Check that the Gitea URL is reachable.` };
}
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
}
async function pushGitHub(
incident: Incident,
config: GitConfig,
markdownTemplate?: string
): Promise<PushResult> {
if (!config.token) {
return { success: false, error: "GitHub token is not set. Check Settings → Git Repository." };
}
if (!config.owner || !config.repo) {
return { success: false, error: "GitHub owner or repository is not set. Check Settings → Git Repository." };
}
const filepath = incidentToFilename(incident);
const content = renderMarkdown(incident, markdownTemplate);
const base64Content = btoa(unescape(encodeURIComponent(content)));
const endpoint = `https://api.github.com/repos/${config.owner}/${config.repo}/contents/${filepath}`;
try {
const existing = await fetchWithTimeout(endpoint, {
headers: {
Authorization: `Bearer ${config.token}`,
Accept: "application/vnd.github+json",
},
});
let sha: string | undefined;
if (existing.ok) {
const data = await existing.json() as { sha?: string };
sha = data.sha;
} else if (existing.status !== 404) {
const body = await existing.text();
return { success: false, error: `GET ${existing.status}: ${body.slice(0, 200)}` };
}
const payload: Record<string, string> = {
message: `incident: ${incident.title}`,
content: base64Content,
};
if (sha) payload.sha = sha;
const res = await fetchWithTimeout(endpoint, {
method: "PUT",
headers: {
Authorization: `Bearer ${config.token}`,
Accept: "application/vnd.github+json",
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
if (!res.ok) {
const body = await res.text();
return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 300)}` };
}
const data = await res.json() as { content?: { html_url?: string } };
return { success: true, url: data.content?.html_url };
} catch (e) {
if (e instanceof Error && e.name === "AbortError") {
return { success: false, error: `Request timed out after ${TIMEOUT_MS / 1000}s.` };
}
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
}
async function pushGitLab(
incident: Incident,
config: GitConfig,
markdownTemplate?: string
): Promise<PushResult> {
if (!config.token) {
return { success: false, error: "GitLab token is not set. Check Settings → Git Repository." };
}
if (!config.owner || !config.repo) {
return { success: false, error: "GitLab namespace or repository is not set. Check Settings → Git Repository." };
}
const filepath = incidentToFilename(incident);
const content = renderMarkdown(incident, markdownTemplate);
const base64Content = btoa(unescape(encodeURIComponent(content)));
const apiBase = (config.url ?? "https://gitlab.com").replace(/\/$/, "");
const projectId = encodeURIComponent(`${config.owner}/${config.repo}`);
const encodedPath = encodeURIComponent(filepath);
const endpoint = `${apiBase}/api/v4/projects/${projectId}/repository/files/${encodedPath}`;
try {
const existing = await fetchWithTimeout(`${endpoint}?ref=HEAD`, {
headers: { "PRIVATE-TOKEN": config.token },
});
const method = existing.ok ? "PUT" : "POST";
const res = await fetchWithTimeout(endpoint, {
method,
headers: {
"PRIVATE-TOKEN": config.token,
"Content-Type": "application/json",
},
body: JSON.stringify({
branch: "main",
content: base64Content,
encoding: "base64",
commit_message: `incident: ${incident.title}`,
}),
});
if (!res.ok) {
const body = await res.text();
return { success: false, error: `HTTP ${res.status}: ${body.slice(0, 300)}` };
}
const data = await res.json() as { file_path?: string };
const webUrl = data.file_path
? `${apiBase}/${config.owner}/${config.repo}/-/blob/main/${data.file_path}`
: undefined;
return { success: true, url: webUrl };
} catch (e) {
if (e instanceof Error && e.name === "AbortError") {
return { success: false, error: `Request timed out after ${TIMEOUT_MS / 1000}s.` };
}
return { success: false, error: e instanceof Error ? e.message : String(e) };
}
}
+129 -143
View File
@@ -19,7 +19,7 @@
"expo-sqlite": "~56.0.5", "expo-sqlite": "~56.0.5",
"expo-status-bar": "~56.0.4", "expo-status-bar": "~56.0.4",
"nativewind": "~4.2.5", "nativewind": "~4.2.5",
"react": "19.2.7", "react": "19.2.3",
"react-native": "0.85.3", "react-native": "0.85.3",
"react-native-gesture-handler": "~3.0.2", "react-native-gesture-handler": "~3.0.2",
"react-native-reanimated": "~4.4.1", "react-native-reanimated": "~4.4.1",
@@ -1154,99 +1154,6 @@
"integrity": "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A==", "integrity": "sha512-IJkBtN1o8u9BW5fvSii1MyHPQ7Q0HxbWcVBvOrOzgMLpVtZw7R2w94wBTVR7kZwv3w1JNTESMmLA5Sqn1+Z36A==",
"license": "MIT AND Apache-2.0" "license": "MIT AND Apache-2.0"
}, },
"node_modules/@expo/cli": {
"version": "56.1.16",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.16.tgz",
"integrity": "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw==",
"license": "MIT",
"dependencies": {
"@expo/code-signing-certificates": "^0.0.6",
"@expo/config": "~56.0.9",
"@expo/config-plugins": "~56.0.9",
"@expo/devcert": "^1.2.1",
"@expo/env": "~2.3.0",
"@expo/image-utils": "^0.10.1",
"@expo/inline-modules": "^0.0.12",
"@expo/json-file": "^10.2.0",
"@expo/log-box": "^56.0.13",
"@expo/metro": "~56.0.0",
"@expo/metro-config": "~56.0.14",
"@expo/metro-file-map": "^56.0.3",
"@expo/osascript": "^2.6.0",
"@expo/package-manager": "^1.12.1",
"@expo/plist": "^0.7.0",
"@expo/prebuild-config": "^56.0.16",
"@expo/require-utils": "^56.1.3",
"@expo/router-server": "^56.0.14",
"@expo/schema-utils": "^56.0.0",
"@expo/spawn-async": "^1.8.0",
"@expo/ws-tunnel": "^2.0.0",
"@expo/xcpretty": "^4.4.4",
"@react-native/dev-middleware": "0.85.3",
"accepts": "^1.3.8",
"arg": "^5.0.2",
"bplist-creator": "0.1.0",
"bplist-parser": "^0.3.1",
"chalk": "^4.0.0",
"ci-info": "^3.3.0",
"compression": "^1.7.4",
"connect": "^3.7.0",
"debug": "^4.3.4",
"dnssd-advertise": "^1.1.4",
"expo-server": "^56.0.5",
"fetch-nodeshim": "^0.4.10",
"getenv": "^2.0.0",
"glob": "^13.0.0",
"lan-network": "^0.2.1",
"multitars": "^1.0.0",
"node-forge": "^1.3.3",
"npm-package-arg": "^11.0.0",
"ora": "^3.4.0",
"picomatch": "^4.0.4",
"pretty-format": "^29.7.0",
"progress": "^2.0.3",
"prompts": "^2.3.2",
"resolve-from": "^5.0.0",
"semver": "^7.6.0",
"send": "^0.19.0",
"slugify": "^1.3.4",
"stacktrace-parser": "^0.1.10",
"structured-headers": "^0.4.1",
"terminal-link": "^2.1.1",
"toqr": "^0.1.1",
"wrap-ansi": "^7.0.0",
"ws": "^8.12.1",
"zod": "^3.25.76"
},
"bin": {
"expo-internal": "main.js"
},
"peerDependencies": {
"expo": "*",
"expo-router": "*",
"react-native": "*"
},
"peerDependenciesMeta": {
"expo-router": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/@expo/cli/node_modules/semver": {
"version": "7.8.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@expo/code-signing-certificates": { "node_modules/@expo/code-signing-certificates": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", "resolved": "https://registry.npmjs.org/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",
@@ -1659,9 +1566,9 @@
} }
}, },
"node_modules/@expo/prebuild-config/node_modules/semver": { "node_modules/@expo/prebuild-config/node_modules/semver": {
"version": "7.8.4", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -1689,40 +1596,6 @@
} }
} }
}, },
"node_modules/@expo/router-server": {
"version": "56.0.14",
"resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-56.0.14.tgz",
"integrity": "sha512-2UCTtZfcq1ZPgp3wk8/+sq9DvFI9UxrPr1jcEKMAF2DGAJLosnpc8GWNNg2hkjt6SHUOdFHIPxujWPYyho2y3A==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.4"
},
"peerDependencies": {
"@expo/metro-runtime": "^56.0.15",
"expo": "*",
"expo-constants": "^56.0.18",
"expo-font": "^56.0.6",
"expo-router": "*",
"expo-server": "^56.0.5",
"react": "*",
"react-dom": "*",
"react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1"
},
"peerDependenciesMeta": {
"@expo/metro-runtime": {
"optional": true
},
"expo-router": {
"optional": true
},
"react-dom": {
"optional": true
},
"react-server-dom-webpack": {
"optional": true
}
}
},
"node_modules/@expo/schema-utils": { "node_modules/@expo/schema-utils": {
"version": "56.0.1", "version": "56.0.1",
"resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-56.0.1.tgz", "resolved": "https://registry.npmjs.org/@expo/schema-utils/-/schema-utils-56.0.1.tgz",
@@ -4306,6 +4179,121 @@
"react-native": "*" "react-native": "*"
} }
}, },
"node_modules/expo/node_modules/@expo/cli": {
"version": "56.1.16",
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-56.1.16.tgz",
"integrity": "sha512-VBQn0mqAwc67b9Cn0RVXyeodghomAx5xGRhA/bXaQzuxDjMQk0zIOb6pXMZX7yiIwJW66UZt/zQiJNSv6aWJYw==",
"license": "MIT",
"dependencies": {
"@expo/code-signing-certificates": "^0.0.6",
"@expo/config": "~56.0.9",
"@expo/config-plugins": "~56.0.9",
"@expo/devcert": "^1.2.1",
"@expo/env": "~2.3.0",
"@expo/image-utils": "^0.10.1",
"@expo/inline-modules": "^0.0.12",
"@expo/json-file": "^10.2.0",
"@expo/log-box": "^56.0.13",
"@expo/metro": "~56.0.0",
"@expo/metro-config": "~56.0.14",
"@expo/metro-file-map": "^56.0.3",
"@expo/osascript": "^2.6.0",
"@expo/package-manager": "^1.12.1",
"@expo/plist": "^0.7.0",
"@expo/prebuild-config": "^56.0.16",
"@expo/require-utils": "^56.1.3",
"@expo/router-server": "^56.0.14",
"@expo/schema-utils": "^56.0.0",
"@expo/spawn-async": "^1.8.0",
"@expo/ws-tunnel": "^2.0.0",
"@expo/xcpretty": "^4.4.4",
"@react-native/dev-middleware": "0.85.3",
"accepts": "^1.3.8",
"arg": "^5.0.2",
"bplist-creator": "0.1.0",
"bplist-parser": "^0.3.1",
"chalk": "^4.0.0",
"ci-info": "^3.3.0",
"compression": "^1.7.4",
"connect": "^3.7.0",
"debug": "^4.3.4",
"dnssd-advertise": "^1.1.4",
"expo-server": "^56.0.5",
"fetch-nodeshim": "^0.4.10",
"getenv": "^2.0.0",
"glob": "^13.0.0",
"lan-network": "^0.2.1",
"multitars": "^1.0.0",
"node-forge": "^1.3.3",
"npm-package-arg": "^11.0.0",
"ora": "^3.4.0",
"picomatch": "^4.0.4",
"pretty-format": "^29.7.0",
"progress": "^2.0.3",
"prompts": "^2.3.2",
"resolve-from": "^5.0.0",
"semver": "^7.6.0",
"send": "^0.19.0",
"slugify": "^1.3.4",
"stacktrace-parser": "^0.1.10",
"structured-headers": "^0.4.1",
"terminal-link": "^2.1.1",
"toqr": "^0.1.1",
"wrap-ansi": "^7.0.0",
"ws": "^8.12.1",
"zod": "^3.25.76"
},
"bin": {
"expo-internal": "main.js"
},
"peerDependencies": {
"expo": "*",
"expo-router": "*",
"react-native": "*"
},
"peerDependenciesMeta": {
"expo-router": {
"optional": true
},
"react-native": {
"optional": true
}
}
},
"node_modules/expo/node_modules/@expo/cli/node_modules/@expo/router-server": {
"version": "56.0.14",
"resolved": "https://registry.npmjs.org/@expo/router-server/-/router-server-56.0.14.tgz",
"integrity": "sha512-2UCTtZfcq1ZPgp3wk8/+sq9DvFI9UxrPr1jcEKMAF2DGAJLosnpc8GWNNg2hkjt6SHUOdFHIPxujWPYyho2y3A==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.4"
},
"peerDependencies": {
"@expo/metro-runtime": "^56.0.15",
"expo": "*",
"expo-constants": "^56.0.18",
"expo-font": "^56.0.6",
"expo-router": "*",
"expo-server": "^56.0.5",
"react": "*",
"react-dom": "*",
"react-server-dom-webpack": "~19.0.1 || ~19.1.2 || ~19.2.1"
},
"peerDependenciesMeta": {
"@expo/metro-runtime": {
"optional": true
},
"expo-router": {
"optional": true
},
"react-dom": {
"optional": true
},
"react-server-dom-webpack": {
"optional": true
}
}
},
"node_modules/expo/node_modules/expo-modules-core": { "node_modules/expo/node_modules/expo-modules-core": {
"version": "56.0.17", "version": "56.0.17",
"resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.17.tgz", "resolved": "https://registry.npmjs.org/expo-modules-core/-/expo-modules-core-56.0.17.tgz",
@@ -4359,8 +4347,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
"license": "ISC", "license": "ISC",
"optional": true,
"peer": true,
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
}, },
@@ -6124,9 +6110,9 @@
} }
}, },
"node_modules/npm-package-arg/node_modules/semver": { "node_modules/npm-package-arg/node_modules/semver": {
"version": "7.8.4", "version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "semver": "bin/semver.js"
@@ -6722,9 +6708,9 @@
} }
}, },
"node_modules/react": { "node_modules/react": {
"version": "19.2.7", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
"integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
@@ -6762,16 +6748,16 @@
} }
}, },
"node_modules/react-dom": { "node_modules/react-dom": {
"version": "19.2.7", "version": "19.2.3",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
"integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"scheduler": "^0.27.0" "scheduler": "^0.27.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": "^19.2.7" "react": "^19.2.3"
} }
}, },
"node_modules/react-fast-compare": { "node_modules/react-fast-compare": {
+5 -1
View File
@@ -19,7 +19,7 @@
"expo-sqlite": "~56.0.5", "expo-sqlite": "~56.0.5",
"expo-status-bar": "~56.0.4", "expo-status-bar": "~56.0.4",
"nativewind": "~4.2.5", "nativewind": "~4.2.5",
"react": "19.2.7", "react": "19.2.3",
"react-native": "0.85.3", "react-native": "0.85.3",
"react-native-gesture-handler": "~3.0.2", "react-native-gesture-handler": "~3.0.2",
"react-native-reanimated": "~4.4.1", "react-native-reanimated": "~4.4.1",
@@ -27,6 +27,10 @@
"react-native-screens": "~4.25.2", "react-native-screens": "~4.25.2",
"tailwindcss": "~3.4.19" "tailwindcss": "~3.4.19"
}, },
"overrides": {
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": { "devDependencies": {
"@babel/core": "^7.25.0", "@babel/core": "^7.25.0",
"@expo/metro-config": "~56.0.14", "@expo/metro-config": "~56.0.14",
+13
View File
@@ -0,0 +1,13 @@
// Exclude native build for packages not used in this project.
// react-native-gesture-handler detects their absence and falls back to its
// noreanimated codepath, so GestureHandlerRootView continues to work.
module.exports = {
dependencies: {
'react-native-reanimated': {
platforms: { android: null },
},
'react-native-worklets': {
platforms: { android: null },
},
},
};
+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">;