From 461f8b0873a1c1a884f9d6ea33bbf8391574607f Mon Sep 17 00:00:00 2001 From: Fredrik Johansson Date: Mon, 20 Jul 2026 22:19:44 +0200 Subject: [PATCH] Scan public repositories through Gitea API --- .env.example | 5 +++++ README.md | 2 ++ src/gitea.mjs | 9 +++++++++ src/server.mjs | 4 +++- test/gitea.test.mjs | 3 +++ 5 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 src/gitea.mjs create mode 100644 test/gitea.test.mjs diff --git a/.env.example b/.env.example index 27595e4..06622a0 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/README.md b/README.md index a8e18f3..2d623f2 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/gitea.mjs b/src/gitea.mjs new file mode 100644 index 0000000..aedf232 --- /dev/null +++ b/src/gitea.mjs @@ -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;seenfile.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 { 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('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"'); 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 `
${fields.map(x=>``).join('')}`;} 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',`

Incident notebook

${counts.map(x=>`${x.status}: ${x.n}`).join(' · ')||'No incidents yet.'}

${rows.map(x=>`${esc(x.title)}

${esc(x.symptom)}

${x.status} ${esc(x.project)} · ${esc(x.detected_at)}
`).join('')}`));return;} if(req.method==='GET'&&u.pathname==='/incidents/new'){res.end(layout('new incident','

Record what happened

'+incidentForm()));return;} diff --git a/test/gitea.test.mjs b/test/gitea.test.mjs new file mode 100644 index 0000000..e973691 --- /dev/null +++ b/test/gitea.test.mjs @@ -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)});