Compare commits
4 Commits
v0.1.15
...
a74d27085b
| Author | SHA1 | Date | |
|---|---|---|---|
| a74d27085b | |||
| fe1fdb9f9c | |||
| c915d5f2ad | |||
| fee283b5d6 |
@@ -5,12 +5,13 @@
|
||||
"version": "0.1.0",
|
||||
"orientation": "portrait",
|
||||
"userInterfaceStyle": "dark",
|
||||
"backgroundColor": "#0f1117",
|
||||
"icon": "./assets/icon.png",
|
||||
"backgroundColor": "#1B2433",
|
||||
"scheme": "sheethappens",
|
||||
"android": {
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
"backgroundColor": "#0f1117"
|
||||
"backgroundColor": "#1B2433"
|
||||
},
|
||||
"package": "fr.gyozamancave.sheethappens",
|
||||
"permissions": [
|
||||
|
||||
+15
-1
@@ -1,4 +1,5 @@
|
||||
import { useEffect } from "react";
|
||||
import { Pressable, Text } from "react-native";
|
||||
import { Stack, router } from "expo-router";
|
||||
import { StatusBar } from "expo-status-bar";
|
||||
import { GestureHandlerRootView } from "react-native-gesture-handler";
|
||||
@@ -28,7 +29,20 @@ export default function RootLayout() {
|
||||
animation: "slide_from_right",
|
||||
}}
|
||||
>
|
||||
<Stack.Screen name="index" options={{ title: "SheetHappens" }} />
|
||||
<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="new" options={{ title: "New Incident", presentation: "modal" }} />
|
||||
<Stack.Screen name="incident/[id]" options={{ title: "Incident" }} />
|
||||
<Stack.Screen name="settings" options={{ title: "Settings" }} />
|
||||
|
||||
+86
-14
@@ -10,9 +10,10 @@ import {
|
||||
import { useLocalSearchParams, router, useNavigation } from "expo-router";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { getIncidentById, updateIncident } from "@/lib/db";
|
||||
import { getIncidentById, updateIncident, deleteIncident } from "@/lib/db";
|
||||
import { renderMarkdown } from "@/lib/markdown";
|
||||
import { pushIncidentToGitea } from "@/lib/gitea";
|
||||
import { pushIncidentToGit } from "@/lib/git";
|
||||
import type { GitProvider } from "@/lib/git";
|
||||
import type { Incident } from "@/types/incident";
|
||||
import { Colors } from "@/constants/theme";
|
||||
|
||||
@@ -53,34 +54,88 @@ export default function IncidentDetailScreen() {
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
async function handleDelete() {
|
||||
if (!incident) return;
|
||||
Alert.alert(
|
||||
"Delete incident?",
|
||||
"This action cannot be undone.",
|
||||
[
|
||||
{ text: "Cancel", style: "cancel" },
|
||||
{
|
||||
text: "Delete",
|
||||
style: "destructive",
|
||||
onPress: async () => {
|
||||
await deleteIncident(incident.id);
|
||||
router.replace("/");
|
||||
},
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
async function handleResolve() {
|
||||
if (!incident) return;
|
||||
await updateIncident(incident.id, { status: "resolved" });
|
||||
setIncident({ ...incident, status: "resolved" });
|
||||
}
|
||||
|
||||
async function handleGiteaPush() {
|
||||
async function handleGitPush() {
|
||||
if (!incident) return;
|
||||
const [url, token, owner, repo, template] = await Promise.all([
|
||||
const [provider, template, giteaUrl, giteaToken, giteaOwner, giteaRepo, githubToken, githubOwner, githubRepo, gitlabUrl, gitlabToken, gitlabOwner, gitlabRepo] =
|
||||
await Promise.all([
|
||||
SecureStore.getItemAsync("git_provider"),
|
||||
SecureStore.getItemAsync("md_template"),
|
||||
SecureStore.getItemAsync("gitea_url"),
|
||||
SecureStore.getItemAsync("gitea_token"),
|
||||
SecureStore.getItemAsync("gitea_owner"),
|
||||
SecureStore.getItemAsync("gitea_repo"),
|
||||
SecureStore.getItemAsync("md_template"),
|
||||
SecureStore.getItemAsync("github_token"),
|
||||
SecureStore.getItemAsync("github_owner"),
|
||||
SecureStore.getItemAsync("github_repo"),
|
||||
SecureStore.getItemAsync("gitlab_url"),
|
||||
SecureStore.getItemAsync("gitlab_token"),
|
||||
SecureStore.getItemAsync("gitlab_owner"),
|
||||
SecureStore.getItemAsync("gitlab_repo"),
|
||||
]);
|
||||
if (!url || !token || !owner || !repo) {
|
||||
Alert.alert("Gitea not configured", "Set up Gitea in Settings first.");
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
setPushing(true);
|
||||
const result = await pushIncidentToGitea(
|
||||
const result = await pushIncidentToGit(
|
||||
incident,
|
||||
{ url, token, owner, repo },
|
||||
{ provider: p, url, token, owner, repo },
|
||||
template ?? undefined
|
||||
);
|
||||
setPushing(false);
|
||||
if (result.success) {
|
||||
Alert.alert("Pushed", result.url ? `${result.url}` : "Push successful.");
|
||||
Alert.alert("Pushed", result.url ?? "Push successful.");
|
||||
} else {
|
||||
Alert.alert("Push failed", result.error ?? "Unknown error");
|
||||
}
|
||||
@@ -117,7 +172,7 @@ export default function IncidentDetailScreen() {
|
||||
<View
|
||||
style={{
|
||||
backgroundColor:
|
||||
incident.status === "open" ? "#f59e0b20" : "#22c55e20",
|
||||
incident.status === "open" ? "#f59e0b20" : "#88D65620",
|
||||
borderRadius: 6,
|
||||
paddingHorizontal: 10,
|
||||
paddingVertical: 4,
|
||||
@@ -188,7 +243,7 @@ export default function IncidentDetailScreen() {
|
||||
<Pressable
|
||||
onPress={handleResolve}
|
||||
style={{
|
||||
backgroundColor: "#22c55e20",
|
||||
backgroundColor: "#88D65620",
|
||||
borderWidth: 1,
|
||||
borderColor: Colors.success,
|
||||
borderRadius: 10,
|
||||
@@ -219,7 +274,7 @@ export default function IncidentDetailScreen() {
|
||||
</Pressable>
|
||||
|
||||
<Pressable
|
||||
onPress={handleGiteaPush}
|
||||
onPress={handleGitPush}
|
||||
disabled={pushing}
|
||||
style={{
|
||||
backgroundColor: pushing ? Colors.surface2 : Colors.surface,
|
||||
@@ -237,7 +292,7 @@ export default function IncidentDetailScreen() {
|
||||
fontSize: 15,
|
||||
}}
|
||||
>
|
||||
{pushing ? "Pushing…" : "Push to Gitea"}
|
||||
{pushing ? "Pushing…" : "Push to Git"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
@@ -256,6 +311,23 @@ export default function IncidentDetailScreen() {
|
||||
Edit
|
||||
</Text>
|
||||
</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>
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
+1
-1
@@ -100,7 +100,7 @@ export default function HomeScreen() {
|
||||
backgroundColor:
|
||||
item.status === "open"
|
||||
? "#f59e0b20"
|
||||
: "#22c55e20",
|
||||
: "#88D65620",
|
||||
borderRadius: 4,
|
||||
paddingHorizontal: 8,
|
||||
paddingVertical: 2,
|
||||
|
||||
+67
@@ -10,6 +10,7 @@ import {
|
||||
Alert,
|
||||
} from "react-native";
|
||||
import { router, useLocalSearchParams, useNavigation } from "expo-router";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import {
|
||||
createIncident,
|
||||
updateIncident,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
} from "@/lib/db";
|
||||
import { Colors } from "@/constants/theme";
|
||||
import { useVoice } from "@/hooks/useVoice";
|
||||
import { callAI } from "@/lib/ai";
|
||||
import type { IncidentDraft } from "@/types/incident";
|
||||
|
||||
const FIELD_STYLE = {
|
||||
@@ -48,6 +50,14 @@ const LABEL_STYLE = {
|
||||
|
||||
type VoiceField = "title" | "service" | "symptom" | "rootCause" | "fix";
|
||||
|
||||
function incrementTemplate(template: string): string {
|
||||
const match = template.match(/^([\s\S]*?)(\d+)$/);
|
||||
if (!match) return template;
|
||||
const [, prefix, numStr] = match;
|
||||
const next = (parseInt(numStr, 10) + 1).toString().padStart(numStr.length, "0");
|
||||
return prefix + next;
|
||||
}
|
||||
|
||||
export default function NewIncidentScreen() {
|
||||
const { editId } = useLocalSearchParams<{ editId?: string }>();
|
||||
const navigation = useNavigation();
|
||||
@@ -63,6 +73,9 @@ export default function NewIncidentScreen() {
|
||||
});
|
||||
const [tagInput, setTagInput] = useState("");
|
||||
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 [serviceFocused, setServiceFocused] = useState(false);
|
||||
|
||||
@@ -72,8 +85,16 @@ export default function NewIncidentScreen() {
|
||||
|
||||
useEffect(() => {
|
||||
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(() => {
|
||||
if (!editId) return;
|
||||
navigation.setOptions({ title: "Edit Incident" });
|
||||
@@ -133,6 +154,19 @@ export default function NewIncidentScreen() {
|
||||
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() {
|
||||
if (!form.title.trim()) {
|
||||
Alert.alert("Required", "Title is required.");
|
||||
@@ -144,6 +178,10 @@ export default function NewIncidentScreen() {
|
||||
await updateIncident(editId, form);
|
||||
} else {
|
||||
await createIncident(form);
|
||||
const tpl = await SecureStore.getItemAsync("pref_title_template");
|
||||
if (tpl) {
|
||||
await SecureStore.setItemAsync("pref_title_template", incrementTemplate(tpl));
|
||||
}
|
||||
}
|
||||
router.back();
|
||||
} catch {
|
||||
@@ -278,6 +316,35 @@ export default function NewIncidentScreen() {
|
||||
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")}
|
||||
<TextInput
|
||||
style={MONO_FIELD_STYLE}
|
||||
|
||||
+349
-107
@@ -6,22 +6,36 @@ import {
|
||||
ScrollView,
|
||||
Switch,
|
||||
Pressable,
|
||||
Alert,
|
||||
} from "react-native";
|
||||
import * as SecureStore from "expo-secure-store";
|
||||
import { Colors } from "@/constants/theme";
|
||||
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 = {
|
||||
INPUT_MODE: "pref_input_mode",
|
||||
HOME_VIEW: "pref_home_view",
|
||||
AI_ENABLED: "pref_ai_enabled",
|
||||
AI_PROVIDER: "pref_ai_provider",
|
||||
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_TOKEN: "gitea_token",
|
||||
GITEA_OWNER: "gitea_owner",
|
||||
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;
|
||||
|
||||
const LABEL = {
|
||||
@@ -53,68 +67,46 @@ const INPUT = {
|
||||
fontFamily: "monospace",
|
||||
} as const;
|
||||
|
||||
export default function SettingsScreen() {
|
||||
const [inputMode, setInputMode] = useState<"voice" | "form">("form");
|
||||
const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard");
|
||||
const [aiEnabled, setAiEnabled] = useState(false);
|
||||
const [aiKey, setAiKey] = useState("");
|
||||
const [giteaUrl, setGiteaUrl] = useState("");
|
||||
const [giteaToken, setGiteaToken] = useState("");
|
||||
const [giteaOwner, setGiteaOwner] = useState("");
|
||||
const [giteaRepo, setGiteaRepo] = useState("");
|
||||
const [mdTemplate, setMdTemplate] = useState(DEFAULT_TEMPLATE);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const PROVIDER_LABELS: Record<GitProvider, string> = {
|
||||
gitea: "Gitea",
|
||||
github: "GitHub",
|
||||
gitlab: "GitLab",
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const [im, hv, ai, key, gu, gt, go, gr, tpl] = await Promise.all([
|
||||
SecureStore.getItemAsync(KEYS.INPUT_MODE),
|
||||
SecureStore.getItemAsync(KEYS.HOME_VIEW),
|
||||
SecureStore.getItemAsync(KEYS.AI_ENABLED),
|
||||
SecureStore.getItemAsync(KEYS.AI_KEY),
|
||||
SecureStore.getItemAsync(KEYS.GITEA_URL),
|
||||
SecureStore.getItemAsync(KEYS.GITEA_TOKEN),
|
||||
SecureStore.getItemAsync(KEYS.GITEA_OWNER),
|
||||
SecureStore.getItemAsync(KEYS.GITEA_REPO),
|
||||
SecureStore.getItemAsync(KEYS.MD_TEMPLATE),
|
||||
]);
|
||||
if (im) setInputMode(im as "voice" | "form");
|
||||
if (hv) setHomeView(hv as "dashboard" | "capture");
|
||||
if (ai) setAiEnabled(ai === "true");
|
||||
if (key) setAiKey(key);
|
||||
if (gu) setGiteaUrl(gu);
|
||||
if (gt) setGiteaToken(gt);
|
||||
if (go) setGiteaOwner(go);
|
||||
if (gr) setGiteaRepo(gr);
|
||||
if (tpl) setMdTemplate(tpl);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function handleSave() {
|
||||
await Promise.all([
|
||||
SecureStore.setItemAsync(KEYS.INPUT_MODE, inputMode),
|
||||
SecureStore.setItemAsync(KEYS.HOME_VIEW, homeView),
|
||||
SecureStore.setItemAsync(KEYS.AI_ENABLED, String(aiEnabled)),
|
||||
SecureStore.setItemAsync(KEYS.AI_KEY, aiKey),
|
||||
SecureStore.setItemAsync(KEYS.GITEA_URL, giteaUrl),
|
||||
SecureStore.setItemAsync(KEYS.GITEA_TOKEN, giteaToken),
|
||||
SecureStore.setItemAsync(KEYS.GITEA_OWNER, giteaOwner),
|
||||
SecureStore.setItemAsync(KEYS.GITEA_REPO, giteaRepo),
|
||||
SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate),
|
||||
]);
|
||||
setSaved(true);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
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 ToggleRow({
|
||||
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={{
|
||||
@@ -136,29 +128,31 @@ export default function SettingsScreen() {
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function SegmentRow({
|
||||
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 }}>
|
||||
<View style={{ flexDirection: "row", gap: 8, flexWrap: wrap ? "wrap" : "nowrap" }}>
|
||||
{options.map((opt) => (
|
||||
<Pressable
|
||||
key={opt.key}
|
||||
onPress={() => onChange(opt.key)}
|
||||
style={{
|
||||
flex: 1,
|
||||
...(wrap ? { width: "31%" } : { flex: 1 }),
|
||||
backgroundColor:
|
||||
value === opt.key ? Colors.primary : Colors.surface,
|
||||
borderRadius: 8,
|
||||
@@ -171,8 +165,8 @@ export default function SettingsScreen() {
|
||||
>
|
||||
<Text
|
||||
style={{
|
||||
color: value === opt.key ? "#fff" : Colors.text2,
|
||||
fontWeight: "600",
|
||||
color: value === opt.key ? Colors.bg : Colors.text2,
|
||||
fontWeight: "700",
|
||||
fontSize: 13,
|
||||
}}
|
||||
>
|
||||
@@ -183,6 +177,139 @@ export default function SettingsScreen() {
|
||||
</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() {
|
||||
const [inputMode, setInputMode] = useState<"voice" | "form">("form");
|
||||
const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard");
|
||||
const [aiEnabled, setAiEnabled] = useState(false);
|
||||
const [aiProvider, setAiProvider] = useState<AIProvider>("openai");
|
||||
const [aiKey, setAiKey] = useState("");
|
||||
const [aiBaseUrl, setAiBaseUrl] = useState("");
|
||||
const [aiModel, setAiModel] = useState("");
|
||||
const [titleTemplate, setTitleTemplate] = useState("");
|
||||
const [mdTemplate, setMdTemplate] = useState(DEFAULT_TEMPLATE);
|
||||
const [saved, setSaved] = useState(false);
|
||||
|
||||
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(() => {
|
||||
(async () => {
|
||||
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.HOME_VIEW),
|
||||
SecureStore.getItemAsync(KEYS.AI_ENABLED),
|
||||
SecureStore.getItemAsync(KEYS.AI_PROVIDER),
|
||||
SecureStore.getItemAsync(KEYS.AI_KEY),
|
||||
SecureStore.getItemAsync(KEYS.AI_BASE_URL),
|
||||
SecureStore.getItemAsync(KEYS.AI_MODEL),
|
||||
SecureStore.getItemAsync(KEYS.TITLE_TEMPLATE),
|
||||
SecureStore.getItemAsync(KEYS.MD_TEMPLATE),
|
||||
SecureStore.getItemAsync(KEYS.GIT_PROVIDER),
|
||||
SecureStore.getItemAsync(KEYS.GITEA_URL),
|
||||
SecureStore.getItemAsync(KEYS.GITEA_TOKEN),
|
||||
SecureStore.getItemAsync(KEYS.GITEA_OWNER),
|
||||
SecureStore.getItemAsync(KEYS.GITEA_REPO),
|
||||
SecureStore.getItemAsync(KEYS.GITHUB_TOKEN),
|
||||
SecureStore.getItemAsync(KEYS.GITHUB_OWNER),
|
||||
SecureStore.getItemAsync(KEYS.GITHUB_REPO),
|
||||
SecureStore.getItemAsync(KEYS.GITLAB_URL),
|
||||
SecureStore.getItemAsync(KEYS.GITLAB_TOKEN),
|
||||
SecureStore.getItemAsync(KEYS.GITLAB_OWNER),
|
||||
SecureStore.getItemAsync(KEYS.GITLAB_REPO),
|
||||
]);
|
||||
if (im) setInputMode(im as "voice" | "form");
|
||||
if (hv) setHomeView(hv as "dashboard" | "capture");
|
||||
if (ai) setAiEnabled(ai === "true");
|
||||
if (aip) setAiProvider(aip as AIProvider);
|
||||
if (key) setAiKey(key);
|
||||
if (aiUrl) setAiBaseUrl(aiUrl);
|
||||
if (aiMdl) setAiModel(aiMdl);
|
||||
if (titleTpl) setTitleTemplate(titleTpl);
|
||||
if (tpl) setMdTemplate(tpl);
|
||||
if (gp) setGitProvider(gp as GitProvider);
|
||||
if (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() {
|
||||
const gitea = parseRepoUrl(giteaRepoUrl, "gitea");
|
||||
const github = parseRepoUrl(githubRepoUrl, "github");
|
||||
const gitlab = parseRepoUrl(gitlabRepoUrl, "gitlab");
|
||||
await Promise.all([
|
||||
SecureStore.setItemAsync(KEYS.INPUT_MODE, inputMode),
|
||||
SecureStore.setItemAsync(KEYS.HOME_VIEW, homeView),
|
||||
SecureStore.setItemAsync(KEYS.AI_ENABLED, String(aiEnabled)),
|
||||
SecureStore.setItemAsync(KEYS.AI_PROVIDER, aiProvider),
|
||||
SecureStore.setItemAsync(KEYS.AI_KEY, aiKey),
|
||||
SecureStore.setItemAsync(KEYS.AI_BASE_URL, aiBaseUrl),
|
||||
SecureStore.setItemAsync(KEYS.AI_MODEL, aiModel),
|
||||
SecureStore.setItemAsync(KEYS.TITLE_TEMPLATE, titleTemplate),
|
||||
SecureStore.setItemAsync(KEYS.MD_TEMPLATE, mdTemplate),
|
||||
SecureStore.setItemAsync(KEYS.GIT_PROVIDER, gitProvider),
|
||||
SecureStore.setItemAsync(KEYS.GITEA_URL, 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);
|
||||
setTimeout(() => setSaved(false), 2000);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -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
|
||||
label="Enable AI assistance"
|
||||
value={aiEnabled}
|
||||
@@ -221,58 +389,132 @@ export default function SettingsScreen() {
|
||||
/>
|
||||
{aiEnabled && (
|
||||
<>
|
||||
<Text style={LABEL}>API Key</Text>
|
||||
<TextInput
|
||||
style={INPUT}
|
||||
<SegmentRow
|
||||
label="Provider"
|
||||
value={aiProvider}
|
||||
wrap
|
||||
onChange={(v) => {
|
||||
const p = v as AIProvider;
|
||||
setAiProvider(p);
|
||||
setAiBaseUrl(AI_PROVIDER_BASE_URLS[p]);
|
||||
}}
|
||||
options={[
|
||||
{ key: "anthropic", label: "Anthropic" },
|
||||
{ key: "openai", label: "OpenAI" },
|
||||
{ key: "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}
|
||||
onChangeText={setAiKey}
|
||||
placeholder="sk-..."
|
||||
placeholderTextColor={Colors.textDim}
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
onChange={setAiKey}
|
||||
placeholder={aiProvider === "ollama" ? "no key required" : "paste your API key"}
|
||||
secure
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Text style={SECTION_TITLE}>Gitea</Text>
|
||||
<Text style={LABEL}>Instance URL</Text>
|
||||
<TextInput
|
||||
style={INPUT}
|
||||
value={giteaUrl}
|
||||
onChangeText={setGiteaUrl}
|
||||
placeholder="https://homegit.gyozamancave.fr"
|
||||
placeholderTextColor={Colors.textDim}
|
||||
autoCapitalize="none"
|
||||
keyboardType="url"
|
||||
<Text style={SECTION_TITLE}>Git Repository</Text>
|
||||
|
||||
<SegmentRow
|
||||
label="Provider"
|
||||
value={gitProvider}
|
||||
onChange={(v) => setGitProvider(v as GitProvider)}
|
||||
options={(["gitea", "github", "gitlab"] as GitProvider[]).map((p) => ({
|
||||
key: p,
|
||||
label: PROVIDER_LABELS[p],
|
||||
}))}
|
||||
/>
|
||||
<Text style={LABEL}>Token</Text>
|
||||
<TextInput
|
||||
style={INPUT}
|
||||
|
||||
{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}
|
||||
onChangeText={setGiteaToken}
|
||||
placeholder="Bearer token"
|
||||
placeholderTextColor={Colors.textDim}
|
||||
secureTextEntry
|
||||
autoCapitalize="none"
|
||||
onChange={setGiteaToken}
|
||||
placeholder="paste token value — no Bearer prefix"
|
||||
secure
|
||||
/>
|
||||
<Text style={LABEL}>Owner</Text>
|
||||
<TextInput
|
||||
style={INPUT}
|
||||
value={giteaOwner}
|
||||
onChangeText={setGiteaOwner}
|
||||
placeholder="username or org"
|
||||
placeholderTextColor={Colors.textDim}
|
||||
autoCapitalize="none"
|
||||
<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
|
||||
/>
|
||||
<Text style={LABEL}>Repository</Text>
|
||||
<TextInput
|
||||
style={INPUT}
|
||||
value={giteaRepo}
|
||||
onChangeText={setGiteaRepo}
|
||||
placeholder="incidents"
|
||||
placeholderTextColor={Colors.textDim}
|
||||
autoCapitalize="none"
|
||||
<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>
|
||||
<TextInput
|
||||
@@ -294,7 +536,7 @@ export default function SettingsScreen() {
|
||||
marginTop: 8,
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: "#fff", fontSize: 16, fontWeight: "700" }}>
|
||||
<Text style={{ color: Colors.bg, fontSize: 16, fontWeight: "700" }}>
|
||||
{saved ? "Saved ✓" : "Save Settings"}
|
||||
</Text>
|
||||
</Pressable>
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
+12
-12
@@ -1,16 +1,16 @@
|
||||
export const Colors = {
|
||||
bg: "#0f1117",
|
||||
surface: "#1a1d27",
|
||||
surface2: "#252836",
|
||||
primary: "#6366f1",
|
||||
primaryDim: "#4338ca",
|
||||
success: "#22c55e",
|
||||
warning: "#f59e0b",
|
||||
danger: "#ef4444",
|
||||
text1: "#f1f5f9",
|
||||
text2: "#94a3b8",
|
||||
textDim: "#64748b",
|
||||
border: "#2d3147",
|
||||
bg: "#1B2433",
|
||||
surface: "#222D3D",
|
||||
surface2: "#293648",
|
||||
primary: "#88D656",
|
||||
primaryDim: "#5FA33A",
|
||||
success: "#88D656",
|
||||
warning: "#F5A623",
|
||||
danger: "#EF4444",
|
||||
text1: "#EEF2F5",
|
||||
text2: "#8FA5BF",
|
||||
textDim: "#4E6480",
|
||||
border: "#2E3F55",
|
||||
} as const;
|
||||
|
||||
export const FontFamily = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
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;
|
||||
|
||||
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) };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user