feat: delete incident, fix git push, title template, clarify AI settings
Release APK / build (push) Has been cancelled
Release APK / build (push) Has been cancelled
- IncidentDetailScreen: add Delete button (confirm dialog) wired to deleteIncident() - git.ts: fix Gitea POST vs PUT (use PUT when file exists/sha present); add pre-flight validation for missing URL/token/owner/repo; add 15s fetch timeout with AbortError handling; improve error messages with actionable hints - [id].tsx: guard URL for Gitea/GitLab before calling pushIncidentToGit - settings.tsx: move ToggleRow/SegmentRow/Field outside SettingsScreen to fix TextInput focus loss on each keystroke (component remount on rerender) - settings.tsx: add Title Template setting with auto-increment info card - settings.tsx: add AI base URL + model fields; expand AI section description - new.tsx: pre-fill title from pref_title_template; increment counter after save Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+72
-19
@@ -17,6 +17,16 @@ export interface PushResult {
|
||||
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,
|
||||
@@ -37,43 +47,61 @@ async function pushGitea(
|
||||
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 apiBase = config.url.replace(/\/$/, "");
|
||||
const endpoint = `${apiBase}/api/v1/repos/${config.owner}/${config.repo}/contents/${filepath}`;
|
||||
|
||||
try {
|
||||
const existing = await fetch(endpoint, {
|
||||
headers: { Authorization: `Bearer ${config.token}` },
|
||||
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) {
|
||||
const body = await existing.text();
|
||||
return { success: false, error: `GET ${existing.status}: ${body.slice(0, 200)}` };
|
||||
}
|
||||
|
||||
const body: Record<string, string> = {
|
||||
const payload: Record<string, string> = {
|
||||
message: `incident: ${incident.title}`,
|
||||
content: base64Content,
|
||||
};
|
||||
if (sha) body.sha = sha;
|
||||
if (sha) payload.sha = sha;
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
// POST creates, PUT updates — Gitea requires the correct verb
|
||||
const res = await fetchWithTimeout(endpoint, {
|
||||
method: sha ? "PUT" : "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.token}`,
|
||||
Authorization: `token ${config.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return { success: false, error: `HTTP ${res.status}: ${await res.text()}` };
|
||||
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. Check that the Gitea URL is reachable.` };
|
||||
}
|
||||
return { success: false, error: e instanceof Error ? e.message : String(e) };
|
||||
}
|
||||
}
|
||||
@@ -83,13 +111,20 @@ async function pushGitHub(
|
||||
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 fetch(endpoint, {
|
||||
const existing = await fetchWithTimeout(endpoint, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
@@ -99,30 +134,37 @@ async function pushGitHub(
|
||||
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 body: Record<string, string> = {
|
||||
const payload: Record<string, string> = {
|
||||
message: `incident: ${incident.title}`,
|
||||
content: base64Content,
|
||||
};
|
||||
if (sha) body.sha = sha;
|
||||
if (sha) payload.sha = sha;
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
const res = await fetchWithTimeout(endpoint, {
|
||||
method: "PUT",
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.token}`,
|
||||
Accept: "application/vnd.github+json",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return { success: false, error: `HTTP ${res.status}: ${await res.text()}` };
|
||||
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) };
|
||||
}
|
||||
}
|
||||
@@ -132,6 +174,13 @@ async function pushGitLab(
|
||||
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)));
|
||||
@@ -141,12 +190,12 @@ async function pushGitLab(
|
||||
const endpoint = `${apiBase}/api/v4/projects/${projectId}/repository/files/${encodedPath}`;
|
||||
|
||||
try {
|
||||
const existing = await fetch(`${endpoint}?ref=HEAD`, {
|
||||
const existing = await fetchWithTimeout(`${endpoint}?ref=HEAD`, {
|
||||
headers: { "PRIVATE-TOKEN": config.token },
|
||||
});
|
||||
const method = existing.ok ? "PUT" : "POST";
|
||||
|
||||
const res = await fetch(endpoint, {
|
||||
const res = await fetchWithTimeout(endpoint, {
|
||||
method,
|
||||
headers: {
|
||||
"PRIVATE-TOKEN": config.token,
|
||||
@@ -161,7 +210,8 @@ async function pushGitLab(
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return { success: false, error: `HTTP ${res.status}: ${await res.text()}` };
|
||||
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
|
||||
@@ -169,6 +219,9 @@ async function pushGitLab(
|
||||
: 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