- 文档重构为 考核目的(1.x)/提交要求(2.x,含成果物需求)/考核内容(3.x) 板块编号体系 - 删除公开评分细则(7维表/合格线/难度赋分/功能拆分),保留验收基准与提交要求 - 自选题取消事前登记,改由 AGENTS.md 记录核心内容;前端同步移除登记字段 - 新增 l2-participants 注册表服务与 teams-config L2 URL 自动映射 - 新增 startReviewRounds 一键N轮评审(自动续跑+快照聚合中位数),前端「重新评审×3」 - 新增 check-repos.mjs 提交状态探测(API四态判定:已提交/空仓/未创建/未授权) - 文档措辞与实现对齐(AI辅助评审)、修复引用/残片/格式问题
173 lines
7.2 KiB
JavaScript
173 lines
7.2 KiB
JavaScript
/**
|
||
* 参赛/受验者仓库提交状态探测工具
|
||
* 用法: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} 个(见上方明细)。`);
|