Merge pull request 'feat(research): 新增科研辅助开发模板 examples/research(补齐起步资源包缺失项)' (#418) from Taoyouce/gitlink-cli:feat/research-template into master
This commit is contained in:
commit
fefcd70dd2
|
|
@ -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 <owner> --repo <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/)(若已收录)
|
||||
|
|
@ -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 更新
|
||||
Binary file not shown.
|
|
@ -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())
|
||||
Binary file not shown.
|
|
@ -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()
|
||||
Loading…
Reference in New Issue