Files
L2keka/server/scripts/check-repos.mjs
T
hangshuo652 040808ef6b L2文档三大板块重构+评分细则不公开化;系统对齐:一键三轮评审、受验者注册表映射、提交状态探测工具
- 文档重构为 考核目的(1.x)/提交要求(2.x,含成果物需求)/考核内容(3.x) 板块编号体系
- 删除公开评分细则(7维表/合格线/难度赋分/功能拆分),保留验收基准与提交要求
- 自选题取消事前登记,改由 AGENTS.md 记录核心内容;前端同步移除登记字段
- 新增 l2-participants 注册表服务与 teams-config L2 URL 自动映射
- 新增 startReviewRounds 一键N轮评审(自动续跑+快照聚合中位数),前端「重新评审×3」
- 新增 check-repos.mjs 提交状态探测(API四态判定:已提交/空仓/未创建/未授权)
- 文档措辞与实现对齐(AI辅助评审)、修复引用/残片/格式问题
2026-08-26 10:52:23 +08:00

173 lines
7.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 参赛/受验者仓库提交状态探测工具
* 用法: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_TOKENserver/.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, // 自注册模式无个人 token404 无法与"未授权"区分
});
}
} 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} 个(见上方明细)。`);