Compare commits
6 Commits
c915d5f2ad
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f163d3b76 | |||
| 83fe089ea5 | |||
| 947ae5917c | |||
| 1437e99cbb | |||
| a74d27085b | |||
| fe1fdb9f9c |
@@ -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,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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+64
-68
@@ -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,21 +60,52 @@ export default function IncidentDetailScreen() {
|
|||||||
|
|
||||||
async function handleDelete() {
|
async function handleDelete() {
|
||||||
if (!incident) return;
|
if (!incident) return;
|
||||||
Alert.alert(
|
|
||||||
"Delete incident?",
|
const canDeleteFromRepo = !!incident.gitPushedAt && gitConfig !== null;
|
||||||
"This action cannot be undone.",
|
|
||||||
[
|
const doDelete = async (deleteFromRepo: boolean) => {
|
||||||
{ text: "Cancel", style: "cancel" },
|
if (deleteFromRepo && gitConfig) {
|
||||||
{
|
const result = await deleteIncidentFromGit(incident, gitConfig);
|
||||||
text: "Delete",
|
if (!result.success) {
|
||||||
style: "destructive",
|
Alert.alert("Repo deletion failed", result.error ?? "Unknown error");
|
||||||
onPress: async () => {
|
return;
|
||||||
await deleteIncident(incident.id);
|
}
|
||||||
router.replace("/");
|
}
|
||||||
|
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(
|
||||||
|
"Delete incident?",
|
||||||
|
"This action cannot be undone.",
|
||||||
|
[
|
||||||
|
{ text: "Cancel", style: "cancel" },
|
||||||
|
{
|
||||||
|
text: "Delete",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => doDelete(false),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleResolve() {
|
async function handleResolve() {
|
||||||
@@ -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"),
|
if (!config) {
|
||||||
SecureStore.getItemAsync("gitea_token"),
|
Alert.alert("Git not configured", "Set up a git provider in Settings first.");
|
||||||
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 = 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 }}>
|
||||||
|
|||||||
+325
-87
@@ -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,98 +156,243 @@ 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"
|
|
||||||
: "#88D65620",
|
|
||||||
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 + insets.bottom,
|
||||||
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,
|
||||||
|
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>
|
||||||
|
</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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+59
-9
@@ -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}
|
||||||
|
|||||||
+119
-100
@@ -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 && (
|
||||||
<>
|
<>
|
||||||
<Field
|
<SegmentRow
|
||||||
label="API Base URL"
|
label="Provider"
|
||||||
value={aiBaseUrl}
|
value={aiProvider}
|
||||||
onChange={setAiBaseUrl}
|
wrap
|
||||||
placeholder="http://192.168.x.x:11434/v1 (empty = OpenAI)"
|
onChange={(v) => {
|
||||||
url
|
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
|
||||||
|
label="Base URL"
|
||||||
|
value={aiBaseUrl}
|
||||||
|
onChange={setAiBaseUrl}
|
||||||
|
placeholder={AI_PROVIDER_BASE_URLS[aiProvider] || "https://your-api.example.com/v1"}
|
||||||
|
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"
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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">;
|
||||||
|
|||||||
Reference in New Issue
Block a user