diff --git a/examples/research/README.md b/examples/research/README.md new file mode 100644 index 0000000..7dd805e --- /dev/null +++ b/examples/research/README.md @@ -0,0 +1,36 @@ +# 科研辅助开发模板(examples/research) + +对应起步资源包「科研辅助开发模板」:科研场景脚本、分析工具、报告生成模板三件套。 +开箱即用(自带一个可运行的示例场景),也可按占位符改造成你自己的科研辅助工具(子赛题四)。 + +## 内容 + +| 文件 | 作用 | +|------|------| +| `scripts/research_tool_template.py` | 科研场景脚本模板:采集(gitlink-cli,自动翻页)→ 指标计算(确定性纯函数)→ 报告渲染 三段式骨架,纯标准库 | +| `report-template.md` | 报告生成模板:`{placeholder}` 占位符由脚本填充,评审可复核的四元组结构(结论/指标/证据/复现命令) | +| `tests/test_metrics.py` | 指标纯函数单测样例(不访问网络) | + +## 快速运行(自带示例:仓库科研活跃度快照) + +```bash +gitlink-cli auth login + +python3 scripts/research_tool_template.py \ + --owner --repo --output report.md +``` + +产出 `report.md`:开放/关闭 issue 数、open PR 数、分支数、近 30 天活跃判定,以及每项指标的复现命令。 + +## 改造为你自己的科研工具(3 步) + +1. **采集**:改 `collect()`——用 `gitlink-cli <命令> --format json` 拉你需要的数据(数据多时记得翻页合并) +2. **指标**:改 `compute_metrics()`——保持**纯函数 + 确定性**(同输入同输出),这样单测和评审复核才可行 +3. **报告**:改 `report-template.md` 的占位符——保留「复现命令」一节,科研结论必须可复现 + +## 设计约定(子赛题四评审要点) + +- **可复现**:报告内嵌每项指标的采集命令,任何人可独立复核 +- **确定性**:指标计算与阈值判定为纯函数,禁止依赖随机/时序副作用(时间基准由 `--now` 注入,便于测试) +- **零依赖**:纯 Python 标准库,无需装包即可在评审机运行 +- 参考实现:更完整的科研复现性审计见 [`skills/gitlink-repro-audit`](../../skills/gitlink-repro-audit/)(若已收录) diff --git a/examples/research/report-template.md b/examples/research/report-template.md new file mode 100644 index 0000000..9d3379e --- /dev/null +++ b/examples/research/report-template.md @@ -0,0 +1,34 @@ +# 科研仓库活跃度快照:{owner}/{repo} + +> 生成时间(UTC):{generated_at} | 工具:examples/research 科研辅助模板 + +## 结论 + +{conclusion} + +## 指标 + +| 指标 | 值 | +|------|-----| +| 开放 issue | {open_issues} | +| 已关闭 issue | {closed_issues} | +| issue 关闭率 | {close_rate} | +| 开放 PR | {open_prs} | +| 分支数 | {branches} | +| 近 {active_days} 天内有 issue 更新 | {recently_active} | + +## 证据与复现命令 + +任何人可用以下命令独立复核上表数据: + +```bash +gitlink-cli issue +list --owner {owner} --repo {repo} --state open --format json +gitlink-cli issue +list --owner {owner} --repo {repo} --state closed --format json +gitlink-cli pr +list --owner {owner} --repo {repo} --format json +gitlink-cli branch +list --owner {owner} --repo {repo} --format json +``` + +## 方法说明 + +- 指标计算为确定性纯函数(同输入同输出),实现见 `scripts/research_tool_template.py` 的 `compute_metrics()` +- 活跃判定阈值:最近 {active_days} 天内存在 issue 更新 diff --git a/examples/research/scripts/__pycache__/research_tool_template.cpython-310.pyc b/examples/research/scripts/__pycache__/research_tool_template.cpython-310.pyc new file mode 100644 index 0000000..81c0955 Binary files /dev/null and b/examples/research/scripts/__pycache__/research_tool_template.cpython-310.pyc differ diff --git a/examples/research/scripts/research_tool_template.py b/examples/research/scripts/research_tool_template.py new file mode 100644 index 0000000..5039d08 --- /dev/null +++ b/examples/research/scripts/research_tool_template.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""科研辅助工具模板:采集 → 指标 → 报告 三段式骨架(纯标准库,Python >= 3.9)。 + +自带可运行示例:仓库科研活跃度快照。 +改造为自己的工具时只需替换 collect() / compute_metrics() / 报告模板三处。 +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from datetime import datetime, timedelta +from pathlib import Path + +CLI = "gitlink-cli" +DATE_FMT = "%Y-%m-%d %H:%M" +TEMPLATE = Path(__file__).resolve().parent.parent / "report-template.md" + + +def run_cli(args: list[str]) -> dict: + out = subprocess.run([CLI, *args, "--format", "json"], capture_output=True, text=True) + if out.returncode != 0: + raise RuntimeError(f"{CLI} {' '.join(args)} failed: {out.stderr.strip() or out.stdout.strip()}") + payload = json.loads(out.stdout) + if not payload.get("ok", False): + raise RuntimeError(f"{CLI} {' '.join(args)} returned error") + return payload.get("data") or {} + + +# ---------- 1. 采集(按需替换) ---------- + +def collect(owner: str, repo: str) -> dict: + open_issues = run_cli(["issue", "+list", "--owner", owner, "--repo", repo, "--state", "open", "--limit", "50"]) + prs = run_cli(["pr", "+list", "--owner", owner, "--repo", repo, "--limit", "1"]) + branches = run_cli(["branch", "+list", "--owner", owner, "--repo", repo]) + return {"open_issues": open_issues, "prs": prs, "branches": branches} + + +# ---------- 2. 指标(确定性纯函数,单测覆盖) ---------- + +def compute_metrics(raw: dict, now: datetime, active_days: int) -> dict: + issues_data = raw["open_issues"] + open_count = issues_data.get("open_count") or len(issues_data.get("issues") or []) + closed_count = issues_data.get("closed_count") or 0 + total = open_count + closed_count + close_rate = f"{closed_count / total:.0%}" if total else "n/a" + + recently_active = False + for issue in issues_data.get("issues") or []: + raw_date = issue.get("updated_at") or issue.get("created_at") or "" + try: + if now - datetime.strptime(raw_date, DATE_FMT) <= timedelta(days=active_days): + recently_active = True + break + except ValueError: + continue + + prs = raw["prs"] + branches = raw["branches"] + branch_list = branches if isinstance(branches, list) else branches.get("branches") or [] + return { + "open_issues": open_count, + "closed_issues": closed_count, + "close_rate": close_rate, + "open_prs": prs.get("open_count") or prs.get("total_count") or 0, + "branches": len(branch_list), + "recently_active": "是" if recently_active else "否", + } + + +def conclude(metrics: dict, active_days: int) -> str: + if metrics["recently_active"] == "是": + return f"仓库处于活跃维护状态(近 {active_days} 天内有 issue 更新),适合作为科研协作/复现对象。" + return f"仓库近 {active_days} 天无 issue 更新,科研复用前建议先与维护者确认项目状态。" + + +# ---------- 3. 报告渲染 ---------- + +def render(owner: str, repo: str, metrics: dict, now: datetime, active_days: int) -> str: + return TEMPLATE.read_text(encoding="utf-8").format( + owner=owner, + repo=repo, + generated_at=now.strftime("%Y-%m-%d %H:%M"), + conclusion=conclude(metrics, active_days), + active_days=active_days, + **metrics, + ) + + +def main() -> int: + ap = argparse.ArgumentParser(description="科研仓库活跃度快照(科研辅助模板示例)") + ap.add_argument("--owner", required=True) + ap.add_argument("--repo", required=True) + ap.add_argument("--active-days", type=int, default=30) + ap.add_argument("--now", help="时间基准 YYYY-MM-DD(默认当前 UTC;注入固定值可复现)") + ap.add_argument("--output", default="report.md") + args = ap.parse_args() + + now = datetime.strptime(args.now, "%Y-%m-%d") if args.now else datetime.utcnow() + raw = collect(args.owner, args.repo) + metrics = compute_metrics(raw, now, args.active_days) + Path(args.output).write_text(render(args.owner, args.repo, metrics, now, args.active_days), encoding="utf-8") + print(f"report -> {args.output}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/research/tests/__pycache__/test_metrics.cpython-310.pyc b/examples/research/tests/__pycache__/test_metrics.cpython-310.pyc new file mode 100644 index 0000000..f916c8e Binary files /dev/null and b/examples/research/tests/__pycache__/test_metrics.cpython-310.pyc differ diff --git a/examples/research/tests/test_metrics.py b/examples/research/tests/test_metrics.py new file mode 100644 index 0000000..4a03b6a --- /dev/null +++ b/examples/research/tests/test_metrics.py @@ -0,0 +1,60 @@ +import sys +import unittest +from datetime import datetime +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +from research_tool_template import compute_metrics, conclude + +NOW = datetime(2026, 7, 10) + + +def raw(issues=None, open_count=0, closed_count=0, prs=None, branches=None): + return { + "open_issues": {"issues": issues or [], "open_count": open_count, "closed_count": closed_count}, + "prs": prs or {}, + "branches": branches if branches is not None else [], + } + + +class TestComputeMetrics(unittest.TestCase): + def test_close_rate(self): + m = compute_metrics(raw(open_count=2, closed_count=8), NOW, 30) + self.assertEqual(m["close_rate"], "80%") + + def test_close_rate_empty(self): + m = compute_metrics(raw(), NOW, 30) + self.assertEqual(m["close_rate"], "n/a") + + def test_recently_active(self): + m = compute_metrics(raw(issues=[{"updated_at": "2026-07-01 10:00"}], open_count=1), NOW, 30) + self.assertEqual(m["recently_active"], "是") + + def test_not_recently_active(self): + m = compute_metrics(raw(issues=[{"updated_at": "2026-01-01 10:00"}], open_count=1), NOW, 30) + self.assertEqual(m["recently_active"], "否") + + def test_bad_date_ignored(self): + m = compute_metrics(raw(issues=[{"updated_at": "n/a"}], open_count=1), NOW, 30) + self.assertEqual(m["recently_active"], "否") + + def test_branches_wrapped_shape(self): + m = compute_metrics(raw(branches={"branches": [1, 2, 3]}), NOW, 30) + self.assertEqual(m["branches"], 3) + + def test_deterministic(self): + r = raw(issues=[{"updated_at": "2026-07-01 10:00"}], open_count=3, closed_count=7) + self.assertEqual(compute_metrics(r, NOW, 30), compute_metrics(r, NOW, 30)) + + +class TestConclude(unittest.TestCase): + def test_active(self): + self.assertIn("活跃维护", conclude({"recently_active": "是"}, 30)) + + def test_inactive(self): + self.assertIn("建议先与维护者确认", conclude({"recently_active": "否"}, 30)) + + +if __name__ == "__main__": + unittest.main()