Initial scaffold: Expo SDK 56, expo-router, expo-sqlite, NativeWind
This commit is contained in:
@@ -0,0 +1,191 @@
|
||||
import * as SQLite from "expo-sqlite";
|
||||
import type { Incident, IncidentDraft, IncidentStatus } from "@/types/incident";
|
||||
|
||||
const DB_NAME = "sheethappens.db";
|
||||
|
||||
let dbInstance: SQLite.SQLiteDatabase | null = null;
|
||||
|
||||
async function getDb(): Promise<SQLite.SQLiteDatabase> {
|
||||
if (dbInstance) return dbInstance;
|
||||
dbInstance = await SQLite.openDatabaseAsync(DB_NAME);
|
||||
await migrate(dbInstance);
|
||||
return dbInstance;
|
||||
}
|
||||
|
||||
async function migrate(db: SQLite.SQLiteDatabase): Promise<void> {
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS schema_version (
|
||||
version INTEGER PRIMARY KEY NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
const row = await db.getFirstAsync<{ version: number }>(
|
||||
"SELECT MAX(version) as version FROM schema_version"
|
||||
);
|
||||
const current = row?.version ?? 0;
|
||||
|
||||
if (current < 1) {
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS incidents (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
service TEXT NOT NULL DEFAULT '',
|
||||
symptom TEXT NOT NULL DEFAULT '',
|
||||
root_cause TEXT NOT NULL DEFAULT '',
|
||||
fix TEXT NOT NULL DEFAULT '',
|
||||
status TEXT NOT NULL DEFAULT 'open',
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
tags TEXT NOT NULL DEFAULT '[]'
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_status
|
||||
ON incidents(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_incidents_created_at
|
||||
ON incidents(created_at DESC);
|
||||
INSERT INTO schema_version (version) VALUES (1);
|
||||
`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- row mapper ---
|
||||
|
||||
interface IncidentRow {
|
||||
id: string;
|
||||
title: string;
|
||||
service: string;
|
||||
symptom: string;
|
||||
root_cause: string;
|
||||
fix: string;
|
||||
status: IncidentStatus;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
tags: string;
|
||||
}
|
||||
|
||||
function rowToIncident(row: IncidentRow): Incident {
|
||||
return {
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
service: row.service,
|
||||
symptom: row.symptom,
|
||||
rootCause: row.root_cause,
|
||||
fix: row.fix,
|
||||
status: row.status,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
tags: JSON.parse(row.tags) as string[],
|
||||
};
|
||||
}
|
||||
|
||||
// --- CRUD ---
|
||||
|
||||
export async function createIncident(draft: IncidentDraft): Promise<Incident> {
|
||||
const db = await getDb();
|
||||
const id = generateId();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
await db.runAsync(
|
||||
`INSERT INTO incidents
|
||||
(id, title, service, symptom, root_cause, fix, status, created_at, updated_at, tags)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
id,
|
||||
draft.title,
|
||||
draft.service,
|
||||
draft.symptom,
|
||||
draft.rootCause,
|
||||
draft.fix,
|
||||
draft.status,
|
||||
now,
|
||||
now,
|
||||
JSON.stringify(draft.tags),
|
||||
]
|
||||
);
|
||||
|
||||
return {
|
||||
...draft,
|
||||
id,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getIncidents(limit = 50): Promise<Incident[]> {
|
||||
const db = await getDb();
|
||||
const rows = await db.getAllAsync<IncidentRow>(
|
||||
"SELECT * FROM incidents ORDER BY created_at DESC LIMIT ?",
|
||||
[limit]
|
||||
);
|
||||
return rows.map(rowToIncident);
|
||||
}
|
||||
|
||||
export async function getIncidentById(id: string): Promise<Incident | null> {
|
||||
const db = await getDb();
|
||||
const row = await db.getFirstAsync<IncidentRow>(
|
||||
"SELECT * FROM incidents WHERE id = ?",
|
||||
[id]
|
||||
);
|
||||
return row ? rowToIncident(row) : null;
|
||||
}
|
||||
|
||||
export async function updateIncident(
|
||||
id: string,
|
||||
updates: Partial<IncidentDraft>
|
||||
): Promise<void> {
|
||||
const db = await getDb();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const fields: string[] = [];
|
||||
const values: (string | null)[] = [];
|
||||
|
||||
if (updates.title !== undefined) { fields.push("title = ?"); values.push(updates.title); }
|
||||
if (updates.service !== undefined) { fields.push("service = ?"); values.push(updates.service); }
|
||||
if (updates.symptom !== undefined) { fields.push("symptom = ?"); values.push(updates.symptom); }
|
||||
if (updates.rootCause !== undefined) { fields.push("root_cause = ?"); values.push(updates.rootCause); }
|
||||
if (updates.fix !== undefined) { fields.push("fix = ?"); values.push(updates.fix); }
|
||||
if (updates.status !== undefined) { fields.push("status = ?"); values.push(updates.status); }
|
||||
if (updates.tags !== undefined) { fields.push("tags = ?"); values.push(JSON.stringify(updates.tags)); }
|
||||
|
||||
if (fields.length === 0) return;
|
||||
|
||||
fields.push("updated_at = ?");
|
||||
values.push(now);
|
||||
values.push(id);
|
||||
|
||||
await db.runAsync(
|
||||
`UPDATE incidents SET ${fields.join(", ")} WHERE id = ?`,
|
||||
values
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteIncident(id: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
await db.runAsync("DELETE FROM incidents WHERE id = ?", [id]);
|
||||
}
|
||||
|
||||
export async function getDistinctServices(): Promise<string[]> {
|
||||
const db = await getDb();
|
||||
const rows = await db.getAllAsync<{ service: string }>(
|
||||
"SELECT DISTINCT service FROM incidents WHERE service != '' ORDER BY service"
|
||||
);
|
||||
return rows.map((r) => r.service);
|
||||
}
|
||||
|
||||
// --- UUID v4 (no external dep) ---
|
||||
|
||||
function generateId(): string {
|
||||
const hex = "0123456789abcdef";
|
||||
let uuid = "";
|
||||
for (let i = 0; i < 36; i++) {
|
||||
if (i === 8 || i === 13 || i === 18 || i === 23) {
|
||||
uuid += "-";
|
||||
} else if (i === 14) {
|
||||
uuid += "4";
|
||||
} else if (i === 19) {
|
||||
uuid += hex[(Math.random() * 4) | 8];
|
||||
} else {
|
||||
uuid += hex[(Math.random() * 16) | 0];
|
||||
}
|
||||
}
|
||||
return uuid;
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { incidentToFilename, renderMarkdown } from "./markdown";
|
||||
import type { Incident } from "@/types/incident";
|
||||
|
||||
export interface GiteaConfig {
|
||||
url: string;
|
||||
token: string;
|
||||
owner: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
export interface PushResult {
|
||||
success: boolean;
|
||||
url?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function pushIncidentToGitea(
|
||||
incident: Incident,
|
||||
config: GiteaConfig,
|
||||
markdownTemplate?: string
|
||||
): Promise<PushResult> {
|
||||
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 {
|
||||
// Check if file already exists to get its SHA (required for updates)
|
||||
const existingResponse = await fetch(endpoint, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
});
|
||||
|
||||
let sha: string | undefined;
|
||||
if (existingResponse.ok) {
|
||||
const existing = await existingResponse.json() as { sha?: string };
|
||||
sha = existing.sha;
|
||||
}
|
||||
|
||||
const body: Record<string, string> = {
|
||||
message: `incident: ${incident.title}`,
|
||||
content: base64Content,
|
||||
};
|
||||
if (sha) body.sha = sha;
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${config.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const err = await response.text();
|
||||
return { success: false, error: `HTTP ${response.status}: ${err}` };
|
||||
}
|
||||
|
||||
const data = await response.json() as { content?: { html_url?: string } };
|
||||
return {
|
||||
success: true,
|
||||
url: data.content?.html_url,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { Incident } from "@/types/incident";
|
||||
|
||||
const DEFAULT_TEMPLATE = `# [TITRE]
|
||||
|
||||
**Date**: [DATE ISO]
|
||||
**Service**: [SERVICE]
|
||||
**Status**: [STATUS]
|
||||
**Tags**: [TAGS]
|
||||
|
||||
## Symptom
|
||||
[SYMPTOM]
|
||||
|
||||
## Root Cause
|
||||
[ROOT_CAUSE]
|
||||
|
||||
## Fix Applied
|
||||
[FIX]
|
||||
`;
|
||||
|
||||
export function renderMarkdown(
|
||||
incident: Incident,
|
||||
template: string = DEFAULT_TEMPLATE
|
||||
): string {
|
||||
const tagList =
|
||||
incident.tags.length > 0 ? incident.tags.join(", ") : "—";
|
||||
|
||||
return template
|
||||
.replace("[TITRE]", incident.title || "Untitled")
|
||||
.replace("[DATE ISO]", incident.createdAt)
|
||||
.replace("[SERVICE]", incident.service || "—")
|
||||
.replace("[STATUS]", incident.status)
|
||||
.replace("[TAGS]", tagList)
|
||||
.replace("[SYMPTOM]", incident.symptom || "—")
|
||||
.replace("[ROOT_CAUSE]", incident.rootCause || "—")
|
||||
.replace("[FIX]", incident.fix || "—");
|
||||
}
|
||||
|
||||
export function incidentToFilename(incident: Incident): string {
|
||||
const date = incident.createdAt.slice(0, 10);
|
||||
const slug = incident.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 50);
|
||||
return `incidents/${date}-${slug || "incident"}.md`;
|
||||
}
|
||||
|
||||
export { DEFAULT_TEMPLATE };
|
||||
Reference in New Issue
Block a user