Reformat src/*.mjs with Prettier; add format/format:check scripts
Every route handler was written as one dense semicolon-chained line (server.mjs's main handler was over 2000 characters wide) — real width, not a VSCode wrapping artifact. Ran Prettier to break statement structure into properly indented multi-line code; left template literal string contents untouched since Prettier doesn't reformat inside those, so the large HTML-generating return strings are still single long lines by nature, just no longer compounded by unformatted surrounding JS. Verified: tests pass, node --check passes, and the app renders identically before/after (screenshotted the diff). No devDependency added — npx prettier@3.9.5 pinned in the new scripts keeps the zero-runtime-dependency pledge intact; formatting is a dev- time concern only. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
cdcc3c7937
commit
4b7b5a42d3
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"printWidth": 100,
|
||||||
|
"singleQuote": true
|
||||||
|
}
|
||||||
+1
-1
@@ -1 +1 @@
|
|||||||
{"name":"trace","version":"0.1.0","private":true,"type":"module","scripts":{"dev":"node --watch --env-file-if-exists=.env src/server.mjs","start":"node --env-file-if-exists=.env src/server.mjs","test":"node --test","build":"node --check src/server.mjs"},"engines":{"node":">=24"}}
|
{"name":"trace","version":"0.1.0","private":true,"type":"module","scripts":{"dev":"node --watch --env-file-if-exists=.env src/server.mjs","start":"node --env-file-if-exists=.env src/server.mjs","test":"node --test","build":"node --check src/server.mjs","format":"npx --yes prettier@3.9.5 --write \"src/*.mjs\" \"test/*.mjs\"","format:check":"npx --yes prettier@3.9.5 --check \"src/*.mjs\" \"test/*.mjs\""},"engines":{"node":">=24"}}
|
||||||
|
|||||||
+70
-8
@@ -1,9 +1,71 @@
|
|||||||
const FIX_WORDS=/\b(fix(?:e[ds])?|bug|broken|race|crash|regression|rollback|restore[ds]?|duplicate[ds]?|auth|fail(?:ed|ure|ing)?)\b/i;
|
const FIX_WORDS =
|
||||||
const validRepo=value=>/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/i.test(value);
|
/\b(fix(?:e[ds])?|bug|broken|race|crash|regression|rollback|restore[ds]?|duplicate[ds]?|auth|fail(?:ed|ure|ing)?)\b/i;
|
||||||
export async function scanGiteaCandidates({baseUrl,repositories,token='',limit=100,fetchImpl=fetch}){
|
const validRepo = (value) => /^[a-z0-9_.-]+\/[a-z0-9_.-]+$/i.test(value);
|
||||||
const candidates=[],errors=[],headers={accept:'application/json'};if(token)headers.authorization=`token ${token}`;
|
export async function scanGiteaCandidates({
|
||||||
for(const repo of repositories){if(!validRepo(repo)){errors.push({repo,error:'invalid owner/repository identifier'});continue}const [owner,name]=repo.split('/');let seen=0;
|
baseUrl,
|
||||||
try{for(let page=1;seen<limit;page++){const count=Math.min(50,limit-seen),url=`${baseUrl.replace(/\/$/,'')}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/commits?limit=${count}&page=${page}`;const response=await fetchImpl(url,{headers,signal:AbortSignal.timeout(5000)});if(!response.ok)throw new Error(`Gitea returned ${response.status}`);const commits=await response.json();if(!Array.isArray(commits))throw new Error('unexpected Gitea response');for(const commit of commits){seen++;const subject=String(commit.commit?.message||'').split('\n')[0].trim(),match=subject.match(FIX_WORDS);if(!match)continue;let paths='';try{const detail=await fetchImpl(`${baseUrl.replace(/\/$/,'')}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/commits/${encodeURIComponent(commit.sha)}`,{headers,signal:AbortSignal.timeout(5000)});if(detail.ok){const data=await detail.json();paths=(data.files||[]).map(file=>file.filename).filter(Boolean).join('\n')}}catch{}candidates.push({repo,sha:commit.sha,committedAt:commit.commit?.committer?.date||commit.commit?.author?.date||commit.created||new Date().toISOString(),subject,paths,reason:`Gitea subject keyword: ${match[0].toLowerCase()}`})}if(commits.length<count)break}}
|
repositories,
|
||||||
catch(error){errors.push({repo,error:error.message})}
|
token = '',
|
||||||
}return{candidates,errors};
|
limit = 100,
|
||||||
|
fetchImpl = fetch,
|
||||||
|
}) {
|
||||||
|
const candidates = [],
|
||||||
|
errors = [],
|
||||||
|
headers = { accept: 'application/json' };
|
||||||
|
if (token) headers.authorization = `token ${token}`;
|
||||||
|
for (const repo of repositories) {
|
||||||
|
if (!validRepo(repo)) {
|
||||||
|
errors.push({ repo, error: 'invalid owner/repository identifier' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const [owner, name] = repo.split('/');
|
||||||
|
let seen = 0;
|
||||||
|
try {
|
||||||
|
for (let page = 1; seen < limit; page++) {
|
||||||
|
const count = Math.min(50, limit - seen),
|
||||||
|
url = `${baseUrl.replace(/\/$/, '')}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/commits?limit=${count}&page=${page}`;
|
||||||
|
const response = await fetchImpl(url, { headers, signal: AbortSignal.timeout(5000) });
|
||||||
|
if (!response.ok) throw new Error(`Gitea returned ${response.status}`);
|
||||||
|
const commits = await response.json();
|
||||||
|
if (!Array.isArray(commits)) throw new Error('unexpected Gitea response');
|
||||||
|
for (const commit of commits) {
|
||||||
|
seen++;
|
||||||
|
const subject = String(commit.commit?.message || '')
|
||||||
|
.split('\n')[0]
|
||||||
|
.trim(),
|
||||||
|
match = subject.match(FIX_WORDS);
|
||||||
|
if (!match) continue;
|
||||||
|
let paths = '';
|
||||||
|
try {
|
||||||
|
const detail = await fetchImpl(
|
||||||
|
`${baseUrl.replace(/\/$/, '')}/api/v1/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/git/commits/${encodeURIComponent(commit.sha)}`,
|
||||||
|
{ headers, signal: AbortSignal.timeout(5000) },
|
||||||
|
);
|
||||||
|
if (detail.ok) {
|
||||||
|
const data = await detail.json();
|
||||||
|
paths = (data.files || [])
|
||||||
|
.map((file) => file.filename)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
candidates.push({
|
||||||
|
repo,
|
||||||
|
sha: commit.sha,
|
||||||
|
committedAt:
|
||||||
|
commit.commit?.committer?.date ||
|
||||||
|
commit.commit?.author?.date ||
|
||||||
|
commit.created ||
|
||||||
|
new Date().toISOString(),
|
||||||
|
subject,
|
||||||
|
paths,
|
||||||
|
reason: `Gitea subject keyword: ${match[0].toLowerCase()}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (commits.length < count) break;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({ repo, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { candidates, errors };
|
||||||
}
|
}
|
||||||
|
|||||||
+153
-13
@@ -1,19 +1,159 @@
|
|||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
const RESULTS=new Set(['success','failed','cancelled','unknown']), TYPES=new Set(['build','deployment','release','rollback']);
|
const RESULTS = new Set(['success', 'failed', 'cancelled', 'unknown']),
|
||||||
const send=(res,status,value)=>res.writeHead(status,{'content-type':'application/json; charset=utf-8','cache-control':status===200?'public, max-age=30':'no-store'}).end(JSON.stringify(value));
|
TYPES = new Set(['build', 'deployment', 'release', 'rollback']);
|
||||||
export function validateReceipt(v){const e=[];if(!v||typeof v!=='object'||Array.isArray(v))return['body must be an object'];if(v.schemaVersion!==1)e.push('schemaVersion must be 1');if(!TYPES.has(v.eventType))e.push('unsupported eventType');for(const f of ['eventId','project','occurredAt'])if(typeof v[f]!=='string'||!v[f].trim())e.push(`${f} is required`);if(v.occurredAt&&Number.isNaN(Date.parse(v.occurredAt)))e.push('occurredAt must be ISO-8601');if(!RESULTS.has(v.result))e.push('unsupported result');if(!v.source||typeof v.source.commit!=='string'||!/^[a-f0-9]{7,64}$/i.test(v.source.commit))e.push('source.commit must be a commit SHA');if(v.artifacts&&!Array.isArray(v.artifacts))e.push('artifacts must be an array');return e;}
|
const send = (res, status, value) =>
|
||||||
|
res
|
||||||
|
.writeHead(status, {
|
||||||
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
'cache-control': status === 200 ? 'public, max-age=30' : 'no-store',
|
||||||
|
})
|
||||||
|
.end(JSON.stringify(value));
|
||||||
|
export function validateReceipt(v) {
|
||||||
|
const e = [];
|
||||||
|
if (!v || typeof v !== 'object' || Array.isArray(v)) return ['body must be an object'];
|
||||||
|
if (v.schemaVersion !== 1) e.push('schemaVersion must be 1');
|
||||||
|
if (!TYPES.has(v.eventType)) e.push('unsupported eventType');
|
||||||
|
for (const f of ['eventId', 'project', 'occurredAt'])
|
||||||
|
if (typeof v[f] !== 'string' || !v[f].trim()) e.push(`${f} is required`);
|
||||||
|
if (v.occurredAt && Number.isNaN(Date.parse(v.occurredAt))) e.push('occurredAt must be ISO-8601');
|
||||||
|
if (!RESULTS.has(v.result)) e.push('unsupported result');
|
||||||
|
if (
|
||||||
|
!v.source ||
|
||||||
|
typeof v.source.commit !== 'string' ||
|
||||||
|
!/^[a-f0-9]{7,64}$/i.test(v.source.commit)
|
||||||
|
)
|
||||||
|
e.push('source.commit must be a commit SHA');
|
||||||
|
if (v.artifacts && !Array.isArray(v.artifacts)) e.push('artifacts must be an array');
|
||||||
|
return e;
|
||||||
|
}
|
||||||
export function createReceiptStore(db, config = '') {
|
export function createReceiptStore(db, config = '') {
|
||||||
db.exec(`CREATE TABLE IF NOT EXISTS deployment_events(id INTEGER PRIMARY KEY AUTOINCREMENT,event_id TEXT NOT NULL UNIQUE,schema_version INTEGER NOT NULL,event_type TEXT NOT NULL,project TEXT NOT NULL,environment TEXT NOT NULL DEFAULT '',source_commit TEXT NOT NULL,source_branch TEXT NOT NULL DEFAULT '',result TEXT NOT NULL,health_result TEXT NOT NULL DEFAULT '',occurred_at TEXT NOT NULL,received_at TEXT NOT NULL,previous_event_id TEXT NOT NULL DEFAULT '',payload TEXT NOT NULL);CREATE INDEX IF NOT EXISTS deployment_events_project_time ON deployment_events(project,occurred_at DESC);`);
|
db.exec(
|
||||||
const tokens=new Map(config.split(',').filter(Boolean).map(p=>{const i=p.indexOf(':');return[p.slice(i+1),p.slice(0,i)]}));
|
`CREATE TABLE IF NOT EXISTS deployment_events(id INTEGER PRIMARY KEY AUTOINCREMENT,event_id TEXT NOT NULL UNIQUE,schema_version INTEGER NOT NULL,event_type TEXT NOT NULL,project TEXT NOT NULL,environment TEXT NOT NULL DEFAULT '',source_commit TEXT NOT NULL,source_branch TEXT NOT NULL DEFAULT '',result TEXT NOT NULL,health_result TEXT NOT NULL DEFAULT '',occurred_at TEXT NOT NULL,received_at TEXT NOT NULL,previous_event_id TEXT NOT NULL DEFAULT '',payload TEXT NOT NULL);CREATE INDEX IF NOT EXISTS deployment_events_project_time ON deployment_events(project,occurred_at DESC);`,
|
||||||
const authenticated=(req,project)=>{const token=req.headers.authorization?.replace(/^Bearer\s+/i,'');if(!token)return false;for(const[candidate,allowed]of tokens)if(candidate.length===token.length&&crypto.timingSafeEqual(Buffer.from(candidate),Buffer.from(token))&&allowed===project)return true;return false};
|
);
|
||||||
const publicEvent=row=>{if(!row)return null;const p=JSON.parse(row.payload);return{schemaVersion:row.schema_version,eventId:row.event_id,eventType:row.event_type,project:row.project,environment:row.environment||undefined,commit:row.source_commit,branch:row.source_branch||undefined,result:row.result,health:row.health_result||undefined,occurredAt:row.occurred_at,artifacts:(p.artifacts||[]).map(x=>({component:x.component,digest:x.digest?.slice(0,19)}))}};
|
const tokens = new Map(
|
||||||
|
config
|
||||||
|
.split(',')
|
||||||
|
.filter(Boolean)
|
||||||
|
.map((p) => {
|
||||||
|
const i = p.indexOf(':');
|
||||||
|
return [p.slice(i + 1), p.slice(0, i)];
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const authenticated = (req, project) => {
|
||||||
|
const token = req.headers.authorization?.replace(/^Bearer\s+/i, '');
|
||||||
|
if (!token) return false;
|
||||||
|
for (const [candidate, allowed] of tokens)
|
||||||
|
if (
|
||||||
|
candidate.length === token.length &&
|
||||||
|
crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(token)) &&
|
||||||
|
allowed === project
|
||||||
|
)
|
||||||
|
return true;
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
const publicEvent = (row) => {
|
||||||
|
if (!row) return null;
|
||||||
|
const p = JSON.parse(row.payload);
|
||||||
|
return {
|
||||||
|
schemaVersion: row.schema_version,
|
||||||
|
eventId: row.event_id,
|
||||||
|
eventType: row.event_type,
|
||||||
|
project: row.project,
|
||||||
|
environment: row.environment || undefined,
|
||||||
|
commit: row.source_commit,
|
||||||
|
branch: row.source_branch || undefined,
|
||||||
|
result: row.result,
|
||||||
|
health: row.health_result || undefined,
|
||||||
|
occurredAt: row.occurred_at,
|
||||||
|
artifacts: (p.artifacts || []).map((x) => ({
|
||||||
|
component: x.component,
|
||||||
|
digest: x.digest?.slice(0, 19),
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
};
|
||||||
async function handle(req, res, path) {
|
async function handle(req, res, path) {
|
||||||
if (path === '/api/health' && req.method === 'GET') return send(res, 200, { ok: true });
|
if (path === '/api/health' && req.method === 'GET') return send(res, 200, { ok: true });
|
||||||
if(path==='/api/v1/events'&&req.method==='POST'){let raw='';for await(const c of req){raw+=c;if(raw.length>131072)return send(res,413,{error:'receipt too large'})}let v;try{v=JSON.parse(raw)}catch{return send(res,400,{error:'invalid JSON'})}const errors=validateReceipt(v);if(errors.length)return send(res,422,{error:'invalid receipt',details:errors});if(!authenticated(req,v.project))return send(res,401,{error:'invalid receipt credential'});if(db.prepare('SELECT event_id FROM deployment_events WHERE event_id=?').get(v.eventId))return send(res,200,{ok:true,eventId:v.eventId,duplicate:true});db.prepare(`INSERT INTO deployment_events(event_id,schema_version,event_type,project,environment,source_commit,source_branch,result,health_result,occurred_at,received_at,previous_event_id,payload) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(v.eventId,1,v.eventType,v.project,v.environment||'',v.source.commit,v.source.branch||'',v.result,v.verification?.health||'',v.occurredAt,new Date().toISOString(),v.previousEventId||'',raw);return send(res,201,{ok:true,eventId:v.eventId});}
|
if (path === '/api/v1/events' && req.method === 'POST') {
|
||||||
const m=path.match(/^\/api\/v1\/public\/projects\/([a-z0-9-]+)\/latest$/);if(m&&req.method==='GET'){const row=db.prepare("SELECT * FROM deployment_events WHERE project=? AND event_type='deployment' ORDER BY occurred_at DESC LIMIT 1").get(m[1]);return send(res,200,{project:m[1],deployment:publicEvent(row)})}
|
let raw = '';
|
||||||
if(path==='/api/v1/public/projects'&&req.method==='GET'){const rows=db.prepare(`SELECT e.* FROM deployment_events e JOIN (SELECT project,MAX(occurred_at) occurred_at FROM deployment_events WHERE event_type='deployment' GROUP BY project) l ON l.project=e.project AND l.occurred_at=e.occurred_at ORDER BY e.project`).all();return send(res,200,{projects:rows.map(publicEvent)})}return false;
|
for await (const c of req) {
|
||||||
}return{handle,publicEvent};
|
raw += c;
|
||||||
|
if (raw.length > 131072) return send(res, 413, { error: 'receipt too large' });
|
||||||
|
}
|
||||||
|
let v;
|
||||||
|
try {
|
||||||
|
v = JSON.parse(raw);
|
||||||
|
} catch {
|
||||||
|
return send(res, 400, { error: 'invalid JSON' });
|
||||||
|
}
|
||||||
|
const errors = validateReceipt(v);
|
||||||
|
if (errors.length) return send(res, 422, { error: 'invalid receipt', details: errors });
|
||||||
|
if (!authenticated(req, v.project))
|
||||||
|
return send(res, 401, { error: 'invalid receipt credential' });
|
||||||
|
if (db.prepare('SELECT event_id FROM deployment_events WHERE event_id=?').get(v.eventId))
|
||||||
|
return send(res, 200, { ok: true, eventId: v.eventId, duplicate: true });
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO deployment_events(event_id,schema_version,event_type,project,environment,source_commit,source_branch,result,health_result,occurred_at,received_at,previous_event_id,payload) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||||
|
).run(
|
||||||
|
v.eventId,
|
||||||
|
1,
|
||||||
|
v.eventType,
|
||||||
|
v.project,
|
||||||
|
v.environment || '',
|
||||||
|
v.source.commit,
|
||||||
|
v.source.branch || '',
|
||||||
|
v.result,
|
||||||
|
v.verification?.health || '',
|
||||||
|
v.occurredAt,
|
||||||
|
new Date().toISOString(),
|
||||||
|
v.previousEventId || '',
|
||||||
|
raw,
|
||||||
|
);
|
||||||
|
return send(res, 201, { ok: true, eventId: v.eventId });
|
||||||
|
}
|
||||||
|
const m = path.match(/^\/api\/v1\/public\/projects\/([a-z0-9-]+)\/latest$/);
|
||||||
|
if (m && req.method === 'GET') {
|
||||||
|
const row = db
|
||||||
|
.prepare(
|
||||||
|
"SELECT * FROM deployment_events WHERE project=? AND event_type='deployment' ORDER BY occurred_at DESC LIMIT 1",
|
||||||
|
)
|
||||||
|
.get(m[1]);
|
||||||
|
return send(res, 200, { project: m[1], deployment: publicEvent(row) });
|
||||||
|
}
|
||||||
|
if (path === '/api/v1/public/projects' && req.method === 'GET') {
|
||||||
|
const rows = db
|
||||||
|
.prepare(
|
||||||
|
`SELECT e.* FROM deployment_events e JOIN (SELECT project,MAX(occurred_at) occurred_at FROM deployment_events WHERE event_type='deployment' GROUP BY project) l ON l.project=e.project AND l.occurred_at=e.occurred_at ORDER BY e.project`,
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
return send(res, 200, { projects: rows.map(publicEvent) });
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return { handle, publicEvent };
|
||||||
}
|
}
|
||||||
|
|
||||||
const escapeHtml=value=>String(value??'').replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"');
|
const escapeHtml = (value) =>
|
||||||
export function renderEventsPage(db){const rows=db.prepare('SELECT * FROM deployment_events ORDER BY occurred_at DESC LIMIT 100').all();const counts=db.prepare('SELECT event_type,result,count(*) count FROM deployment_events GROUP BY event_type,result ORDER BY event_type,result').all();return`<!doctype html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width"><title>operational events · trace</title><style>:root{--b:#0d1117;--p:#161b22;--l:#30363d;--t:#c9d1d9;--m:#8b949e;--a:#58a6ff;--ok:#3fb950;--bad:#f85149}*{box-sizing:border-box}body{margin:0;background:var(--b);color:var(--t);font:14px/1.55 ui-monospace,monospace}header,main{max-width:1050px;margin:auto;padding:20px}header{display:flex;justify-content:space-between;border-bottom:1px solid var(--l)}a{color:var(--a);text-decoration:none}nav{display:flex;gap:18px}.summary{display:flex;gap:8px;flex-wrap:wrap}.badge{border:1px solid var(--l);padding:3px 8px;border-radius:99px;color:var(--m)}.event{background:var(--p);border:1px solid var(--l);border-left:3px solid var(--a);padding:16px;border-radius:8px;margin:10px 0}.event.failed{border-left-color:var(--bad)}.event.success{border-left-color:var(--ok)}.top{display:flex;justify-content:space-between;gap:12px;align-items:start}.meta{color:var(--m);font-size:12px}.result.success{color:var(--ok)}.result.failed{color:var(--bad)}code,pre{background:var(--b);border:1px solid var(--l);border-radius:5px}code{padding:2px 5px}pre{padding:12px;overflow:auto;max-height:360px;white-space:pre-wrap}details{margin-top:10px}h1{margin-bottom:5px}@media(max-width:600px){.top{display:block}}</style></head><body><header><a href=/><b>trace</b></a><nav><a href=/incidents/new>new incident</a><a href=/candidates>git inbox</a><a href=/events>operational events</a></nav></header><main><h1>Operational events</h1><p class=meta>Builds say what CI produced. Deployments say what a host actually ran.</p><div class=summary>${counts.map(x=>`<span class=badge>${escapeHtml(x.event_type)} · ${escapeHtml(x.result)}: ${x.count}</span>`).join('')}</div>${rows.map(row=>{const payload=JSON.parse(row.payload),artifacts=payload.artifacts||[];return`<article class="event ${escapeHtml(row.result)}"><div class=top><div><b>${escapeHtml(row.project)} · ${escapeHtml(row.event_type)}</b><p class=meta><code>${escapeHtml(row.source_commit.slice(0,12))}</code> ${escapeHtml(row.source_branch)} ${row.environment?`· ${escapeHtml(row.environment)}`:''}</p></div><span class="result ${escapeHtml(row.result)}">${escapeHtml(row.result)}</span></div><p class=meta>${escapeHtml(row.occurred_at)} · received ${escapeHtml(row.received_at)}</p>${artifacts.map(x=>`<p><b>${escapeHtml(x.component)}</b> <code>${escapeHtml(x.digest||'digest unavailable')}</code></p>`).join('')}<details><summary>raw private receipt</summary><pre>${escapeHtml(JSON.stringify(payload,null,2))}</pre></details></article>`}).join('')||'<p>No operational events received yet.</p>'}</main></body></html>`}
|
String(value ?? '')
|
||||||
|
.replaceAll('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"');
|
||||||
|
export function renderEventsPage(db) {
|
||||||
|
const rows = db
|
||||||
|
.prepare('SELECT * FROM deployment_events ORDER BY occurred_at DESC LIMIT 100')
|
||||||
|
.all();
|
||||||
|
const counts = db
|
||||||
|
.prepare(
|
||||||
|
'SELECT event_type,result,count(*) count FROM deployment_events GROUP BY event_type,result ORDER BY event_type,result',
|
||||||
|
)
|
||||||
|
.all();
|
||||||
|
return `<!doctype html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width"><title>operational events · trace</title><style>:root{--b:#0d1117;--p:#161b22;--l:#30363d;--t:#c9d1d9;--m:#8b949e;--a:#58a6ff;--ok:#3fb950;--bad:#f85149}*{box-sizing:border-box}body{margin:0;background:var(--b);color:var(--t);font:14px/1.55 ui-monospace,monospace}header,main{max-width:1050px;margin:auto;padding:20px}header{display:flex;justify-content:space-between;border-bottom:1px solid var(--l)}a{color:var(--a);text-decoration:none}nav{display:flex;gap:18px}.summary{display:flex;gap:8px;flex-wrap:wrap}.badge{border:1px solid var(--l);padding:3px 8px;border-radius:99px;color:var(--m)}.event{background:var(--p);border:1px solid var(--l);border-left:3px solid var(--a);padding:16px;border-radius:8px;margin:10px 0}.event.failed{border-left-color:var(--bad)}.event.success{border-left-color:var(--ok)}.top{display:flex;justify-content:space-between;gap:12px;align-items:start}.meta{color:var(--m);font-size:12px}.result.success{color:var(--ok)}.result.failed{color:var(--bad)}code,pre{background:var(--b);border:1px solid var(--l);border-radius:5px}code{padding:2px 5px}pre{padding:12px;overflow:auto;max-height:360px;white-space:pre-wrap}details{margin-top:10px}h1{margin-bottom:5px}@media(max-width:600px){.top{display:block}}</style></head><body><header><a href=/><b>trace</b></a><nav><a href=/incidents/new>new incident</a><a href=/candidates>git inbox</a><a href=/events>operational events</a></nav></header><main><h1>Operational events</h1><p class=meta>Builds say what CI produced. Deployments say what a host actually ran.</p><div class=summary>${counts.map((x) => `<span class=badge>${escapeHtml(x.event_type)} · ${escapeHtml(x.result)}: ${x.count}</span>`).join('')}</div>${
|
||||||
|
rows
|
||||||
|
.map((row) => {
|
||||||
|
const payload = JSON.parse(row.payload),
|
||||||
|
artifacts = payload.artifacts || [];
|
||||||
|
return `<article class="event ${escapeHtml(row.result)}"><div class=top><div><b>${escapeHtml(row.project)} · ${escapeHtml(row.event_type)}</b><p class=meta><code>${escapeHtml(row.source_commit.slice(0, 12))}</code> ${escapeHtml(row.source_branch)} ${row.environment ? `· ${escapeHtml(row.environment)}` : ''}</p></div><span class="result ${escapeHtml(row.result)}">${escapeHtml(row.result)}</span></div><p class=meta>${escapeHtml(row.occurred_at)} · received ${escapeHtml(row.received_at)}</p>${artifacts.map((x) => `<p><b>${escapeHtml(x.component)}</b> <code>${escapeHtml(x.digest || 'digest unavailable')}</code></p>`).join('')}<details><summary>raw private receipt</summary><pre>${escapeHtml(JSON.stringify(payload, null, 2))}</pre></details></article>`;
|
||||||
|
})
|
||||||
|
.join('') || '<p>No operational events received yet.</p>'
|
||||||
|
}</main></body></html>`;
|
||||||
|
}
|
||||||
|
|||||||
+446
-39
@@ -8,8 +8,11 @@ import crypto from 'node:crypto';
|
|||||||
import { createReceiptStore, renderEventsPage } from './receipts.mjs';
|
import { createReceiptStore, renderEventsPage } from './receipts.mjs';
|
||||||
import { scanGiteaCandidates } from './gitea.mjs';
|
import { scanGiteaCandidates } from './gitea.mjs';
|
||||||
|
|
||||||
const exec = promisify(execFile), port = Number(process.env.PORT || 3082), password = process.env.TRACE_PASSWORD || '';
|
const exec = promisify(execFile),
|
||||||
mkdirSync('data', { recursive: true }); mkdirSync('data/exports', { recursive: true });
|
port = Number(process.env.PORT || 3082),
|
||||||
|
password = process.env.TRACE_PASSWORD || '';
|
||||||
|
mkdirSync('data', { recursive: true });
|
||||||
|
mkdirSync('data/exports', { recursive: true });
|
||||||
const db = new DatabaseSync('data/trace.db');
|
const db = new DatabaseSync('data/trace.db');
|
||||||
db.exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;
|
db.exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON;
|
||||||
CREATE TABLE IF NOT EXISTS incidents(id TEXT PRIMARY KEY,title TEXT NOT NULL,project TEXT,status TEXT NOT NULL DEFAULT 'investigating',severity TEXT NOT NULL DEFAULT 'annoyance',detected_at TEXT NOT NULL,symptom TEXT NOT NULL,impact TEXT DEFAULT '',root_cause TEXT DEFAULT '',confidence TEXT DEFAULT 'unknown',fix TEXT DEFAULT '',verification TEXT DEFAULT '',prevention TEXT DEFAULT '',remaining_risk TEXT DEFAULT '',lesson TEXT DEFAULT '',public_summary TEXT DEFAULT '',created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
|
CREATE TABLE IF NOT EXISTS incidents(id TEXT PRIMARY KEY,title TEXT NOT NULL,project TEXT,status TEXT NOT NULL DEFAULT 'investigating',severity TEXT NOT NULL DEFAULT 'annoyance',detected_at TEXT NOT NULL,symptom TEXT NOT NULL,impact TEXT DEFAULT '',root_cause TEXT DEFAULT '',confidence TEXT DEFAULT 'unknown',fix TEXT DEFAULT '',verification TEXT DEFAULT '',prevention TEXT DEFAULT '',remaining_risk TEXT DEFAULT '',lesson TEXT DEFAULT '',public_summary TEXT DEFAULT '',created_at TEXT NOT NULL,updated_at TEXT NOT NULL);
|
||||||
@@ -17,53 +20,457 @@ CREATE TABLE IF NOT EXISTS timeline(id INTEGER PRIMARY KEY AUTOINCREMENT,inciden
|
|||||||
CREATE TABLE IF NOT EXISTS hypotheses(id INTEGER PRIMARY KEY AUTOINCREMENT,incident_id TEXT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,statement TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'untested',evidence TEXT DEFAULT '');
|
CREATE TABLE IF NOT EXISTS hypotheses(id INTEGER PRIMARY KEY AUTOINCREMENT,incident_id TEXT NOT NULL REFERENCES incidents(id) ON DELETE CASCADE,statement TEXT NOT NULL,status TEXT NOT NULL DEFAULT 'untested',evidence TEXT DEFAULT '');
|
||||||
CREATE TABLE IF NOT EXISTS candidates(id INTEGER PRIMARY KEY AUTOINCREMENT,repo TEXT NOT NULL,sha TEXT NOT NULL,committed_at TEXT NOT NULL,subject TEXT NOT NULL,paths TEXT DEFAULT '',reason TEXT NOT NULL,state TEXT NOT NULL DEFAULT 'new',incident_id TEXT,UNIQUE(repo,sha));
|
CREATE TABLE IF NOT EXISTS candidates(id INTEGER PRIMARY KEY AUTOINCREMENT,repo TEXT NOT NULL,sha TEXT NOT NULL,committed_at TEXT NOT NULL,subject TEXT NOT NULL,paths TEXT DEFAULT '',reason TEXT NOT NULL,state TEXT NOT NULL DEFAULT 'new',incident_id TEXT,UNIQUE(repo,sha));
|
||||||
CREATE TABLE IF NOT EXISTS receipts(id INTEGER PRIMARY KEY AUTOINCREMENT,project TEXT NOT NULL,commit_sha TEXT NOT NULL,deployed_at TEXT NOT NULL,environment TEXT DEFAULT '',health TEXT DEFAULT '',payload TEXT NOT NULL,UNIQUE(project,commit_sha,deployed_at));`);
|
CREATE TABLE IF NOT EXISTS receipts(id INTEGER PRIMARY KEY AUTOINCREMENT,project TEXT NOT NULL,commit_sha TEXT NOT NULL,deployed_at TEXT NOT NULL,environment TEXT DEFAULT '',health TEXT DEFAULT '',payload TEXT NOT NULL,UNIQUE(project,commit_sha,deployed_at));`);
|
||||||
for(const sql of ["ALTER TABLE incidents ADD COLUMN published_at TEXT DEFAULT ''","ALTER TABLE incidents ADD COLUMN public_slug TEXT DEFAULT ''"]){try{db.exec(sql)}catch(error){if(!String(error.message).includes('duplicate column'))throw error;}}
|
for (const sql of [
|
||||||
|
"ALTER TABLE incidents ADD COLUMN published_at TEXT DEFAULT ''",
|
||||||
|
"ALTER TABLE incidents ADD COLUMN public_slug TEXT DEFAULT ''",
|
||||||
|
]) {
|
||||||
|
try {
|
||||||
|
db.exec(sql);
|
||||||
|
} catch (error) {
|
||||||
|
if (!String(error.message).includes('duplicate column')) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
const receiptStore = createReceiptStore(db, process.env.TRACE_RECEIPT_TOKENS || '');
|
const receiptStore = createReceiptStore(db, process.env.TRACE_RECEIPT_TOKENS || '');
|
||||||
const repos = new Map((process.env.TRACE_REPOS || '').split(',').filter(Boolean).map(x => { const i=x.indexOf(':'); return [x.slice(0,i),x.slice(i+1)]; }));
|
const repos = new Map(
|
||||||
const giteaRepos=(process.env.TRACE_GITEA_REPOS||'').split(',').map(x=>x.trim()).filter(Boolean),giteaUrl=process.env.TRACE_GITEA_URL||'https://repo.explewd.com',giteaToken=process.env.TRACE_GITEA_TOKEN||'';
|
(process.env.TRACE_REPOS || '')
|
||||||
const esc=s=>String(s??'').replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"');
|
.split(',')
|
||||||
const form=async req=>{let b='';for await(const c of req)b+=c;return Object.fromEntries(new URLSearchParams(b));};
|
.filter(Boolean)
|
||||||
function auth(req,res){if(!password)return true;const expected='Basic '+Buffer.from('trace:'+password).toString('base64');if(req.headers.authorization===expected)return true;res.writeHead(401,{'WWW-Authenticate':'Basic realm="trace"'}).end('Authentication required');return false;}
|
.map((x) => {
|
||||||
function layout(title,body){return `<!doctype html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width"><title>${esc(title)} · trace</title><link rel=stylesheet href=/style.css></head><body><header><a href=/><b>trace</b></a><nav><a href=/incidents/new>new incident</a><a href=/candidates>git inbox</a><a href=/events>operational events</a></nav></header><main>${body}</main><footer>Private by default. Git discovers; a human explains.</footer><script>const incident=location.pathname.match(/^\\/incidents\\/([^/]+)$/);if(incident){document.querySelector('form[action$="/export"]')?.remove();const controls=document.createElement('div');controls.className='actions publication-actions';controls.innerHTML='<form method="post" action="/incidents/'+incident[1]+'/publish"><button>publish / update reviewed article</button></form><form method="post" action="/incidents/'+incident[1]+'/unpublish"><button>unpublish</button></form>';document.querySelector('main h1')?.after(controls)}</script></body></html>`;}
|
const i = x.indexOf(':');
|
||||||
const fields=['impact','root_cause','fix','verification','prevention','remaining_risk','lesson','public_summary'];
|
return [x.slice(0, i), x.slice(i + 1)];
|
||||||
const slugify=s=>String(s||'failure').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'')||'failure';
|
}),
|
||||||
const publicSeverity={annoyance:'paper-cut',degraded:'minor',unavailable:'significant','data-risk':'significant','security-risk':'significant'};
|
);
|
||||||
const publicStatus={investigating:'open',mitigated:'worked-around',resolved:'resolved',abandoned:'abandoned'};
|
const giteaRepos = (process.env.TRACE_GITEA_REPOS || '')
|
||||||
function publicMarkdown(x){return `---\ntitle: ${JSON.stringify(x.title)}\nsummary: ${JSON.stringify(x.public_summary||x.symptom)}\ndate: ${x.detected_at.slice(0,10)}\nprojectSlug: ${JSON.stringify(x.project||'')}\nkind: bug\nseverity: ${publicSeverity[x.severity]||'minor'}\nstatus: ${publicStatus[x.status]||'open'}\ntags: []\n---\n\n## Symptom\n\n${x.symptom}\n\n## What was actually happening\n\n${x.root_cause}\n\n## How we proved it\n\n${x.verification}\n\n## The fix\n\n${x.fix}\n\n## What changed afterward\n\n${x.prevention}\n\n## Remaining risk\n\n${x.remaining_risk}\n\n## Lesson\n\n${x.lesson}\n`;}
|
.split(',')
|
||||||
function publicIncident(x){return {id:x.id,slug:x.public_slug,title:x.title,summary:x.public_summary||x.symptom,date:x.detected_at.slice(0,10),projectSlug:x.project||'',kind:'bug',severity:publicSeverity[x.severity]||'minor',status:publicStatus[x.status]||'open',publishedAt:x.published_at,updatedAt:x.updated_at,markdownUrl:`/api/v1/public/failures/${encodeURIComponent(x.public_slug)}.md`};}
|
.map((x) => x.trim())
|
||||||
function publicDetail(x){return {...publicIncident(x),sections:[['Symptom',x.symptom],['What was actually happening',x.root_cause],['How we proved it',x.verification],['The fix',x.fix],['What changed afterward',x.prevention],['Remaining risk',x.remaining_risk],['Lesson',x.lesson]].filter(([,body])=>body)};}
|
.filter(Boolean),
|
||||||
function incidentForm(v={}){return `<form method=post class=stack><label>Title<input name=title required value="${esc(v.title)}"></label><div class=cols><label>Project<input name=project value="${esc(v.project)}"></label><label>Detected<input type=datetime-local name=detected_at required value="${esc((v.detected_at||new Date().toISOString()).slice(0,16))}"></label><label>Status<select name=status>${['investigating','mitigated','resolved','abandoned'].map(x=>`<option ${v.status===x?'selected':''}>${x}</option>`)}</select></label><label>Severity<select name=severity>${['annoyance','degraded','unavailable','data-risk','security-risk'].map(x=>`<option ${v.severity===x?'selected':''}>${x}</option>`)}</select></label></div><label>Symptom<textarea name=symptom required>${esc(v.symptom)}</textarea></label>${fields.map(x=>`<label>${x.replaceAll('_',' ')}<textarea name=${x}>${esc(v[x])}</textarea></label>`).join('')}<label>Confidence<select name=confidence>${['unknown','low','medium','high'].map(x=>`<option ${v.confidence===x?'selected':''}>${x}</option>`)}</select></label><button>save</button></form>`;}
|
giteaUrl = process.env.TRACE_GITEA_URL || 'https://repo.explewd.com',
|
||||||
function redirect(res,to){res.writeHead(303,{Location:to}).end();}
|
giteaToken = process.env.TRACE_GITEA_TOKEN || '';
|
||||||
async function scan(){for(const [id,path] of repos){let stdout;try{stdout=(await exec('git',['-C',path,'log','--all','--no-merges','-n','300','--pretty=format:%H%x1f%ad%x1f%s','--date=iso-strict'],{maxBuffer:2e6})).stdout;}catch(error){console.warn(`Skipping unavailable repository ${id} at ${path}: ${error.message}`);continue;}for(const line of stdout.split('\n')){const [sha,date,subject]=line.split('\x1f');if(!/fix|bug|broken|race|crash|regression|rollback|restore|duplicate|auth|fail/i.test(subject||''))continue;let paths='';try{paths=(await exec('git',['-C',path,'show','--name-only','--pretty=format:',sha],{maxBuffer:2e5})).stdout.trim();}catch{}db.prepare('INSERT OR IGNORE INTO candidates(repo,sha,committed_at,subject,paths,reason) VALUES(?,?,?,?,?,?)').run(id,sha,date,subject,paths,'local git keyword');}}const remote=await scanGiteaCandidates({baseUrl:giteaUrl,repositories:giteaRepos,token:giteaToken});for(const candidate of remote.candidates)db.prepare('INSERT OR IGNORE INTO candidates(repo,sha,committed_at,subject,paths,reason) VALUES(?,?,?,?,?,?)').run(candidate.repo,candidate.sha,candidate.committedAt,candidate.subject,candidate.paths,candidate.reason);for(const error of remote.errors)console.warn(`Skipping Gitea repository ${error.repo}: ${error.error}`)}
|
const esc = (s) =>
|
||||||
async function handler(req,res){if(!auth(req,res))return;const u=new URL(req.url,'http://x');if(u.pathname==='/style.css'){res.writeHead(200,{'content-type':'text/css'}).end(`:root{--b:#0d1117;--p:#161b22;--l:#30363d;--t:#c9d1d9;--m:#8b949e;--a:#58a6ff}*{box-sizing:border-box}body{margin:0;background:var(--b);color:var(--t);font:14px/1.55 ui-monospace,monospace}header,footer,main{max-width:1050px;margin:auto;padding:20px}header{display:flex;justify-content:space-between;border-bottom:1px solid var(--l)}a{color:var(--a);text-decoration:none}nav{display:flex;gap:18px}.card,form{background:var(--p);border:1px solid var(--l);padding:18px;border-radius:8px;margin:12px 0}.stack,label{display:grid;gap:6px}.stack{gap:14px}.cols{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}input,textarea,select,button{background:var(--b);border:1px solid var(--l);color:var(--t);padding:10px;font:inherit}textarea{min-height:75px}button{cursor:pointer;color:var(--a)}.meta{color:var(--m);font-size:12px}.badge{border:1px solid var(--l);padding:2px 7px;border-radius:99px}.timeline{border-left:2px solid var(--l);padding-left:18px}.actions{display:flex;gap:8px}@media(max-width:650px){.cols{grid-template-columns:1fr}}`);return;}
|
String(s ?? '')
|
||||||
if(req.method==='GET'&&u.pathname==='/'){const rows=db.prepare('SELECT * FROM incidents ORDER BY detected_at DESC').all();const counts=db.prepare("SELECT status,count(*) n FROM incidents GROUP BY status").all();res.end(layout('incidents',`<h1>Incident notebook</h1><p class=meta>${counts.map(x=>`${x.status}: ${x.n}`).join(' · ')||'No incidents yet.'}</p>${rows.map(x=>`<a class=card style=display:block href=/incidents/${x.id}><b>${esc(x.title)}</b><p>${esc(x.symptom)}</p><span class=badge>${x.status}</span> <span class=meta>${esc(x.project)} · ${esc(x.detected_at)}</span></a>`).join('')}`));return;}
|
.replaceAll('&', '&')
|
||||||
if(req.method==='GET'&&u.pathname==='/incidents/new'){res.end(layout('new incident','<h1>Record what happened</h1>'+incidentForm()));return;}
|
.replaceAll('<', '<')
|
||||||
if(req.method==='POST'&&u.pathname==='/incidents/new'){const f=await form(req),id=crypto.randomUUID(),now=new Date().toISOString();db.prepare(`INSERT INTO incidents(id,title,project,status,severity,detected_at,symptom,impact,root_cause,confidence,fix,verification,prevention,remaining_risk,lesson,public_summary,created_at,updated_at) VALUES(${Array(18).fill('?').join(',')})`).run(id,f.title,f.project||'',f.status,f.severity,f.detected_at,f.symptom,...fields.slice(0,2).map(x=>f[x]||''),f.confidence||'unknown',...fields.slice(2).map(x=>f[x]||''),now,now);redirect(res,'/incidents/'+id);return;}
|
.replaceAll('>', '>')
|
||||||
const match=u.pathname.match(/^\/incidents\/([^/]+)$/);if(match){const x=db.prepare('SELECT * FROM incidents WHERE id=?').get(match[1]);if(!x){res.writeHead(404).end();return;}if(req.method==='POST'){const f=await form(req);db.prepare(`UPDATE incidents SET title=?,project=?,status=?,severity=?,detected_at=?,symptom=?,${fields.map(x=>x+'=?').join(',')},confidence=?,updated_at=? WHERE id=?`).run(f.title,f.project||'',f.status,f.severity,f.detected_at,f.symptom,...fields.map(x=>f[x]||''),f.confidence||'unknown',new Date().toISOString(),x.id);redirect(res,u.pathname);return;}const timeline=db.prepare('SELECT * FROM timeline WHERE incident_id=? ORDER BY at').all(x.id), hyps=db.prepare('SELECT * FROM hypotheses WHERE incident_id=?').all(x.id), commits=db.prepare('SELECT * FROM candidates WHERE incident_id=?').all(x.id);res.end(layout(x.title,`<h1>${esc(x.title)}</h1><div class=actions><form method=post action=/incidents/${x.id}/export><button>export public draft</button></form></div>${incidentForm(x)}<section class=card><h2>Timeline</h2><div class=timeline>${timeline.map(t=>`<p><b>${esc(t.kind)}</b> ${esc(t.at)}<br>${esc(t.body)}</p>`).join('')}</div><form method=post action=/incidents/${x.id}/timeline><div class=cols><input type=datetime-local name=at required><select name=kind>${['observation','hypothesis','action','result','decision','deploy','note'].map(k=>`<option>${k}</option>`)}</select></div><textarea name=body required></textarea><button>append</button></form></section><section class=card><h2>Hypotheses</h2>${hyps.map(h=>`<p><b>${esc(h.statement)}</b> <span class=badge>${h.status}</span><br>${esc(h.evidence)}</p>`).join('')}<form method=post action=/incidents/${x.id}/hypotheses><input name=statement required placeholder="We think…"><select name=status>${['untested','supported','rejected','inconclusive'].map(k=>`<option>${k}</option>`)}</select><textarea name=evidence placeholder=evidence></textarea><button>add</button></form></section><section class=card><h2>Linked commits</h2>${commits.map(c=>`<p><code>${c.sha.slice(0,7)}</code> ${esc(c.subject)}</p>`).join('')||'<p class=meta>None.</p>'}</section>`));return;}
|
.replaceAll('"', '"');
|
||||||
const sub=u.pathname.match(/^\/incidents\/([^/]+)\/(timeline|hypotheses|export)$/);if(req.method==='POST'&&sub){const f=await form(req),id=sub[1];if(sub[2]==='timeline')db.prepare('INSERT INTO timeline(incident_id,at,kind,body) VALUES(?,?,?,?)').run(id,f.at,f.kind,f.body);if(sub[2]==='hypotheses')db.prepare('INSERT INTO hypotheses(incident_id,statement,status,evidence) VALUES(?,?,?,?)').run(id,f.statement,f.status,f.evidence);if(sub[2]==='export'){const x=db.prepare('SELECT * FROM incidents WHERE id=?').get(id);const slug=x.title.toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,'');const md=`---\ntitle: ${JSON.stringify(x.title)}\nsummary: ${JSON.stringify(x.public_summary||x.symptom)}\ndate: ${x.detected_at.slice(0,10)}\nprojectSlug: ${JSON.stringify(x.project||'')}\nkind: bug\nseverity: ${x.severity}\nstatus: ${x.status}\ntags: []\n---\n\n## Symptom\n\n${x.symptom}\n\n## What we thought\n\n<!-- Add reviewed hypothesis -->\n\n## What was actually happening\n\n${x.root_cause}\n\n## How we proved it\n\n${x.verification}\n\n## The fix\n\n${x.fix}\n\n## What changed afterward\n\n${x.prevention}\n\n## Remaining risk\n\n${x.remaining_risk}\n\n## Lesson\n\n${x.lesson}\n`;writeFileSync(join('data/exports',slug+'.md'),md);res.end(layout('export',`<h1>Public draft exported</h1><p><code>data/exports/${esc(slug)}.md</code></p><pre>${esc(md)}</pre>`));return;}redirect(res,'/incidents/'+id);return;}
|
const form = async (req) => {
|
||||||
if(u.pathname==='/candidates'&&req.method==='POST'){await scan();redirect(res,'/candidates');return;}if(u.pathname==='/candidates'){const rows=db.prepare("SELECT * FROM candidates WHERE state='new' ORDER BY committed_at DESC").all();res.end(layout('git inbox',`<h1>Git inbox</h1><form method=post><button>scan configured repositories</button></form>${rows.map(c=>`<div class=card><b>${esc(c.subject)}</b><p class=meta>${esc(c.repo)} · ${c.sha.slice(0,10)} · ${esc(c.committed_at)}</p><details><summary>changed paths</summary><pre>${esc(c.paths)}</pre></details><form method=post action=/candidates/${c.id}/link><input name=incident_id placeholder="incident UUID"><button>link</button></form></div>`).join('')}`));return;}
|
let b = '';
|
||||||
const link=u.pathname.match(/^\/candidates\/(\d+)\/link$/);if(link&&req.method==='POST'){const f=await form(req);db.prepare("UPDATE candidates SET incident_id=?,state='linked' WHERE id=?").run(f.incident_id,link[1]);redirect(res,'/candidates');return;}
|
for await (const c of req) b += c;
|
||||||
if(u.pathname==='/api/receipts'&&req.method==='POST'){let raw='';for await(const c of req)raw+=c;const x=JSON.parse(raw);db.prepare('INSERT OR IGNORE INTO receipts(project,commit_sha,deployed_at,environment,health,payload) VALUES(?,?,?,?,?,?)').run(x.project,x.commit,x.deployedAt,x.environment||'',x.health||'',raw);res.writeHead(201,{'content-type':'application/json'}).end('{"ok":true}');return;}
|
return Object.fromEntries(new URLSearchParams(b));
|
||||||
res.writeHead(404).end(layout('not found','<h1>Not found</h1>'));}
|
};
|
||||||
|
function auth(req, res) {
|
||||||
|
if (!password) return true;
|
||||||
|
const expected = 'Basic ' + Buffer.from('trace:' + password).toString('base64');
|
||||||
|
if (req.headers.authorization === expected) return true;
|
||||||
|
res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="trace"' }).end('Authentication required');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
function layout(title, body) {
|
||||||
|
return `<!doctype html><html><head><meta charset=utf-8><meta name=viewport content="width=device-width"><title>${esc(title)} · trace</title><link rel=stylesheet href=/style.css></head><body><header><a href=/><b>trace</b></a><nav><a href=/incidents/new>new incident</a><a href=/candidates>git inbox</a><a href=/events>operational events</a></nav></header><main>${body}</main><footer>Private by default. Git discovers; a human explains.</footer><script>const incident=location.pathname.match(/^\\/incidents\\/([^/]+)$/);if(incident){document.querySelector('form[action$="/export"]')?.remove();const controls=document.createElement('div');controls.className='actions publication-actions';controls.innerHTML='<form method="post" action="/incidents/'+incident[1]+'/publish"><button>publish / update reviewed article</button></form><form method="post" action="/incidents/'+incident[1]+'/unpublish"><button>unpublish</button></form>';document.querySelector('main h1')?.after(controls)}</script></body></html>`;
|
||||||
|
}
|
||||||
|
const fields = [
|
||||||
|
'impact',
|
||||||
|
'root_cause',
|
||||||
|
'fix',
|
||||||
|
'verification',
|
||||||
|
'prevention',
|
||||||
|
'remaining_risk',
|
||||||
|
'lesson',
|
||||||
|
'public_summary',
|
||||||
|
];
|
||||||
|
const slugify = (s) =>
|
||||||
|
String(s || 'failure')
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '') || 'failure';
|
||||||
|
const publicSeverity = {
|
||||||
|
annoyance: 'paper-cut',
|
||||||
|
degraded: 'minor',
|
||||||
|
unavailable: 'significant',
|
||||||
|
'data-risk': 'significant',
|
||||||
|
'security-risk': 'significant',
|
||||||
|
};
|
||||||
|
const publicStatus = {
|
||||||
|
investigating: 'open',
|
||||||
|
mitigated: 'worked-around',
|
||||||
|
resolved: 'resolved',
|
||||||
|
abandoned: 'abandoned',
|
||||||
|
};
|
||||||
|
function publicMarkdown(x) {
|
||||||
|
return `---\ntitle: ${JSON.stringify(x.title)}\nsummary: ${JSON.stringify(x.public_summary || x.symptom)}\ndate: ${x.detected_at.slice(0, 10)}\nprojectSlug: ${JSON.stringify(x.project || '')}\nkind: bug\nseverity: ${publicSeverity[x.severity] || 'minor'}\nstatus: ${publicStatus[x.status] || 'open'}\ntags: []\n---\n\n## Symptom\n\n${x.symptom}\n\n## What was actually happening\n\n${x.root_cause}\n\n## How we proved it\n\n${x.verification}\n\n## The fix\n\n${x.fix}\n\n## What changed afterward\n\n${x.prevention}\n\n## Remaining risk\n\n${x.remaining_risk}\n\n## Lesson\n\n${x.lesson}\n`;
|
||||||
|
}
|
||||||
|
function publicIncident(x) {
|
||||||
|
return {
|
||||||
|
id: x.id,
|
||||||
|
slug: x.public_slug,
|
||||||
|
title: x.title,
|
||||||
|
summary: x.public_summary || x.symptom,
|
||||||
|
date: x.detected_at.slice(0, 10),
|
||||||
|
projectSlug: x.project || '',
|
||||||
|
kind: 'bug',
|
||||||
|
severity: publicSeverity[x.severity] || 'minor',
|
||||||
|
status: publicStatus[x.status] || 'open',
|
||||||
|
publishedAt: x.published_at,
|
||||||
|
updatedAt: x.updated_at,
|
||||||
|
markdownUrl: `/api/v1/public/failures/${encodeURIComponent(x.public_slug)}.md`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function publicDetail(x) {
|
||||||
|
return {
|
||||||
|
...publicIncident(x),
|
||||||
|
sections: [
|
||||||
|
['Symptom', x.symptom],
|
||||||
|
['What was actually happening', x.root_cause],
|
||||||
|
['How we proved it', x.verification],
|
||||||
|
['The fix', x.fix],
|
||||||
|
['What changed afterward', x.prevention],
|
||||||
|
['Remaining risk', x.remaining_risk],
|
||||||
|
['Lesson', x.lesson],
|
||||||
|
].filter(([, body]) => body),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
function incidentForm(v = {}) {
|
||||||
|
return `<form method=post class=stack><label>Title<input name=title required value="${esc(v.title)}"></label><div class=cols><label>Project<input name=project value="${esc(v.project)}"></label><label>Detected<input type=datetime-local name=detected_at required value="${esc((v.detected_at || new Date().toISOString()).slice(0, 16))}"></label><label>Status<select name=status>${['investigating', 'mitigated', 'resolved', 'abandoned'].map((x) => `<option ${v.status === x ? 'selected' : ''}>${x}</option>`)}</select></label><label>Severity<select name=severity>${['annoyance', 'degraded', 'unavailable', 'data-risk', 'security-risk'].map((x) => `<option ${v.severity === x ? 'selected' : ''}>${x}</option>`)}</select></label></div><label>Symptom<textarea name=symptom required>${esc(v.symptom)}</textarea></label>${fields.map((x) => `<label>${x.replaceAll('_', ' ')}<textarea name=${x}>${esc(v[x])}</textarea></label>`).join('')}<label>Confidence<select name=confidence>${['unknown', 'low', 'medium', 'high'].map((x) => `<option ${v.confidence === x ? 'selected' : ''}>${x}</option>`)}</select></label><button>save</button></form>`;
|
||||||
|
}
|
||||||
|
function redirect(res, to) {
|
||||||
|
res.writeHead(303, { Location: to }).end();
|
||||||
|
}
|
||||||
|
async function scan() {
|
||||||
|
for (const [id, path] of repos) {
|
||||||
|
let stdout;
|
||||||
|
try {
|
||||||
|
stdout = (
|
||||||
|
await exec(
|
||||||
|
'git',
|
||||||
|
[
|
||||||
|
'-C',
|
||||||
|
path,
|
||||||
|
'log',
|
||||||
|
'--all',
|
||||||
|
'--no-merges',
|
||||||
|
'-n',
|
||||||
|
'300',
|
||||||
|
'--pretty=format:%H%x1f%ad%x1f%s',
|
||||||
|
'--date=iso-strict',
|
||||||
|
],
|
||||||
|
{ maxBuffer: 2e6 },
|
||||||
|
)
|
||||||
|
).stdout;
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`Skipping unavailable repository ${id} at ${path}: ${error.message}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const line of stdout.split('\n')) {
|
||||||
|
const [sha, date, subject] = line.split('\x1f');
|
||||||
|
if (
|
||||||
|
!/fix|bug|broken|race|crash|regression|rollback|restore|duplicate|auth|fail/i.test(
|
||||||
|
subject || '',
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue;
|
||||||
|
let paths = '';
|
||||||
|
try {
|
||||||
|
paths = (
|
||||||
|
await exec('git', ['-C', path, 'show', '--name-only', '--pretty=format:', sha], {
|
||||||
|
maxBuffer: 2e5,
|
||||||
|
})
|
||||||
|
).stdout.trim();
|
||||||
|
} catch {}
|
||||||
|
db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO candidates(repo,sha,committed_at,subject,paths,reason) VALUES(?,?,?,?,?,?)',
|
||||||
|
).run(id, sha, date, subject, paths, 'local git keyword');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const remote = await scanGiteaCandidates({
|
||||||
|
baseUrl: giteaUrl,
|
||||||
|
repositories: giteaRepos,
|
||||||
|
token: giteaToken,
|
||||||
|
});
|
||||||
|
for (const candidate of remote.candidates)
|
||||||
|
db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO candidates(repo,sha,committed_at,subject,paths,reason) VALUES(?,?,?,?,?,?)',
|
||||||
|
).run(
|
||||||
|
candidate.repo,
|
||||||
|
candidate.sha,
|
||||||
|
candidate.committedAt,
|
||||||
|
candidate.subject,
|
||||||
|
candidate.paths,
|
||||||
|
candidate.reason,
|
||||||
|
);
|
||||||
|
for (const error of remote.errors)
|
||||||
|
console.warn(`Skipping Gitea repository ${error.repo}: ${error.error}`);
|
||||||
|
}
|
||||||
|
async function handler(req, res) {
|
||||||
|
if (!auth(req, res)) return;
|
||||||
|
const u = new URL(req.url, 'http://x');
|
||||||
|
if (u.pathname === '/style.css') {
|
||||||
|
res
|
||||||
|
.writeHead(200, { 'content-type': 'text/css' })
|
||||||
|
.end(
|
||||||
|
`:root{--b:#0d1117;--p:#161b22;--l:#30363d;--t:#c9d1d9;--m:#8b949e;--a:#58a6ff}*{box-sizing:border-box}body{margin:0;background:var(--b);color:var(--t);font:14px/1.55 ui-monospace,monospace}header,footer,main{max-width:1050px;margin:auto;padding:20px}header{display:flex;justify-content:space-between;border-bottom:1px solid var(--l)}a{color:var(--a);text-decoration:none}nav{display:flex;gap:18px}.card,form{background:var(--p);border:1px solid var(--l);padding:18px;border-radius:8px;margin:12px 0}.stack,label{display:grid;gap:6px}.stack{gap:14px}.cols{display:grid;grid-template-columns:repeat(2,1fr);gap:12px}input,textarea,select,button{background:var(--b);border:1px solid var(--l);color:var(--t);padding:10px;font:inherit}textarea{min-height:75px}button{cursor:pointer;color:var(--a)}.meta{color:var(--m);font-size:12px}.badge{border:1px solid var(--l);padding:2px 7px;border-radius:99px}.timeline{border-left:2px solid var(--l);padding-left:18px}.actions{display:flex;gap:8px}@media(max-width:650px){.cols{grid-template-columns:1fr}}`,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && u.pathname === '/') {
|
||||||
|
const rows = db.prepare('SELECT * FROM incidents ORDER BY detected_at DESC').all();
|
||||||
|
const counts = db.prepare('SELECT status,count(*) n FROM incidents GROUP BY status').all();
|
||||||
|
res.end(
|
||||||
|
layout(
|
||||||
|
'incidents',
|
||||||
|
`<h1>Incident notebook</h1><p class=meta>${counts.map((x) => `${x.status}: ${x.n}`).join(' · ') || 'No incidents yet.'}</p>${rows.map((x) => `<a class=card style=display:block href=/incidents/${x.id}><b>${esc(x.title)}</b><p>${esc(x.symptom)}</p><span class=badge>${x.status}</span> <span class=meta>${esc(x.project)} · ${esc(x.detected_at)}</span></a>`).join('')}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method === 'GET' && u.pathname === '/incidents/new') {
|
||||||
|
res.end(layout('new incident', '<h1>Record what happened</h1>' + incidentForm()));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method === 'POST' && u.pathname === '/incidents/new') {
|
||||||
|
const f = await form(req),
|
||||||
|
id = crypto.randomUUID(),
|
||||||
|
now = new Date().toISOString();
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO incidents(id,title,project,status,severity,detected_at,symptom,impact,root_cause,confidence,fix,verification,prevention,remaining_risk,lesson,public_summary,created_at,updated_at) VALUES(${Array(18).fill('?').join(',')})`,
|
||||||
|
).run(
|
||||||
|
id,
|
||||||
|
f.title,
|
||||||
|
f.project || '',
|
||||||
|
f.status,
|
||||||
|
f.severity,
|
||||||
|
f.detected_at,
|
||||||
|
f.symptom,
|
||||||
|
...fields.slice(0, 2).map((x) => f[x] || ''),
|
||||||
|
f.confidence || 'unknown',
|
||||||
|
...fields.slice(2).map((x) => f[x] || ''),
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
);
|
||||||
|
redirect(res, '/incidents/' + id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const match = u.pathname.match(/^\/incidents\/([^/]+)$/);
|
||||||
|
if (match) {
|
||||||
|
const x = db.prepare('SELECT * FROM incidents WHERE id=?').get(match[1]);
|
||||||
|
if (!x) {
|
||||||
|
res.writeHead(404).end();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (req.method === 'POST') {
|
||||||
|
const f = await form(req);
|
||||||
|
db.prepare(
|
||||||
|
`UPDATE incidents SET title=?,project=?,status=?,severity=?,detected_at=?,symptom=?,${fields.map((x) => x + '=?').join(',')},confidence=?,updated_at=? WHERE id=?`,
|
||||||
|
).run(
|
||||||
|
f.title,
|
||||||
|
f.project || '',
|
||||||
|
f.status,
|
||||||
|
f.severity,
|
||||||
|
f.detected_at,
|
||||||
|
f.symptom,
|
||||||
|
...fields.map((x) => f[x] || ''),
|
||||||
|
f.confidence || 'unknown',
|
||||||
|
new Date().toISOString(),
|
||||||
|
x.id,
|
||||||
|
);
|
||||||
|
redirect(res, u.pathname);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const timeline = db.prepare('SELECT * FROM timeline WHERE incident_id=? ORDER BY at').all(x.id),
|
||||||
|
hyps = db.prepare('SELECT * FROM hypotheses WHERE incident_id=?').all(x.id),
|
||||||
|
commits = db.prepare('SELECT * FROM candidates WHERE incident_id=?').all(x.id);
|
||||||
|
res.end(
|
||||||
|
layout(
|
||||||
|
x.title,
|
||||||
|
`<h1>${esc(x.title)}</h1><div class=actions><form method=post action=/incidents/${x.id}/export><button>export public draft</button></form></div>${incidentForm(x)}<section class=card><h2>Timeline</h2><div class=timeline>${timeline.map((t) => `<p><b>${esc(t.kind)}</b> ${esc(t.at)}<br>${esc(t.body)}</p>`).join('')}</div><form method=post action=/incidents/${x.id}/timeline><div class=cols><input type=datetime-local name=at required><select name=kind>${['observation', 'hypothesis', 'action', 'result', 'decision', 'deploy', 'note'].map((k) => `<option>${k}</option>`)}</select></div><textarea name=body required></textarea><button>append</button></form></section><section class=card><h2>Hypotheses</h2>${hyps.map((h) => `<p><b>${esc(h.statement)}</b> <span class=badge>${h.status}</span><br>${esc(h.evidence)}</p>`).join('')}<form method=post action=/incidents/${x.id}/hypotheses><input name=statement required placeholder="We think…"><select name=status>${['untested', 'supported', 'rejected', 'inconclusive'].map((k) => `<option>${k}</option>`)}</select><textarea name=evidence placeholder=evidence></textarea><button>add</button></form></section><section class=card><h2>Linked commits</h2>${commits.map((c) => `<p><code>${c.sha.slice(0, 7)}</code> ${esc(c.subject)}</p>`).join('') || '<p class=meta>None.</p>'}</section>`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sub = u.pathname.match(/^\/incidents\/([^/]+)\/(timeline|hypotheses|export)$/);
|
||||||
|
if (req.method === 'POST' && sub) {
|
||||||
|
const f = await form(req),
|
||||||
|
id = sub[1];
|
||||||
|
if (sub[2] === 'timeline')
|
||||||
|
db.prepare('INSERT INTO timeline(incident_id,at,kind,body) VALUES(?,?,?,?)').run(
|
||||||
|
id,
|
||||||
|
f.at,
|
||||||
|
f.kind,
|
||||||
|
f.body,
|
||||||
|
);
|
||||||
|
if (sub[2] === 'hypotheses')
|
||||||
|
db.prepare(
|
||||||
|
'INSERT INTO hypotheses(incident_id,statement,status,evidence) VALUES(?,?,?,?)',
|
||||||
|
).run(id, f.statement, f.status, f.evidence);
|
||||||
|
if (sub[2] === 'export') {
|
||||||
|
const x = db.prepare('SELECT * FROM incidents WHERE id=?').get(id);
|
||||||
|
const slug = x.title
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, '-')
|
||||||
|
.replace(/^-|-$/g, '');
|
||||||
|
const md = `---\ntitle: ${JSON.stringify(x.title)}\nsummary: ${JSON.stringify(x.public_summary || x.symptom)}\ndate: ${x.detected_at.slice(0, 10)}\nprojectSlug: ${JSON.stringify(x.project || '')}\nkind: bug\nseverity: ${x.severity}\nstatus: ${x.status}\ntags: []\n---\n\n## Symptom\n\n${x.symptom}\n\n## What we thought\n\n<!-- Add reviewed hypothesis -->\n\n## What was actually happening\n\n${x.root_cause}\n\n## How we proved it\n\n${x.verification}\n\n## The fix\n\n${x.fix}\n\n## What changed afterward\n\n${x.prevention}\n\n## Remaining risk\n\n${x.remaining_risk}\n\n## Lesson\n\n${x.lesson}\n`;
|
||||||
|
writeFileSync(join('data/exports', slug + '.md'), md);
|
||||||
|
res.end(
|
||||||
|
layout(
|
||||||
|
'export',
|
||||||
|
`<h1>Public draft exported</h1><p><code>data/exports/${esc(slug)}.md</code></p><pre>${esc(md)}</pre>`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
redirect(res, '/incidents/' + id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (u.pathname === '/candidates' && req.method === 'POST') {
|
||||||
|
await scan();
|
||||||
|
redirect(res, '/candidates');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (u.pathname === '/candidates') {
|
||||||
|
const rows = db
|
||||||
|
.prepare("SELECT * FROM candidates WHERE state='new' ORDER BY committed_at DESC")
|
||||||
|
.all();
|
||||||
|
res.end(
|
||||||
|
layout(
|
||||||
|
'git inbox',
|
||||||
|
`<h1>Git inbox</h1><form method=post><button>scan configured repositories</button></form>${rows.map((c) => `<div class=card><b>${esc(c.subject)}</b><p class=meta>${esc(c.repo)} · ${c.sha.slice(0, 10)} · ${esc(c.committed_at)}</p><details><summary>changed paths</summary><pre>${esc(c.paths)}</pre></details><form method=post action=/candidates/${c.id}/link><input name=incident_id placeholder="incident UUID"><button>link</button></form></div>`).join('')}`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const link = u.pathname.match(/^\/candidates\/(\d+)\/link$/);
|
||||||
|
if (link && req.method === 'POST') {
|
||||||
|
const f = await form(req);
|
||||||
|
db.prepare("UPDATE candidates SET incident_id=?,state='linked' WHERE id=?").run(
|
||||||
|
f.incident_id,
|
||||||
|
link[1],
|
||||||
|
);
|
||||||
|
redirect(res, '/candidates');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (u.pathname === '/api/receipts' && req.method === 'POST') {
|
||||||
|
let raw = '';
|
||||||
|
for await (const c of req) raw += c;
|
||||||
|
const x = JSON.parse(raw);
|
||||||
|
db.prepare(
|
||||||
|
'INSERT OR IGNORE INTO receipts(project,commit_sha,deployed_at,environment,health,payload) VALUES(?,?,?,?,?,?)',
|
||||||
|
).run(x.project, x.commit, x.deployedAt, x.environment || '', x.health || '', raw);
|
||||||
|
res.writeHead(201, { 'content-type': 'application/json' }).end('{"ok":true}');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(404).end(layout('not found', '<h1>Not found</h1>'));
|
||||||
|
}
|
||||||
|
|
||||||
async function publicContent(req, res, path) {
|
async function publicContent(req, res, path) {
|
||||||
if (req.method === 'GET' && path === '/api/v1/public/failures') {
|
if (req.method === 'GET' && path === '/api/v1/public/failures') {
|
||||||
const rows=db.prepare("SELECT * FROM incidents WHERE published_at<>'' ORDER BY detected_at DESC").all();
|
const rows = db
|
||||||
res.writeHead(200,{'content-type':'application/json; charset=utf-8','access-control-allow-origin':'*','cache-control':'public, max-age=60'}).end(JSON.stringify({schemaVersion:1,generatedAt:new Date().toISOString(),failures:rows.map(publicIncident)}));return true;
|
.prepare("SELECT * FROM incidents WHERE published_at<>'' ORDER BY detected_at DESC")
|
||||||
|
.all();
|
||||||
|
res
|
||||||
|
.writeHead(200, {
|
||||||
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
'access-control-allow-origin': '*',
|
||||||
|
'cache-control': 'public, max-age=60',
|
||||||
|
})
|
||||||
|
.end(
|
||||||
|
JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
failures: rows.map(publicIncident),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
const md = path.match(/^\/api\/v1\/public\/failures\/([^/]+)\.md$/);
|
const md = path.match(/^\/api\/v1\/public\/failures\/([^/]+)\.md$/);
|
||||||
if(req.method==='GET'&&md){const x=db.prepare("SELECT * FROM incidents WHERE public_slug=? AND published_at<>''").get(decodeURIComponent(md[1]));if(!x){res.writeHead(404).end('Not found');return true;}res.writeHead(200,{'content-type':'text/markdown; charset=utf-8','access-control-allow-origin':'*','cache-control':'public, max-age=60'}).end(publicMarkdown(x));return true;}
|
if (req.method === 'GET' && md) {
|
||||||
|
const x = db
|
||||||
|
.prepare("SELECT * FROM incidents WHERE public_slug=? AND published_at<>''")
|
||||||
|
.get(decodeURIComponent(md[1]));
|
||||||
|
if (!x) {
|
||||||
|
res.writeHead(404).end('Not found');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
res
|
||||||
|
.writeHead(200, {
|
||||||
|
'content-type': 'text/markdown; charset=utf-8',
|
||||||
|
'access-control-allow-origin': '*',
|
||||||
|
'cache-control': 'public, max-age=60',
|
||||||
|
})
|
||||||
|
.end(publicMarkdown(x));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
const detail = path.match(/^\/api\/v1\/public\/failures\/([^/]+)$/);
|
const detail = path.match(/^\/api\/v1\/public\/failures\/([^/]+)$/);
|
||||||
if(req.method==='GET'&&detail){const x=db.prepare("SELECT * FROM incidents WHERE public_slug=? AND published_at<>''").get(decodeURIComponent(detail[1]));if(!x){res.writeHead(404,{'content-type':'application/json; charset=utf-8','access-control-allow-origin':'*'}).end('{"error":"not_found"}');return true;}res.writeHead(200,{'content-type':'application/json; charset=utf-8','access-control-allow-origin':'*','cache-control':'public, max-age=60'}).end(JSON.stringify({schemaVersion:1,failure:publicDetail(x)}));return true;}
|
if (req.method === 'GET' && detail) {
|
||||||
|
const x = db
|
||||||
|
.prepare("SELECT * FROM incidents WHERE public_slug=? AND published_at<>''")
|
||||||
|
.get(decodeURIComponent(detail[1]));
|
||||||
|
if (!x) {
|
||||||
|
res
|
||||||
|
.writeHead(404, {
|
||||||
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
'access-control-allow-origin': '*',
|
||||||
|
})
|
||||||
|
.end('{"error":"not_found"}');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
res
|
||||||
|
.writeHead(200, {
|
||||||
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
'access-control-allow-origin': '*',
|
||||||
|
'cache-control': 'public, max-age=60',
|
||||||
|
})
|
||||||
|
.end(JSON.stringify({ schemaVersion: 1, failure: publicDetail(x) }));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function publicationAction(req, res, path) {
|
async function publicationAction(req, res, path) {
|
||||||
const match=path.match(/^\/incidents\/([^/]+)\/(publish|unpublish)$/);if(!match||req.method!=='POST')return false;if(!auth(req,res))return true;
|
const match = path.match(/^\/incidents\/([^/]+)\/(publish|unpublish)$/);
|
||||||
const x=db.prepare('SELECT * FROM incidents WHERE id=?').get(match[1]);if(!x){res.writeHead(404).end();return true;}
|
if (!match || req.method !== 'POST') return false;
|
||||||
if(match[2]==='unpublish')db.prepare("UPDATE incidents SET published_at='',updated_at=? WHERE id=?").run(new Date().toISOString(),x.id);
|
if (!auth(req, res)) return true;
|
||||||
else {let slug=x.public_slug||slugify(x.title);const collision=db.prepare('SELECT id FROM incidents WHERE public_slug=? AND id<>?').get(slug,x.id);if(collision)slug=`${slug}-${x.id.slice(0,8)}`;const now=new Date().toISOString();db.prepare('UPDATE incidents SET public_slug=?,published_at=?,updated_at=? WHERE id=?').run(slug,now,now,x.id);}
|
const x = db.prepare('SELECT * FROM incidents WHERE id=?').get(match[1]);
|
||||||
redirect(res,`/incidents/${x.id}`);return true;
|
if (!x) {
|
||||||
|
res.writeHead(404).end();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (match[2] === 'unpublish')
|
||||||
|
db.prepare("UPDATE incidents SET published_at='',updated_at=? WHERE id=?").run(
|
||||||
|
new Date().toISOString(),
|
||||||
|
x.id,
|
||||||
|
);
|
||||||
|
else {
|
||||||
|
let slug = x.public_slug || slugify(x.title);
|
||||||
|
const collision = db
|
||||||
|
.prepare('SELECT id FROM incidents WHERE public_slug=? AND id<>?')
|
||||||
|
.get(slug, x.id);
|
||||||
|
if (collision) slug = `${slug}-${x.id.slice(0, 8)}`;
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
db.prepare('UPDATE incidents SET public_slug=?,published_at=?,updated_at=? WHERE id=?').run(
|
||||||
|
slug,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
x.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
redirect(res, `/incidents/${x.id}`);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
http.createServer(async(req,res)=>{try{const path=new URL(req.url,'http://x').pathname;if(await publicContent(req,res,path))return;if(await publicationAction(req,res,path))return;if(path==='/events'){if(auth(req,res))res.end(renderEventsPage(db));return;}const handled=await receiptStore.handle(req,res,path);if(handled===false)await handler(req,res);}catch(e){console.error(e);if(!res.headersSent)res.writeHead(500);res.end('Internal error');}}).listen(port,()=>console.log(`trace listening on ${port}`));
|
http
|
||||||
|
.createServer(async (req, res) => {
|
||||||
|
try {
|
||||||
|
const path = new URL(req.url, 'http://x').pathname;
|
||||||
|
if (await publicContent(req, res, path)) return;
|
||||||
|
if (await publicationAction(req, res, path)) return;
|
||||||
|
if (path === '/events') {
|
||||||
|
if (auth(req, res)) res.end(renderEventsPage(db));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const handled = await receiptStore.handle(req, res, path);
|
||||||
|
if (handled === false) await handler(req, res);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
if (!res.headersSent) res.writeHead(500);
|
||||||
|
res.end('Internal error');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.listen(port, () => console.log(`trace listening on ${port}`));
|
||||||
|
|||||||
+48
-3
@@ -1,3 +1,48 @@
|
|||||||
import test from'node:test';import assert from'node:assert/strict';import{scanGiteaCandidates}from'../src/gitea.mjs';
|
import test from 'node:test';
|
||||||
test('finds subject fixes, paginates safely, and ignores body-only matches',async()=>{const calls=[],fetchImpl=async url=>{calls.push(url);if(url.includes('/git/commits/abc'))return new Response(JSON.stringify({files:[{filename:'web/verify.go'}]}));return new Response(JSON.stringify([{sha:'abc',commit:{message:'Fix DTLS verification\nDetailed body',committer:{date:'2026-07-20T00:00:00Z'}}},{sha:'def',commit:{message:'Add feature\nfix mentioned only in body',committer:{date:'2026-07-19T00:00:00Z'}}}]))};const out=await scanGiteaCandidates({baseUrl:'https://gitea.example',repositories:['explewd/flit'],fetchImpl});assert.equal(out.candidates.length,1);assert.equal(out.candidates[0].sha,'abc');assert.equal(out.candidates[0].paths,'web/verify.go');assert.equal(out.errors.length,0);assert.ok(calls[0].includes('limit=50&page=1'))});
|
import assert from 'node:assert/strict';
|
||||||
test('isolates invalid and failed repositories',async()=>{const out=await scanGiteaCandidates({baseUrl:'https://gitea.example',repositories:['bad','explewd/missing'],fetchImpl:async()=>new Response('',{status:404})});assert.equal(out.candidates.length,0);assert.equal(out.errors.length,2)});
|
import { scanGiteaCandidates } from '../src/gitea.mjs';
|
||||||
|
test('finds subject fixes, paginates safely, and ignores body-only matches', async () => {
|
||||||
|
const calls = [],
|
||||||
|
fetchImpl = async (url) => {
|
||||||
|
calls.push(url);
|
||||||
|
if (url.includes('/git/commits/abc'))
|
||||||
|
return new Response(JSON.stringify({ files: [{ filename: 'web/verify.go' }] }));
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify([
|
||||||
|
{
|
||||||
|
sha: 'abc',
|
||||||
|
commit: {
|
||||||
|
message: 'Fix DTLS verification\nDetailed body',
|
||||||
|
committer: { date: '2026-07-20T00:00:00Z' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
sha: 'def',
|
||||||
|
commit: {
|
||||||
|
message: 'Add feature\nfix mentioned only in body',
|
||||||
|
committer: { date: '2026-07-19T00:00:00Z' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
const out = await scanGiteaCandidates({
|
||||||
|
baseUrl: 'https://gitea.example',
|
||||||
|
repositories: ['explewd/flit'],
|
||||||
|
fetchImpl,
|
||||||
|
});
|
||||||
|
assert.equal(out.candidates.length, 1);
|
||||||
|
assert.equal(out.candidates[0].sha, 'abc');
|
||||||
|
assert.equal(out.candidates[0].paths, 'web/verify.go');
|
||||||
|
assert.equal(out.errors.length, 0);
|
||||||
|
assert.ok(calls[0].includes('limit=50&page=1'));
|
||||||
|
});
|
||||||
|
test('isolates invalid and failed repositories', async () => {
|
||||||
|
const out = await scanGiteaCandidates({
|
||||||
|
baseUrl: 'https://gitea.example',
|
||||||
|
repositories: ['bad', 'explewd/missing'],
|
||||||
|
fetchImpl: async () => new Response('', { status: 404 }),
|
||||||
|
});
|
||||||
|
assert.equal(out.candidates.length, 0);
|
||||||
|
assert.equal(out.errors.length, 2);
|
||||||
|
});
|
||||||
|
|||||||
+52
-4
@@ -1,4 +1,52 @@
|
|||||||
import test from 'node:test';import assert from 'node:assert/strict';import {DatabaseSync}from'node:sqlite';import{createReceiptStore,validateReceipt}from'../src/receipts.mjs';
|
import test from 'node:test';
|
||||||
const valid={schemaVersion:1,eventType:'deployment',eventId:'goonk-prod-1',project:'goonk',occurredAt:'2026-07-20T20:00:00Z',result:'success',source:{commit:'577a62e'},verification:{health:'healthy'}};
|
import assert from 'node:assert/strict';
|
||||||
test('validates v1',()=>{assert.deepEqual(validateReceipt(valid),[]);assert.ok(validateReceipt({}).length)});
|
import { DatabaseSync } from 'node:sqlite';
|
||||||
test('is idempotent and public projection is allowlisted',async()=>{const db=new DatabaseSync(':memory:'),store=createReceiptStore(db,'goonk:secret');const req=()=>({method:'POST',headers:{authorization:'Bearer secret'},async *[Symbol.asyncIterator](){yield JSON.stringify(valid)}});const res=()=>{const x={status:0,body:''};x.writeHead=s=>{x.status=s;return x};x.end=b=>{x.body=b;return x};return x};let r=res();await store.handle(req(),r,'/api/v1/events');assert.equal(r.status,201);r=res();await store.handle(req(),r,'/api/v1/events');assert.equal(JSON.parse(r.body).duplicate,true);r=res();await store.handle({method:'GET',headers:{}},r,'/api/v1/public/projects/goonk/latest');const out=JSON.parse(r.body);assert.equal(out.deployment.commit,'577a62e');assert.equal(out.deployment.payload,undefined)});
|
import { createReceiptStore, validateReceipt } from '../src/receipts.mjs';
|
||||||
|
const valid = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
eventType: 'deployment',
|
||||||
|
eventId: 'goonk-prod-1',
|
||||||
|
project: 'goonk',
|
||||||
|
occurredAt: '2026-07-20T20:00:00Z',
|
||||||
|
result: 'success',
|
||||||
|
source: { commit: '577a62e' },
|
||||||
|
verification: { health: 'healthy' },
|
||||||
|
};
|
||||||
|
test('validates v1', () => {
|
||||||
|
assert.deepEqual(validateReceipt(valid), []);
|
||||||
|
assert.ok(validateReceipt({}).length);
|
||||||
|
});
|
||||||
|
test('is idempotent and public projection is allowlisted', async () => {
|
||||||
|
const db = new DatabaseSync(':memory:'),
|
||||||
|
store = createReceiptStore(db, 'goonk:secret');
|
||||||
|
const req = () => ({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { authorization: 'Bearer secret' },
|
||||||
|
async *[Symbol.asyncIterator]() {
|
||||||
|
yield JSON.stringify(valid);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const res = () => {
|
||||||
|
const x = { status: 0, body: '' };
|
||||||
|
x.writeHead = (s) => {
|
||||||
|
x.status = s;
|
||||||
|
return x;
|
||||||
|
};
|
||||||
|
x.end = (b) => {
|
||||||
|
x.body = b;
|
||||||
|
return x;
|
||||||
|
};
|
||||||
|
return x;
|
||||||
|
};
|
||||||
|
let r = res();
|
||||||
|
await store.handle(req(), r, '/api/v1/events');
|
||||||
|
assert.equal(r.status, 201);
|
||||||
|
r = res();
|
||||||
|
await store.handle(req(), r, '/api/v1/events');
|
||||||
|
assert.equal(JSON.parse(r.body).duplicate, true);
|
||||||
|
r = res();
|
||||||
|
await store.handle({ method: 'GET', headers: {} }, r, '/api/v1/public/projects/goonk/latest');
|
||||||
|
const out = JSON.parse(r.body);
|
||||||
|
assert.equal(out.deployment.commit, '577a62e');
|
||||||
|
assert.equal(out.deployment.payload, undefined);
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user