/** * 参赛/受验者仓库提交状态探测工具 * 用法:node server/scripts/check-repos.mjs [赛道一|赛道二|L2考核|all] * * 通过 Gitea API 快速判定四种状态(无需克隆): * SUBMITTED 仓库存在且非空(已提交) * EMPTY 仓库存在但为空(建仓未推送) * NOT_CREATED 仓库不存在(或全局评审账号无权限且无备用凭据可区分) * NO_ACCESS 仓库存在(队伍自身凭据可见)但评审账号未被授权协作者 * AUTH_FAIL 队伍自身凭据失效 */ import fs from 'fs'; import path from 'path'; import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..', '..'); // ---------- 加载 .env ---------- function loadEnv() { const p = path.join(ROOT, 'server', '.env'); const out = {}; for (const line of fs.readFileSync(p, 'utf8').split(/\r?\n/)) { const m = line.match(/^([A-Z_]+)=(.*)$/); if (m && !line.trim().startsWith('#')) out[m[1]] = m[2].trim(); } return out; } const env = loadEnv(); const G_USER = env.GITEA_USERNAME || ''; const G_TOKEN = env.GITEA_TOKEN || ''; if (!G_USER || !G_TOKEN) { console.error('缺少 GITEA_USERNAME / GITEA_TOKEN(server/.env)'); process.exit(1); } const filterArg = process.argv[2] || 'all'; // ---------- 组装探测目标 ---------- const targets = []; if (filterArg === 'all' || filterArg === '赛道一' || filterArg === '赛道二') { const teams = JSON.parse(fs.readFileSync(path.join(ROOT, 'config', 'teams.json'), 'utf8')).teams || []; for (const t of teams) { if (filterArg !== 'all' && t.track !== filterArg) continue; targets.push({ kind: t.track, label: `${t.team || t.dept || ''}${t.leader ? '/' + t.leader : ''}`, user: t.gittea.user, repo: t.gittea.repo, // 备用凭据:队伍自己的 token/密码(用于区分"未创建"与"未授权") altAuth: t.gittea.token ? { type: 'token', value: t.gittea.token } : (t.gittea.password ? { type: 'basic', value: t.gittea.password } : null), }); } } if (filterArg === 'all' || filterArg === 'L2考核') { try { const l2 = JSON.parse(fs.readFileSync(path.join(ROOT, 'config', 'l2-participants.json'), 'utf8')); for (const p of l2.participants || []) { targets.push({ kind: 'L2考核', label: `${p.no}${p.name ? '/' + p.name : ''}`, user: p.gitteaUser, repo: p.repo || 'L2-assessment', altAuth: null, // 自注册模式无个人 token,404 无法与"未授权"区分 }); } } catch { /* 注册表不存在则跳过 */ } } if (targets.length === 0) { console.error('没有匹配的探测目标。用法: node server/scripts/check-repos.mjs [赛道一|赛道二|L2考核|all]'); process.exit(1); } // ---------- 探测 ---------- async function probeRepo(base, user, repo, auth) { const url = `${base.replace(/\/+$/, '')}/api/v1/repos/${encodeURIComponent(user)}/${encodeURIComponent(repo)}`; const headers = {}; if (auth?.type === 'token') headers.Authorization = `token ${auth.value}`; else if (auth?.type === 'basic') headers.Authorization = 'Basic ' + Buffer.from(`${G_USER}:${auth.value}`).toString('base64'); else headers.Authorization = 'Basic ' + Buffer.from(`${G_USER}:${G_TOKEN}`).toString('base64'); for (let attempt = 1; attempt <= 2; attempt++) { try { const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), 12000); const res = await fetch(url, { headers, signal: ctrl.signal }); clearTimeout(timer); if (res.status === 200) { const j = await res.json(); return { code: 'ok', empty: !!j.empty, defaultBranch: j.default_branch || '', updatedAt: j.updated_at || '' }; } if (res.status === 404) return { code: 'miss' }; if (res.status === 401 || res.status === 403) return { code: 'denied' }; return { code: 'http', detail: res.status }; } catch (e) { if (attempt === 2) return { code: 'net', detail: (e.message || '').slice(0, 60) }; await new Promise(r => setTimeout(r, 800)); } } } async function classify(t, base) { const g = await probeRepo(base, t.user, t.repo, null); if (g.code === 'ok') { if (g.empty) return { state: 'EMPTY', icon: '🟡', text: '空仓库(已建仓,未推送任何内容)' }; const branchWarn = g.defaultBranch && g.defaultBranch !== 'main' ? `,注意默认分支=${g.defaultBranch}` : ''; return { state: 'SUBMITTED', icon: '✅', text: `已提交(${g.defaultBranch}${branchWarn},更新 ${String(g.updatedAt).slice(0, 10)})` }; } if (g.code === 'denied') return { state: 'NO_ACCESS', icon: '⚠️ ', text: '评审账号无权限(403)' }; if (g.code === 'miss') { if (t.altAuth) { const a = await probeRepo(base, t.user, t.repo, t.altAuth); if (a.code === 'ok') { const extra = a.empty ? '(且为空仓库)' : ''; return { state: 'NO_ACCESS', icon: '⚠️ ', text: `仓库存在${extra},但评审账号未被授权协作者` }; } if (a.code === 'miss') return { state: 'NOT_CREATED', icon: '🔴', text: '仓库未创建(两队凭据均确认 404)' }; if (a.code === 'denied') return { state: 'AUTH_FAIL', icon: '🔴', text: '队伍自身凭据失效(token/密码不可用)' }; return { state: 'ERR', icon: '❓', text: '队伍凭据探测异常' }; } return { state: 'NOT_CREATED', icon: '🔴', text: '未创建 或 未授权(无备用凭据,无法区分)' }; } return { state: 'ERR', icon: '❓', text: `探测失败 ${g.detail || ''}` }; } // 小并发池 async function pool(items, worker, size = 6) { const results = new Array(items.length); let i = 0; async function run() { while (i < items.length) { const idx = i++; results[idx] = await worker(items[idx]); } } await Promise.all(Array.from({ length: Math.min(size, items.length) }, run)); return results; } // ---------- 主流程 ---------- const l2cfg = (() => { try { return JSON.parse(fs.readFileSync(path.join(ROOT, 'config', 'l2-participants.json'), 'utf8')); } catch { return {}; } })(); const BASE = l2cfg.gitteaUrl || 'https://gittea.dev'; console.log(`探测目标 ${targets.length} 个(base=${BASE}, 评审账号=${G_USER})`); console.log('='.repeat(100)); const results = await pool(targets, async (t) => ({ t, r: await classify(t, BASE) })); const ORDER = ['SUBMITTED', 'EMPTY', 'NO_ACCESS', 'NOT_CREATED', 'AUTH_FAIL', 'ERR']; const STATE_TEXT = { SUBMITTED: '已提交', EMPTY: '空仓库', NO_ACCESS: '未授权', NOT_CREATED: '未创建', AUTH_FAIL: '凭据失效', ERR: '探测异常', }; const counts = {}; for (const { t, r } of results) { counts[r.state] = (counts[r.state] || 0) + 1; console.log(`[${t.kind}] ${t.label.padEnd(24)} ${r.icon} ${STATE_TEXT[r.state].padEnd(6)} ${r.text}`); } console.log('='.repeat(100)); console.log('汇总:'); for (const s of ORDER) { if (counts[s]) console.log(` ${STATE_TEXT[s]}: ${counts[s]}`); } const notDone = (counts.EMPTY || 0) + (counts.NO_ACCESS || 0) + (counts.NOT_CREATED || 0) + (counts.AUTH_FAIL || 0); console.log(`\n结论:已提交 ${counts.SUBMITTED || 0} / 总数 ${results.length};未完成 ${notDone} 个(见上方明细)。`);