Scan public repositories through Gitea API
All checks were successful
Docker / test-build-push (push) Successful in 36s

This commit is contained in:
Fredrik Johansson
2026-07-20 22:19:44 +02:00
parent bb0dc65e83
commit 461f8b0873
5 changed files with 22 additions and 1 deletions

View File

@@ -5,3 +5,8 @@ TRACE_RECEIPT_TOKENS=goonk:replace-with-a-long-random-token
# Optional: comma-separated id:absolute-path pairs for deliberately mounted
# read-only repository mirrors. Leave empty when production has no Git repos.
TRACE_REPOS=
# Production-friendly candidate scanning through the Gitea API. Public
# repositories need no token; add a read-only token only for private repos.
TRACE_GITEA_URL=https://repo.explewd.com
TRACE_GITEA_REPOS=explewd/flit
TRACE_GITEA_TOKEN=

View File

@@ -11,6 +11,8 @@ Node 24+, no runtime dependencies. Uses built-in `node:sqlite`. Set `TRACE_PASSW
Git candidate scanning is optional and disabled by default. If an installation deliberately mounts read-only repository mirrors, list their container paths in `TRACE_REPOS`; production receipt ingestion does not require repository access.
Production can scan repositories without Git checkouts through Gitea's read-only API. Set `TRACE_GITEA_URL` and a comma-separated `TRACE_GITEA_REPOS` list such as `explewd/flit`. Public repositories require no token; `TRACE_GITEA_TOKEN` is optional for private repositories. A failed repository is skipped rather than failing the inbox.
Producer onboarding, shared-runner examples, deployment-host responsibilities, and verification are documented in [`docs/INTEGRATING_PROJECTS.md`](docs/INTEGRATING_PROJECTS.md).
## Deployment receipts v1

9
src/gitea.mjs Normal file
View File

@@ -0,0 +1,9 @@
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 validRepo=value=>/^[a-z0-9_.-]+\/[a-z0-9_.-]+$/i.test(value);
export async function scanGiteaCandidates({baseUrl,repositories,token='',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};
}

View File

@@ -6,6 +6,7 @@ import { mkdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import crypto from 'node:crypto';
import { createReceiptStore, renderEventsPage } from './receipts.mjs';
import { scanGiteaCandidates } from './gitea.mjs';
const exec = promisify(execFile), port = Number(process.env.PORT || 3082), password = process.env.TRACE_PASSWORD || '';
mkdirSync('data', { recursive: true }); mkdirSync('data/exports', { recursive: true });
@@ -18,6 +19,7 @@ CREATE TABLE IF NOT EXISTS candidates(id INTEGER PRIMARY KEY AUTOINCREMENT,repo
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));`);
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 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||'';
const esc=s=>String(s??'').replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;').replaceAll('"','&quot;');
const form=async req=>{let b='';for await(const c of req)b+=c;return Object.fromEntries(new URLSearchParams(b));};
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;}
@@ -25,7 +27,7 @@ function layout(title,body){return `<!doctype html><html><head><meta charset=utf
const fields=['impact','root_cause','fix','verification','prevention','remaining_risk','lesson','public_summary'];
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,'keyword');}}}
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;}

3
test/gitea.test.mjs Normal file
View File

@@ -0,0 +1,3 @@
import test from'node:test';import assert from'node:assert/strict';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)});