feat(repro-audit): 新增 --repos-file 批量审计模式与确定性汇总排名,单测 10/10

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
farmyobutu5233 2026-07-05 11:03:00 +00:00
parent 3b017d3cb6
commit c1a0e4cc61
4 changed files with 97 additions and 23 deletions

View File

@ -37,6 +37,10 @@ gitlink-cli auth login
python3 scripts/repro_audit.py --owner <owner> --repo <repo> --output-dir outputs
# 回写改进 tracking issue请先确认报告内容加 --apply
# 批量审计(实验室/课题组场景):清单每行 owner/repo# 为注释
python3 scripts/repro_audit.py --repos-file repos.txt --output-dir outputs
# 输出逐仓库报告 + repro-audit-summary.md 汇总排名(得分降序,退出码 2 表示存在 <70 分仓库
```
## 已在真实科研仓库验证

View File

@ -16,7 +16,7 @@
| 使用者 | 场景 | 价值 |
|--------|------|------|
| 课题组 | 论文投稿/开源发布前自查 | 逐项补齐复现要件,提升论文可信度 |
| 实验室管理者 | 批量审计组内科研仓库 | 统一学术规范(许可证/引用/数据说明) |
| 实验室管理者 | 批量审计组内科研仓库`--repos-file` 清单模式,输出得分排名汇总表) | 统一学术规范(许可证/引用/数据说明) |
| 期刊/会议 artifact 评审 | 快速初筛 | 评分卡作为客观初审依据 |
| CI 门禁 | 科研仓库发布流程 | 退出码 2 阻断复现缺口明显的发布 |

View File

@ -164,40 +164,89 @@ def render_report(owner, repo, ref, results):
return "\n".join(lines), total
def audit_repo(owner, repo, ref, out_dir, cli, apply_issue=False, echo=True):
"""审计单个仓库,落盘报告,返回总分。"""
entries = list_entries(owner, repo, ref, cli=cli)
names = [n for n, t in entries if t != "dir"]
dirs = [n for n, t in entries if t == "dir"]
_, readme_text = fetch_readme(owner, repo, ref, names, cli=cli)
releases = count_releases(owner, repo, cli=cli)
results = audit(names, dirs, readme_text, releases)
report, total = render_report(owner, repo, ref, results)
path = out_dir / f"repro-audit-{owner}-{repo}.md"
path.write_text(report, encoding="utf-8")
if echo:
print(report)
print(f"\n报告已保存:{path}", file=sys.stderr)
if apply_issue:
run_cli([
"issue", "+create", "--owner", owner, "--repo", repo,
"--title", f"[repro-audit] 复现性审计报告({total}/100",
"--body", report, "--format", "json",
], cli=cli)
print("已创建 tracking issue。", file=sys.stderr)
return total
def read_repos_file(path):
"""读取批量仓库清单:每行 owner/repo# 开头为注释。"""
repos = []
for line in Path(path).read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#"):
continue
owner, _, repo = line.partition("/")
if not owner or not repo:
raise ValueError(f"无效的仓库行(应为 owner/repo{line}")
repos.append((owner, repo))
return repos
def render_summary(rows):
"""批量审计汇总表(确定性:按得分降序、同分按名称)。"""
rows = sorted(rows, key=lambda r: (-r[1], r[0]))
lines = ["# 批量复现性审计汇总", "", "| 仓库 | 得分 | 等级 |", "|------|------|------|"]
for name, total in rows:
grade = next(g for t, g in GRADE if total >= t)
lines.append(f"| {name} | {total}/100 | {grade} |")
return "\n".join(lines) + "\n"
def main():
parser = argparse.ArgumentParser(description="科研项目复现性审计")
parser.add_argument("--owner", required=True)
parser.add_argument("--repo", required=True)
parser.add_argument("--owner")
parser.add_argument("--repo")
parser.add_argument("--repos-file", help="批量审计清单文件(每行 owner/repo# 注释)")
parser.add_argument("--ref", default="")
parser.add_argument("--apply", action="store_true", help="把报告作为 tracking issue 回写(默认 dry-run")
parser.add_argument("--output-dir", default="outputs")
parser.add_argument("--cli", default="gitlink-cli")
args = parser.parse_args()
entries = list_entries(args.owner, args.repo, args.ref, cli=args.cli)
names = [n for n, t in entries if t != "dir"]
dirs = [n for n, t in entries if t == "dir"]
_, readme_text = fetch_readme(args.owner, args.repo, args.ref, names, cli=args.cli)
releases = count_releases(args.owner, args.repo, cli=args.cli)
results = audit(names, dirs, readme_text, releases)
report, total = render_report(args.owner, args.repo, args.ref, results)
if not args.repos_file and not (args.owner and args.repo):
parser.error("需要 --owner 与 --repo或 --repos-file")
out_dir = Path(args.output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"repro-audit-{args.owner}-{args.repo}.md"
path.write_text(report, encoding="utf-8")
print(report)
print(f"\n报告已保存:{path}", file=sys.stderr)
if args.apply:
run_cli([
"issue", "+create", "--owner", args.owner, "--repo", args.repo,
"--title", f"[repro-audit] 复现性审计报告({total}/100",
"--body", report, "--format", "json",
], cli=args.cli)
print("已创建 tracking issue。", file=sys.stderr)
if args.repos_file:
rows = []
for owner, repo in read_repos_file(args.repos_file):
total = audit_repo(owner, repo, args.ref, out_dir, args.cli,
apply_issue=args.apply, echo=False)
rows.append((f"{owner}/{repo}", total))
summary = render_summary(rows)
summary_path = out_dir / "repro-audit-summary.md"
summary_path.write_text(summary, encoding="utf-8")
print(summary)
print(f"汇总已保存:{summary_path}", file=sys.stderr)
return 0 if all(t >= 70 for _, t in rows) else 2
total = audit_repo(args.owner, args.repo, args.ref, out_dir, args.cli,
apply_issue=args.apply)
return 0 if total >= 70 else 2

View File

@ -1,12 +1,13 @@
"""确定性回归护栏:同输入 → 同分 → 同等级。"""
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
from repro_audit import audit, render_report # noqa: E402
from repro_audit import audit, read_repos_file, render_report, render_summary # noqa: E402
GOOD_README = """# Project
@ -68,6 +69,26 @@ class AuditTest(unittest.TestCase):
for r in results:
self.assertTrue(r.advice, f"{r.name} 应给出修复建议")
def test_read_repos_file(self):
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False, encoding="utf-8") as f:
f.write("# 注释\n\nowner1/repo1\n owner2/repo2 \n")
path = f.name
self.assertEqual(read_repos_file(path), [("owner1", "repo1"), ("owner2", "repo2")])
def test_read_repos_file_invalid_line(self):
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False, encoding="utf-8") as f:
f.write("not-a-repo-line\n")
path = f.name
with self.assertRaises(ValueError):
read_repos_file(path)
def test_render_summary_sorted(self):
summary = render_summary([("o/low", 8), ("o/high", 92), ("o/mid", 58)])
rows = [line for line in summary.splitlines() if line.startswith("| o/")]
self.assertEqual([r.split(" | ")[0] for r in rows], ["| o/high", "| o/mid", "| o/low"])
self.assertIn("A可复现性良好", rows[0])
self.assertIn("D复现困难", rows[2])
if __name__ == "__main__":
unittest.main()