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