3 Commits

Author SHA1 Message Date
billisdead 1437e99cbb fix: title increment based on DB max, not stored template only
Release APK / build (push) Has been cancelled
After each incident save, query existing titles with the same prefix
to find the real max number, then use max(dbMax, tplNum) + 1.
Prevents duplicate counters when incidents were created before the
template was configured.

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 14:12:09 +02:00
5 changed files with 323 additions and 109 deletions
+56 -8
View File
@@ -16,9 +16,11 @@ import {
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,13 +51,6 @@ 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 }>();
@@ -72,6 +67,9 @@ export default function NewIncidentScreen() {
}); });
const [tagInput, setTagInput] = useState(""); const [tagInput, setTagInput] = useState("");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [suggesting, setSuggesting] = useState(false);
const [suggestError, setSuggestError] = useState("");
const [aiEnabled, setAiEnabled] = useState(false);
const [knownServices, setKnownServices] = useState<string[]>([]); const [knownServices, setKnownServices] = useState<string[]>([]);
const [serviceFocused, setServiceFocused] = useState(false); const [serviceFocused, setServiceFocused] = useState(false);
@@ -81,6 +79,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 +148,19 @@ export default function NewIncidentScreen() {
setForm((f) => ({ ...f, tags: f.tags.filter((t) => t !== tag) })); setForm((f) => ({ ...f, tags: f.tags.filter((t) => t !== tag) }));
} }
async function handleSuggest() {
setSuggesting(true);
setSuggestError("");
try {
const result = await callAI(form.title, form.symptom);
setForm((f) => ({ ...f, rootCause: result.rootCause, fix: result.fix }));
} catch (e) {
setSuggestError(e instanceof Error ? e.message : "AI call failed");
} finally {
setSuggesting(false);
}
}
async function handleSave() { async function handleSave() {
if (!form.title.trim()) { if (!form.title.trim()) {
Alert.alert("Required", "Title is required."); Alert.alert("Required", "Title is required.");
@@ -162,7 +174,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();
@@ -298,6 +317,35 @@ export default function NewIncidentScreen() {
returnKeyType="next" returnKeyType="next"
/> />
{aiEnabled && form.title.trim() && form.symptom.trim() && (
<View style={{ marginBottom: 16 }}>
<Pressable
onPress={handleSuggest}
disabled={suggesting}
style={{
backgroundColor: suggesting ? Colors.surface2 : Colors.surface,
borderRadius: 8,
padding: 12,
alignItems: "center",
borderWidth: 1,
borderColor: Colors.primary,
flexDirection: "row",
justifyContent: "center",
gap: 8,
}}
>
<Text style={{ color: Colors.primary, fontSize: 14, fontWeight: "700" }}>
{suggesting ? "Analyzing…" : "✦ Suggest root cause & fix"}
</Text>
</Pressable>
{suggestError !== "" && (
<Text style={{ color: Colors.danger, fontSize: 12, marginTop: 6 }}>
{suggestError}
</Text>
)}
</View>
)}
{labelRow("Root Cause", "rootCause")} {labelRow("Root Cause", "rootCause")}
<TextInput <TextInput
style={MONO_FIELD_STYLE} style={MONO_FIELD_STYLE}
+111 -95
View File
@@ -11,11 +11,14 @@ 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 +73,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 +135,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,
@@ -189,6 +216,7 @@ export default function SettingsScreen() {
const [inputMode, setInputMode] = useState<"voice" | "form">("form"); const [inputMode, setInputMode] = useState<"voice" | "form">("form");
const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard"); const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard");
const [aiEnabled, setAiEnabled] = useState(false); const [aiEnabled, setAiEnabled] = useState(false);
const [aiProvider, setAiProvider] = useState<AIProvider>("openai");
const [aiKey, setAiKey] = useState(""); const [aiKey, setAiKey] = useState("");
const [aiBaseUrl, setAiBaseUrl] = useState(""); const [aiBaseUrl, setAiBaseUrl] = useState("");
const [aiModel, setAiModel] = useState(""); const [aiModel, setAiModel] = useState("");
@@ -197,22 +225,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 +243,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 +265,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);
@@ -352,10 +376,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 +389,44 @@ export default function SettingsScreen() {
/> />
{aiEnabled && ( {aiEnabled && (
<> <>
<SegmentRow
label="Provider"
value={aiProvider}
wrap
onChange={(v) => {
const p = v as AIProvider;
setAiProvider(p);
setAiBaseUrl(AI_PROVIDER_BASE_URLS[p]);
}}
options={[
{ key: "anthropic", label: "Anthropic" },
{ key: "openai", label: "OpenAI" },
{ key: "perplexity", label: "Perplexity" },
{ key: "ollama", label: "Ollama" },
{ key: "openrouter", label: "OpenRouter" },
{ key: "custom", label: "Custom" },
]}
/>
{aiProvider !== "anthropic" && (
<Field <Field
label="API Base URL" label="Base URL"
value={aiBaseUrl} value={aiBaseUrl}
onChange={setAiBaseUrl} onChange={setAiBaseUrl}
placeholder="http://192.168.x.x:11434/v1 (empty = OpenAI)" placeholder={AI_PROVIDER_BASE_URLS[aiProvider] || "https://your-api.example.com/v1"}
url url
/> />
)}
<Field <Field
label="Model" label="Model"
value={aiModel} value={aiModel}
onChange={setAiModel} onChange={setAiModel}
placeholder="gpt-4o-mini or llama3.2" placeholder={AI_PROVIDER_MODEL_PLACEHOLDERS[aiProvider]}
/> />
<Field <Field
label="API Key" label="API Key"
value={aiKey} value={aiKey}
onChange={setAiKey} onChange={setAiKey}
placeholder="sk-... (leave empty for Ollama)" placeholder={aiProvider === "ollama" ? "no key required" : "paste your API key"}
secure secure
/> />
</> </>
@@ -403,50 +447,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 +494,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 +513,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"
/>
</> </>
)} )}
+128
View File
@@ -0,0 +1,128 @@
import * as SecureStore from "expo-secure-store";
export type AIProvider =
| "anthropic"
| "openai"
| "perplexity"
| "ollama"
| "openrouter"
| "custom";
const KEYS = {
AI_PROVIDER: "pref_ai_provider",
AI_BASE_URL: "pref_ai_base_url",
AI_MODEL: "pref_ai_model",
AI_KEY: "pref_ai_key",
};
export const AI_PROVIDER_BASE_URLS: Record<AIProvider, string> = {
anthropic: "https://api.anthropic.com",
openai: "https://api.openai.com/v1",
perplexity: "https://api.perplexity.ai",
ollama: "http://localhost:11434/v1",
openrouter: "https://openrouter.ai/api/v1",
custom: "",
};
export const AI_PROVIDER_MODEL_PLACEHOLDERS: Record<AIProvider, string> = {
anthropic: "claude-haiku-4-5-20251001",
openai: "gpt-4o-mini",
perplexity: "llama-3.1-sonar-small-128k-online",
ollama: "llama3.2",
openrouter: "openai/gpt-4o-mini",
custom: "model-name",
};
const SYSTEM_PROMPT =
"You are a systems reliability engineer. Analyze the incident and respond with JSON ONLY " +
"(no prose, no markdown fences):\n" +
'{"root_cause":"...","fix":"..."}\n' +
"Be concise and technical. Max 200 chars per field.";
async function getConfig() {
const [provider, baseUrl, model, key] = await Promise.all([
SecureStore.getItemAsync(KEYS.AI_PROVIDER),
SecureStore.getItemAsync(KEYS.AI_BASE_URL),
SecureStore.getItemAsync(KEYS.AI_MODEL),
SecureStore.getItemAsync(KEYS.AI_KEY),
]);
const p = (provider ?? "openai") as AIProvider;
return {
provider: p,
baseUrl: baseUrl || AI_PROVIDER_BASE_URLS[p],
model: model || AI_PROVIDER_MODEL_PLACEHOLDERS[p],
key: key ?? "",
};
}
export async function callAI(
title: string,
symptom: string
): Promise<{ rootCause: string; fix: string }> {
const config = await getConfig();
const userMsg = `Title: ${title}\nSymptom: ${symptom}`;
const raw =
config.provider === "anthropic"
? await callAnthropic(config, userMsg)
: await callOpenAICompat(config, userMsg);
try {
const parsed = JSON.parse(raw) as { root_cause?: string; fix?: string };
return {
rootCause: parsed.root_cause ?? "",
fix: parsed.fix ?? "",
};
} catch {
throw new Error(`Unexpected AI response: ${raw.slice(0, 120)}`);
}
}
async function callAnthropic(
config: { baseUrl: string; model: string; key: string },
userMsg: string
): Promise<string> {
const res = await fetch(`${config.baseUrl}/v1/messages`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"x-api-key": config.key,
"anthropic-version": "2023-06-01",
},
body: JSON.stringify({
model: config.model,
max_tokens: 512,
system: SYSTEM_PROMPT,
messages: [{ role: "user", content: userMsg }],
}),
});
if (!res.ok) throw new Error(`Anthropic ${res.status}: ${await res.text()}`);
const data = (await res.json()) as { content: { text: string }[] };
return data.content[0].text;
}
async function callOpenAICompat(
config: { baseUrl: string; model: string; key: string },
userMsg: string
): Promise<string> {
const headers: Record<string, string> = { "Content-Type": "application/json" };
if (config.key) headers["Authorization"] = `Bearer ${config.key}`;
const res = await fetch(`${config.baseUrl}/chat/completions`, {
method: "POST",
headers,
body: JSON.stringify({
model: config.model,
max_tokens: 512,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: userMsg },
],
}),
});
if (!res.ok) throw new Error(`AI ${res.status}: ${await res.text()}`);
const data = (await res.json()) as {
choices: { message: { content: string } }[];
};
return data.choices[0].message.content;
}
+14
View File
@@ -163,6 +163,20 @@ 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 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 }>(
+10 -2
View File
@@ -71,7 +71,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 +82,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 +98,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 };