From c1a0e4cc61bbdff595f3a694bb70cddc15155ded Mon Sep 17 00:00:00 2001 From: farmyobutu5233 Date: Sun, 5 Jul 2026 11:03:00 +0000 Subject: [PATCH] =?UTF-8?q?feat(repro-audit):=20=E6=96=B0=E5=A2=9E=20--rep?= =?UTF-8?q?os-file=20=E6=89=B9=E9=87=8F=E5=AE=A1=E8=AE=A1=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E4=B8=8E=E7=A1=AE=E5=AE=9A=E6=80=A7=E6=B1=87=E6=80=BB?= =?UTF-8?q?=E6=8E=92=E5=90=8D=EF=BC=8C=E5=8D=95=E6=B5=8B=2010/10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- examples/research/repro-audit/README.md | 4 + .../repro-audit/docs/application-report.md | 2 +- .../repro-audit/scripts/repro_audit.py | 91 ++++++++++++++----- .../research/repro-audit/tests/test_audit.py | 23 ++++- 4 files changed, 97 insertions(+), 23 deletions(-) diff --git a/examples/research/repro-audit/README.md b/examples/research/repro-audit/README.md index 5d002cc..ea1fbe4 100644 --- a/examples/research/repro-audit/README.md +++ b/examples/research/repro-audit/README.md @@ -37,6 +37,10 @@ gitlink-cli auth login python3 scripts/repro_audit.py --owner --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 分仓库) ``` ## 已在真实科研仓库验证 diff --git a/examples/research/repro-audit/docs/application-report.md b/examples/research/repro-audit/docs/application-report.md index 5defad3..091d437 100644 --- a/examples/research/repro-audit/docs/application-report.md +++ b/examples/research/repro-audit/docs/application-report.md @@ -16,7 +16,7 @@ | 使用者 | 场景 | 价值 | |--------|------|------| | 课题组 | 论文投稿/开源发布前自查 | 逐项补齐复现要件,提升论文可信度 | -| 实验室管理者 | 批量审计组内科研仓库 | 统一学术规范(许可证/引用/数据说明) | +| 实验室管理者 | 批量审计组内科研仓库(`--repos-file` 清单模式,输出得分排名汇总表) | 统一学术规范(许可证/引用/数据说明) | | 期刊/会议 artifact 评审 | 快速初筛 | 评分卡作为客观初审依据 | | CI 门禁 | 科研仓库发布流程 | 退出码 2 阻断复现缺口明显的发布 | diff --git a/examples/research/repro-audit/scripts/repro_audit.py b/examples/research/repro-audit/scripts/repro_audit.py index 8f92024..a0dacfd 100644 --- a/examples/research/repro-audit/scripts/repro_audit.py +++ b/examples/research/repro-audit/scripts/repro_audit.py @@ -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 diff --git a/examples/research/repro-audit/tests/test_audit.py b/examples/research/repro-audit/tests/test_audit.py index 2143e23..762127f 100644 --- a/examples/research/repro-audit/tests/test_audit.py +++ b/examples/research/repro-audit/tests/test_audit.py @@ -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()