3 Commits

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 18:18:50 +02:00
billisdead 947ae5917c feat: git push status indicator + multi-select bulk push
Release APK / build (push) Has been cancelled
- DB migration v2: git_pushed_at column on incidents
- markIncidentPushed(id): stamps the push timestamp
- loadGitConfig(): shared helper in lib/git.ts, removes duplication
- Home screen: colored dot per incident (red=never pushed,
  orange=dirty/modified after push, green=up to date);
  dot visible only when git is configured
- Home screen: long-press enters selection mode, tap toggles;
  action bar with Select unpushed / Select all shortcuts +
  bulk push with sequential progress counter
- [id].tsx: marks incident pushed after successful push,
  updates local state without reload

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-23 18:11:23 +02:00
10 changed files with 565 additions and 163 deletions
+3 -5
View File
@@ -42,15 +42,13 @@ jobs:
- name: Install dependencies
run: npm ci
- name: Set up Android SDK
uses: android-actions/setup-android@v3
- 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
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
uses: actions/cache@v4
+3
View File
@@ -3,6 +3,7 @@ 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";
import { SafeAreaProvider } from "react-native-safe-area-context";
import "../global.css";
import { Colors } from "@/constants/theme";
import { useSettings } from "@/hooks/useSettings";
@@ -18,6 +19,7 @@ export default function RootLayout() {
}, [ready, settings.onboardingDone]);
return (
<SafeAreaProvider>
<GestureHandlerRootView style={{ flex: 1, backgroundColor: Colors.bg }}>
<StatusBar style="light" />
<Stack
@@ -49,5 +51,6 @@ export default function RootLayout() {
<Stack.Screen name="onboarding" options={{ headerShown: false }} />
</Stack>
</GestureHandlerRootView>
</SafeAreaProvider>
);
}
+52 -56
View File
@@ -8,12 +8,13 @@ import {
Share,
} from "react-native";
import { useLocalSearchParams, router, useNavigation } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as Clipboard from "expo-clipboard";
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 { pushIncidentToGit } from "@/lib/git";
import type { GitProvider } from "@/lib/git";
import { pushIncidentToGit, loadGitConfig, deleteIncidentFromGit } from "@/lib/git";
import type { GitConfig } from "@/lib/git";
import type { Incident } from "@/types/incident";
import { Colors } from "@/constants/theme";
@@ -42,11 +43,14 @@ export default function IncidentDetailScreen() {
const navigation = useNavigation();
const [incident, setIncident] = useState<Incident | null>(null);
const [pushing, setPushing] = useState(false);
const [gitConfig, setGitConfig] = useState<GitConfig | null>(null);
const insets = useSafeAreaInsets();
useEffect(() => {
if (id) {
getIncidentById(id).then((inc) => {
Promise.all([getIncidentById(id), loadGitConfig()]).then(([inc, config]) => {
setIncident(inc);
setGitConfig(config);
if (inc) {
navigation.setOptions({ title: inc.title || "Incident" });
}
@@ -56,6 +60,39 @@ export default function IncidentDetailScreen() {
async function handleDelete() {
if (!incident) return;
const canDeleteFromRepo = !!incident.gitPushedAt && gitConfig !== null;
const doDelete = async (deleteFromRepo: boolean) => {
if (deleteFromRepo && gitConfig) {
const result = await deleteIncidentFromGit(incident, gitConfig);
if (!result.success) {
Alert.alert("Repo deletion failed", result.error ?? "Unknown error");
return;
}
}
await deleteIncident(incident.id);
router.replace("/");
};
if (canDeleteFromRepo) {
Alert.alert(
"Delete incident?",
"This incident was pushed to the repository. Do you also want to delete the file from the repo?",
[
{ text: "Cancel", style: "cancel" },
{
text: "Local only",
onPress: () => doDelete(false),
},
{
text: "Local + Repo",
style: "destructive",
onPress: () => doDelete(true),
},
]
);
} else {
Alert.alert(
"Delete incident?",
"This action cannot be undone.",
@@ -64,14 +101,12 @@ export default function IncidentDetailScreen() {
{
text: "Delete",
style: "destructive",
onPress: async () => {
await deleteIncident(incident.id);
router.replace("/");
},
onPress: () => doDelete(false),
},
]
);
}
}
async function handleResolve() {
if (!incident) return;
@@ -81,60 +116,21 @@ export default function IncidentDetailScreen() {
async function handleGitPush() {
if (!incident) return;
const [provider, template, giteaUrl, giteaToken, giteaOwner, giteaRepo, githubToken, githubOwner, githubRepo, gitlabUrl, gitlabToken, gitlabOwner, gitlabRepo] =
await Promise.all([
SecureStore.getItemAsync("git_provider"),
const [config, template] = await Promise.all([
loadGitConfig(),
SecureStore.getItemAsync("md_template"),
SecureStore.getItemAsync("gitea_url"),
SecureStore.getItemAsync("gitea_token"),
SecureStore.getItemAsync("gitea_owner"),
SecureStore.getItemAsync("gitea_repo"),
SecureStore.getItemAsync("github_token"),
SecureStore.getItemAsync("github_owner"),
SecureStore.getItemAsync("github_repo"),
SecureStore.getItemAsync("gitlab_url"),
SecureStore.getItemAsync("gitlab_token"),
SecureStore.getItemAsync("gitlab_owner"),
SecureStore.getItemAsync("gitlab_repo"),
]);
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.`);
if (!config) {
Alert.alert("Git not configured", "Set up a git provider in Settings first.");
return;
}
setPushing(true);
const result = await pushIncidentToGit(
incident,
{ provider: p, url, token, owner, repo },
template ?? undefined
);
const result = await pushIncidentToGit(incident, config, template ?? undefined);
setPushing(false);
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.");
} else {
Alert.alert("Push failed", result.error ?? "Unknown error");
@@ -165,7 +161,7 @@ export default function IncidentDetailScreen() {
return (
<ScrollView
style={{ flex: 1, backgroundColor: Colors.bg }}
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
contentContainerStyle={{ padding: 16, paddingBottom: 32 + insets.bottom }}
>
{/* Status badge */}
<View style={{ flexDirection: "row", alignItems: "center", gap: 8, marginBottom: 4 }}>
+260 -22
View File
@@ -5,16 +5,33 @@ import {
FlatList,
Pressable,
ActivityIndicator,
Alert,
} from "react-native";
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 type { Incident } from "@/types/incident";
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() {
const [incidents, setIncidents] = useState<Incident[]>([]);
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 didRedirect = useRef(false);
@@ -28,29 +45,105 @@ export default function HomeScreen() {
useFocusEffect(
useCallback(() => {
loadIncidents();
load();
}, [])
);
async function loadIncidents() {
async function load() {
setLoading(true);
const data = await getIncidents(10);
const [data, config, tpl] = await Promise.all([
getIncidents(100),
loadGitConfig(),
SecureStore.getItemAsync("md_template"),
]);
setIncidents(data);
setGitConfig(config);
setMdTemplate(tpl ?? undefined);
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 (
<View style={{ flex: 1, backgroundColor: Colors.bg }}>
{loading ? (
<ActivityIndicator
color={Colors.primary}
style={{ marginTop: 48 }}
/>
<ActivityIndicator color={Colors.primary} style={{ marginTop: 48 }} />
) : (
<FlatList
data={incidents}
keyExtractor={(item) => item.id}
contentContainerStyle={{ padding: 16, paddingBottom: 96 }}
contentContainerStyle={{ padding: 16, paddingBottom: 96 + insets.bottom }}
ListEmptyComponent={
<Text
style={{
@@ -63,19 +156,29 @@ export default function HomeScreen() {
No incidents. Press + to log one.
</Text>
}
renderItem={({ item }) => (
renderItem={({ item }) => {
const isSelected = selected.has(item.id);
return (
<Pressable
onPress={() => router.push(`/incident/${item.id}`)}
onPress={() =>
selectionMode
? toggleSelection(item.id)
: router.push(`/incident/${item.id}`)
}
onLongPress={() => !selectionMode && enterSelectionMode(item.id)}
style={{
backgroundColor: Colors.surface,
backgroundColor: isSelected ? Colors.surface2 : Colors.surface,
borderRadius: 8,
padding: 16,
marginBottom: 10,
borderLeftWidth: 3,
borderLeftColor:
item.status === "open" ? Colors.warning : Colors.success,
flexDirection: "row",
alignItems: "center",
}}
>
<View style={{ flex: 1 }}>
<View
style={{
flexDirection: "row",
@@ -95,16 +198,24 @@ export default function HomeScreen() {
>
{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",
item.status === "open" ? "#f59e0b20" : "#88D65620",
borderRadius: 4,
paddingHorizontal: 8,
paddingVertical: 2,
marginLeft: 8,
}}
>
<Text
@@ -122,23 +233,51 @@ export default function HomeScreen() {
</Text>
</View>
</View>
</View>
<Text
style={{ color: Colors.text2, fontSize: 12 }}
numberOfLines={1}
>
{item.service || "—"} · {item.createdAt.slice(0, 10)}
</Text>
</Pressable>
</View>
{selectionMode && (
<View
style={{
width: 22,
height: 22,
borderRadius: 11,
borderWidth: 2,
borderColor: isSelected ? Colors.primary : Colors.border,
backgroundColor: isSelected ? Colors.primary : "transparent",
alignItems: "center",
justifyContent: "center",
marginLeft: 14,
}}
>
{isSelected && (
<Text
style={{ color: Colors.bg, fontSize: 12, fontWeight: "700" }}
>
</Text>
)}
</View>
)}
</Pressable>
);
}}
/>
)}
{/* FAB */}
{/* FAB — hidden in selection mode */}
{!selectionMode && (
<Pressable
onPress={() => router.push("/new")}
style={{
position: "absolute",
bottom: 24,
bottom: 24 + insets.bottom,
right: 24,
width: 56,
height: 56,
@@ -149,12 +288,111 @@ export default function HomeScreen() {
elevation: 6,
}}
>
<Text
style={{ color: "#fff", fontSize: 28, lineHeight: 32 }}
<Text style={{ color: "#fff", fontSize: 28, lineHeight: 32 }}>+</Text>
</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>
);
}
+3 -1
View File
@@ -10,6 +10,7 @@ import {
Alert,
} from "react-native";
import { router, useLocalSearchParams, useNavigation } from "expo-router";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as SecureStore from "expo-secure-store";
import {
createIncident,
@@ -55,6 +56,7 @@ type VoiceField = "title" | "service" | "symptom" | "rootCause" | "fix";
export default function NewIncidentScreen() {
const { editId } = useLocalSearchParams<{ editId?: string }>();
const navigation = useNavigation();
const insets = useSafeAreaInsets();
const [form, setForm] = useState<IncidentDraft>({
title: "",
@@ -250,7 +252,7 @@ export default function NewIncidentScreen() {
behavior={Platform.OS === "ios" ? "padding" : undefined}
>
<ScrollView
contentContainerStyle={{ padding: 16, paddingBottom: 32 }}
contentContainerStyle={{ padding: 16, paddingBottom: 32 + insets.bottom }}
keyboardShouldPersistTaps="handled"
>
{labelRow("Title *", "title")}
+4 -1
View File
@@ -7,6 +7,7 @@ import {
Switch,
Pressable,
} from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as SecureStore from "expo-secure-store";
import { Colors } from "@/constants/theme";
import { DEFAULT_TEMPLATE } from "@/lib/markdown";
@@ -213,6 +214,7 @@ function Field({
}
export default function SettingsScreen() {
const insets = useSafeAreaInsets();
const [inputMode, setInputMode] = useState<"voice" | "form">("form");
const [homeView, setHomeView] = useState<"dashboard" | "capture">("dashboard");
const [aiEnabled, setAiEnabled] = useState(false);
@@ -315,7 +317,7 @@ export default function SettingsScreen() {
return (
<ScrollView
style={{ flex: 1, backgroundColor: Colors.bg }}
contentContainerStyle={{ padding: 16, paddingBottom: 48 }}
contentContainerStyle={{ padding: 16, paddingBottom: 48 + insets.bottom }}
keyboardShouldPersistTaps="handled"
>
<Text style={SECTION_TITLE}>Preferences</Text>
@@ -401,6 +403,7 @@ export default function SettingsScreen() {
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" },
+3
View File
@@ -3,6 +3,7 @@ import * as SecureStore from "expo-secure-store";
export type AIProvider =
| "anthropic"
| "openai"
| "mistral"
| "perplexity"
| "ollama"
| "openrouter"
@@ -18,6 +19,7 @@ const KEYS = {
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",
@@ -27,6 +29,7 @@ export const AI_PROVIDER_BASE_URLS: Record<AIProvider, string> = {
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",
+18
View File
@@ -45,6 +45,13 @@ async function migrate(db: SQLite.SQLiteDatabase): Promise<void> {
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 ---
@@ -60,6 +67,7 @@ interface IncidentRow {
created_at: string;
updated_at: string;
tags: string;
git_pushed_at: string | null;
}
function rowToIncident(row: IncidentRow): Incident {
@@ -74,6 +82,7 @@ function rowToIncident(row: IncidentRow): Incident {
createdAt: row.created_at,
updatedAt: row.updated_at,
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,
createdAt: now,
updatedAt: now,
gitPushedAt: null,
};
}
@@ -163,6 +173,14 @@ export async function deleteIncident(id: string): Promise<void> {
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 }>(
+140
View File
@@ -1,3 +1,4 @@
import * as SecureStore from "expo-secure-store";
import { incidentToFilename, renderMarkdown } from "./markdown";
import type { Incident } from "@/types/incident";
@@ -17,8 +18,51 @@ export interface PushResult {
error?: string;
}
export interface DeleteResult {
success: boolean;
error?: string;
}
const TIMEOUT_MS = 15_000;
export async function loadGitConfig(): Promise<GitConfig | null> {
const [provider, gu, gt, go, gr, ghu, ghown, ghrepo, glu, glt, glo, glr] =
await Promise.all([
SecureStore.getItemAsync("git_provider"),
SecureStore.getItemAsync("gitea_url"),
SecureStore.getItemAsync("gitea_token"),
SecureStore.getItemAsync("gitea_owner"),
SecureStore.getItemAsync("gitea_repo"),
SecureStore.getItemAsync("github_token"),
SecureStore.getItemAsync("github_owner"),
SecureStore.getItemAsync("github_repo"),
SecureStore.getItemAsync("gitlab_url"),
SecureStore.getItemAsync("gitlab_token"),
SecureStore.getItemAsync("gitlab_owner"),
SecureStore.getItemAsync("gitlab_repo"),
]);
const p = (provider ?? "gitea") as GitProvider;
let url: string | undefined;
let token: string | null;
let owner: string | null;
let repo: string | null;
if (p === "gitea") {
url = gu ?? undefined;
token = gt; owner = go; repo = gr;
} else if (p === "github") {
token = ghu; owner = ghown; repo = ghrepo;
} else {
url = glu ?? undefined;
token = glt; owner = glo; repo = glr;
}
const needsUrl = p === "gitea" || p === "gitlab";
if (!token || !owner || !repo || (needsUrl && !url)) return null;
return { provider: p, url, token, owner, repo };
}
function fetchWithTimeout(url: string, init: RequestInit): Promise<Response> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
@@ -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(
incident: Incident,
config: GitConfig,
@@ -177,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(
incident: Incident,
config: GitConfig,
+2 -1
View File
@@ -11,6 +11,7 @@ export interface Incident {
createdAt: string;
updatedAt: string;
tags: string[];
gitPushedAt: string | null;
}
export type IncidentDraft = Omit<Incident, "id" | "createdAt" | "updatedAt">;
export type IncidentDraft = Omit<Incident, "id" | "createdAt" | "updatedAt" | "gitPushedAt">;