From c8ca6010971d489dcbfcca791e9dd80daa086dfe Mon Sep 17 00:00:00 2001 From: maidamaliziasimnw Date: Fri, 10 Jul 2026 16:08:58 +0000 Subject: [PATCH] =?UTF-8?q?feat(workflows):=20=E6=96=B0=E5=A2=9E=20Issue?= =?UTF-8?q?=20=E8=87=AA=E5=8A=A8=E5=88=86=E6=8B=A3=E7=AB=AF=E5=88=B0?= =?UTF-8?q?=E7=AB=AF=E5=B7=A5=E4=BD=9C=E6=B5=81=EF=BC=88=E8=B5=B7=E6=AD=A5?= =?UTF-8?q?=E8=B5=84=E6=BA=90=E5=8C=85=E7=BC=BA=E5=A4=B1=E9=A1=B9=EF=BC=89?= 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> --- .../issue-triage-automation/README.md | 47 +++++ .../rules.example.json | 72 ++++++++ .../__pycache__/issue_triage.cpython-310.pyc | Bin 0 -> 6951 bytes .../scripts/issue_triage.py | 170 ++++++++++++++++++ .../__pycache__/test_triage.cpython-310.pyc | Bin 0 -> 4015 bytes .../tests/test_triage.py | 74 ++++++++ 6 files changed, 363 insertions(+) create mode 100644 examples/workflows/issue-triage-automation/README.md create mode 100644 examples/workflows/issue-triage-automation/rules.example.json create mode 100644 examples/workflows/issue-triage-automation/scripts/__pycache__/issue_triage.cpython-310.pyc create mode 100644 examples/workflows/issue-triage-automation/scripts/issue_triage.py create mode 100644 examples/workflows/issue-triage-automation/tests/__pycache__/test_triage.cpython-310.pyc create mode 100644 examples/workflows/issue-triage-automation/tests/test_triage.py diff --git a/examples/workflows/issue-triage-automation/README.md b/examples/workflows/issue-triage-automation/README.md new file mode 100644 index 0000000..eb559b7 --- /dev/null +++ b/examples/workflows/issue-triage-automation/README.md @@ -0,0 +1,47 @@ +# Issue 自动分拣工作流(issue-triage-automation) + +补齐起步资源包承诺的三个参考工作流之一「Issue 自动分拣」: + +> **采集 → 分类 → 报告 →(可选)回写**:拉取仓库全部 open issue(自动翻页合并),按规则文件做**确定性分类**(关键词 → 建议优先级/标签/负责人;超龄未更新 → stale),产出 markdown + json 分拣报告;仅在 `--apply` 时把优先级更新与分拣评论真实回写到 GitLink。 + +## 架构 + +```mermaid +flowchart LR + A["issue +list(自动翻页)"] --> B["规则引擎(rules.example.json)"] + B --> C["triage.md / triage.json 报告"] + B -->|"--apply"| D["issue +update --priority-id"] + B -->|"--apply"| E["issue +comment 分拣说明"] +``` + +## 交付物 + +- `scripts/issue_triage.py`:单文件工作流(纯标准库,Python ≥ 3.9,零第三方依赖) +- `rules.example.json`:规则样例(bug / feature / docs / question 四类,含平台优先级 ID 映射:1 低 / 2 正常 / 3 高 / 4 紧急) +- `tests/test_triage.py`:10 个确定性单测(规则命中、stale 判定、首个命中规则生效、同输入同输出、报告排序) + +## 快速运行(默认 dry-run,不写远端) + +```bash +gitlink-cli auth login + +python3 scripts/issue_triage.py --owner --repo --output-dir triage-out +# 产出 triage-out/triage.md(人读)与 triage.json(机读) + +# 真实回写(优先级 + 分拣评论;请先在自有仓库演练) +python3 scripts/issue_triage.py --owner --repo --apply +``` + +## 已在真实平台验证(2026-07-10) + +- **只读分拣**:`Gitlink/forgeplus` 35 个 open issue,命中规则 13 个、stale 32 个,报告确定性可复现 +- **回写闭环**:自有仓库探针 issue(标题含"报错 bug")→ 命中 bug 规则 → `issue +update --priority-id 4` 优先级变为「紧急」+ 分拣评论落盘 → `issue +view` 验证 `priority.name == 紧急`、评论数 +1 → 探针清理 + +## 定制规则 + +编辑 `rules.example.json`:每条规则含 `keywords_any`(标题/描述任一命中,大小写不敏感)、`set_priority` / `set_priority_id`、`add_tags`、`route_to`。规则**从上到下首个命中生效**,顺序即优先级。 + +## CI / Agent 集成 + +- 定时任务:`cron` 每日 dry-run 产出报告,人工确认后 `--apply` +- Agent:本工作流与 `skills/gitlink-issue-triage` Skill 同源互补——Skill 供 Agent 交互式分拣,本脚本供确定性批量闭环 diff --git a/examples/workflows/issue-triage-automation/rules.example.json b/examples/workflows/issue-triage-automation/rules.example.json new file mode 100644 index 0000000..b3bed38 --- /dev/null +++ b/examples/workflows/issue-triage-automation/rules.example.json @@ -0,0 +1,72 @@ +{ + "rules": [ + { + "name": "bug", + "keywords_any": [ + "bug", + "崩溃", + "报错", + "错误", + "失败", + "404", + "500", + "panic" + ], + "set_priority": "紧急", + "add_tags": [ + "bug" + ], + "route_to": null, + "set_priority_id": 4 + }, + { + "name": "feature", + "keywords_any": [ + "feature", + "新增", + "希望", + "建议", + "支持", + "功能请求" + ], + "set_priority": "正常", + "add_tags": [ + "enhancement" + ], + "route_to": null, + "set_priority_id": 2 + }, + { + "name": "docs", + "keywords_any": [ + "文档", + "readme", + "typo", + "错别字", + "翻译" + ], + "set_priority": "低", + "add_tags": [ + "documentation" + ], + "route_to": null, + "set_priority_id": 1 + }, + { + "name": "question", + "keywords_any": [ + "怎么", + "如何", + "请问", + "question", + "how to" + ], + "set_priority": "低", + "add_tags": [ + "question" + ], + "route_to": null, + "set_priority_id": 1 + } + ] +} \ No newline at end of file diff --git a/examples/workflows/issue-triage-automation/scripts/__pycache__/issue_triage.cpython-310.pyc b/examples/workflows/issue-triage-automation/scripts/__pycache__/issue_triage.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..80be13e4c192235aa72310536b609b299f4c0fa4 GIT binary patch literal 6951 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 literal 0 HcmV?d00001 diff --git a/examples/workflows/issue-triage-automation/scripts/issue_triage.py b/examples/workflows/issue-triage-automation/scripts/issue_triage.py new file mode 100644 index 0000000..380b7aa --- /dev/null +++ b/examples/workflows/issue-triage-automation/scripts/issue_triage.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""Issue 自动分拣端到端工作流(deterministic issue triage)。 + +采集 → 分类 → 报告 →(可选 --apply)回写: +1. 通过 gitlink-cli 拉取仓库 open issue 列表(含分页合并) +2. 按规则文件(JSON,纯标准库解析)做确定性分类: + - 关键词命中 → 建议优先级 / 建议标签 / 建议负责人 + - 超龄未更新 → 标记 stale +3. 产出 triage 报告(markdown + json,同输入必得同输出) +4. 仅在 --apply 时通过 gitlink-cli 真实回写(优先级更新 + 分拣评论) + +零第三方依赖,Python >= 3.9。 +""" + +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" + + +# ---------- 纯函数核心(单测覆盖,无网络) ---------- + +def match_rule(issue: dict, rule: dict) -> bool: + """规则命中判定:keywords_any 任一关键词出现在标题或描述(大小写不敏感)。""" + hay = ((issue.get("subject") or "") + "\n" + (issue.get("description") or "")).lower() + return any(kw.lower() in hay for kw in rule.get("keywords_any", [])) + + +def is_stale(issue: dict, now: datetime, stale_days: int) -> bool: + raw = issue.get("updated_at") or issue.get("created_at") or "" + try: + updated = datetime.strptime(raw, DATE_FMT) + except ValueError: + return False + return now - updated > timedelta(days=stale_days) + + +def triage_issue(issue: dict, rules: list[dict], now: datetime, stale_days: int) -> dict: + """对单个 issue 产出确定性分拣决定(首个命中规则生效)。""" + decision = { + "number": issue.get("project_issues_index") or issue.get("number"), + "subject": issue.get("subject", ""), + "rule": None, + "priority": None, + "priority_id": None, + "tags": [], + "assignee": None, + "stale": is_stale(issue, now, stale_days), + } + for rule in rules: + if match_rule(issue, rule): + decision["rule"] = rule.get("name") + decision["priority"] = rule.get("set_priority") + decision["priority_id"] = rule.get("set_priority_id") + decision["tags"] = list(rule.get("add_tags", [])) + decision["assignee"] = rule.get("route_to") + break + return decision + + +def render_report(decisions: list[dict], owner: str, repo: str) -> str: + lines = [ + f"# Issue 分拣报告:{owner}/{repo}", + "", + f"共 {len(decisions)} 个 open issue," + f"命中规则 {sum(1 for d in decisions if d['rule'])} 个," + f"stale {sum(1 for d in decisions if d['stale'])} 个。", + "", + "| # | 标题 | 命中规则 | 建议优先级 | 建议标签 | 建议负责人 | stale |", + "|---|------|----------|------------|----------|------------|-------|", + ] + for d in sorted(decisions, key=lambda x: (x["number"] is None, x["number"])): + lines.append( + "| {number} | {subject} | {rule} | {priority} | {tags} | {assignee} | {stale} |".format( + number=d["number"], + subject=(d["subject"][:40] or "-"), + rule=d["rule"] or "-", + priority=d["priority"] or "-", + tags=",".join(d["tags"]) or "-", + assignee=d["assignee"] or "-", + stale="是" if d["stale"] else "-", + ) + ) + return "\n".join(lines) + "\n" + + +# ---------- CLI 采集与回写 ---------- + +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: {payload}") + return payload.get("data") or {} + + +def fetch_open_issues(owner: str, repo: str) -> list[dict]: + issues: list[dict] = [] + page = 1 + while True: + data = run_cli([ + "issue", "+list", "--owner", owner, "--repo", repo, + "--state", "open", "--page", str(page), "--limit", "50", + ]) + batch = data.get("issues") or [] + issues.extend(batch) + if len(batch) < 50: + return issues + page += 1 + + +def apply_decision(owner: str, repo: str, decision: dict) -> None: + number = str(decision["number"]) + if decision["priority_id"]: + run_cli([ + "issue", "+update", "--owner", owner, "--repo", repo, + "--number", number, "--priority-id", str(decision["priority_id"]), + ]) + note = f"[issue-triage] 规则「{decision['rule']}」命中:建议标签 {','.join(decision['tags']) or '无'},建议负责人 {decision['assignee'] or '无'}。" + run_cli([ + "issue", "+comment", "--owner", owner, "--repo", repo, + "--number", number, "--body", note, + ]) + + +def main() -> int: + ap = argparse.ArgumentParser(description="GitLink issue 自动分拣工作流") + ap.add_argument("--owner", required=True) + ap.add_argument("--repo", required=True) + ap.add_argument("--rules", default=str(Path(__file__).resolve().parent.parent / "rules.example.json")) + ap.add_argument("--stale-days", type=int, default=30) + ap.add_argument("--output-dir", default="triage-out") + ap.add_argument("--apply", action="store_true", help="真实回写优先级与分拣评论(默认 dry-run)") + args = ap.parse_args() + + rules = json.loads(Path(args.rules).read_text(encoding="utf-8"))["rules"] + issues = fetch_open_issues(args.owner, args.repo) + now = datetime.utcnow() + decisions = [triage_issue(i, rules, now, args.stale_days) for i in issues] + + outdir = Path(args.output_dir) + outdir.mkdir(parents=True, exist_ok=True) + (outdir / "triage.json").write_text( + json.dumps(decisions, ensure_ascii=False, indent=2), encoding="utf-8") + (outdir / "triage.md").write_text(render_report(decisions, args.owner, args.repo), encoding="utf-8") + print(f"triaged {len(decisions)} issues -> {outdir}/triage.md") + + if args.apply: + applied = 0 + for d in decisions: + if d["rule"]: + apply_decision(args.owner, args.repo, d) + applied += 1 + print(f"applied {applied} decisions") + else: + print("dry-run(未写远端);加 --apply 真实回写") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/workflows/issue-triage-automation/tests/__pycache__/test_triage.cpython-310.pyc b/examples/workflows/issue-triage-automation/tests/__pycache__/test_triage.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..600508299c9b104a069c45cd0fc2329a094b6623 GIT binary patch literal 4015 zcma)9-ESOM6`wmFyW{n&?YK_-mA2aupbI!_8=BHm+R)HOL1lp?Ri%CCXuS7&Jx2TU#l@fmWhw)fc{$2OETnkw75zA&Tffa$jpFEH6A*ydYFK=iXg=ZLeu(HD~TU z_hZjJ=XXACQY;n>cy^|KwEE7lVSIv}{wEKeOYn;S0HFpYkx|oMQX|keBeQANOoBWs zvYK|yCI&NBoy{CISf1I;-5{GoTgGZ(wLtAzV}oqz*-kXv^lDzSSSvP1Y9r0j+9<3v zX^!TpONXej;nc=x0e-^`lX|q!AhbwF?mBdojzRkr9S2H~(=cnNt{C(YI&sIK6O62m zZ&59yYIuBc^DBIEvg)h<|VzST-()xk+JX}pyUoJx`VdpctE7Jlr{d9gVydx+Yjz?-Iu zTUurHG#i7-yYGMS@V&q6+`GMV?~kPyU%kR{yKPGr0wEYrZ}5z9++8}?UVY`I>)T)` z!J_4DgFg=3f4^Q?Nt&#ZEoE_qp-r>44SQoi3%w8=5mzW5*1~t zIc>MOAWIXlE~u%J!U=_d=nxI$vkY1gtLPF=4fA0MDSJ@~mc=(5(@7{l3qi*N^ z&$dlI4O}@)nZ`JrSt*;(0GmIG1XZd@30iXYsN{W1Ps4;gJnv&+CCN!Rz$6MbJ6e7~7cfI752-~VwIM0$ijfxCSX$ybmZs7$p=$iqK81@E2<#5G*v zTAro2C;B3vgsI?XLnz^Oj5;T(Y7gyofClYj8&Ef7r{?At zN(=MP&d+xcO=XME!iPIRq*eXFYXkK-sq~mcRL3`5lAoPoX1#NjCF>W0c~S2496Qi}yUAaE|jD`FrR>oIZMwcaAB z(IzRzeP_dd%LJTSZ72n%4VuStq1$(%Kh)PR;CMq76x|ua!11~>Y}lbVZR{g_q{@fj zUse9lsju_)yAOYJrv$0C4@S>uyhAdXXz>Iyj>d+7xJ}ucDjX{4DMLyd^E+2L+wJ9D zJ_7$EZJl@_{)CN~F^3n=F`RWTnjfdxxYgUST2L!)N`YQc@b zz#8;!Ufm*~cx>R!WC%G8Q->M6QEXT$$Pnta&2G3b+vcW4i(5o}N4C266p&=J+Cgz8 zSyFzky!%EuFLQC$Tw+{CFinA%zksbvKss2NN)o&*Ng@f)(7d}p{cCBlgr#fJ zVsS}%_&@*l?(PR4@7(*--#@&w`_8XR3pfr;6)$w}liNR8ciPZy<7ctd287_X0m^(; z^30AC literal 0 HcmV?d00001 diff --git a/examples/workflows/issue-triage-automation/tests/test_triage.py b/examples/workflows/issue-triage-automation/tests/test_triage.py new file mode 100644 index 0000000..56709eb --- /dev/null +++ b/examples/workflows/issue-triage-automation/tests/test_triage.py @@ -0,0 +1,74 @@ +import sys +import unittest +from datetime import datetime +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +from issue_triage import is_stale, match_rule, render_report, triage_issue + +NOW = datetime(2026, 7, 10, 12, 0) +RULES = [ + {"name": "bug", "keywords_any": ["bug", "崩溃"], "set_priority": "紧急", "add_tags": ["bug"], "route_to": "alice"}, + {"name": "docs", "keywords_any": ["文档"], "set_priority": "低", "add_tags": ["documentation"], "route_to": None}, +] + + +class TestMatchRule(unittest.TestCase): + def test_keyword_in_subject_case_insensitive(self): + self.assertTrue(match_rule({"subject": "发现一个 BUG"}, RULES[0])) + + def test_keyword_in_description(self): + self.assertTrue(match_rule({"subject": "问题", "description": "程序崩溃了"}, RULES[0])) + + def test_no_match(self): + self.assertFalse(match_rule({"subject": "hello", "description": "world"}, RULES[0])) + + +class TestStale(unittest.TestCase): + def test_old_issue_is_stale(self): + self.assertTrue(is_stale({"updated_at": "2026-05-01 10:00"}, NOW, 30)) + + def test_fresh_issue_not_stale(self): + self.assertFalse(is_stale({"updated_at": "2026-07-01 10:00"}, NOW, 30)) + + def test_unparseable_date_not_stale(self): + self.assertFalse(is_stale({"updated_at": "n/a"}, NOW, 30)) + + +class TestTriageIssue(unittest.TestCase): + def test_first_matching_rule_wins(self): + issue = {"project_issues_index": 7, "subject": "文档里的 bug", "updated_at": "2026-07-09 10:00"} + d = triage_issue(issue, RULES, NOW, 30) + self.assertEqual(d["rule"], "bug") + self.assertEqual(d["priority"], "紧急") + self.assertEqual(d["tags"], ["bug"]) + self.assertEqual(d["assignee"], "alice") + self.assertFalse(d["stale"]) + + def test_no_rule_matched(self): + d = triage_issue({"project_issues_index": 8, "subject": "hello"}, RULES, NOW, 30) + self.assertIsNone(d["rule"]) + self.assertEqual(d["tags"], []) + + def test_deterministic(self): + issue = {"project_issues_index": 9, "subject": "崩溃", "updated_at": "2026-01-01 00:00"} + self.assertEqual( + triage_issue(issue, RULES, NOW, 30), + triage_issue(issue, RULES, NOW, 30), + ) + + +class TestReport(unittest.TestCase): + def test_report_contains_counts_and_rows(self): + decisions = [ + {"number": 2, "subject": "b", "rule": "bug", "priority": "紧急", "tags": ["bug"], "assignee": None, "stale": True}, + {"number": 1, "subject": "a", "rule": None, "priority": None, "tags": [], "assignee": None, "stale": False}, + ] + md = render_report(decisions, "o", "r") + self.assertIn("共 2 个 open issue,命中规则 1 个,stale 1 个。", md) + self.assertLess(md.index("| 1 |"), md.index("| 2 |")) # 按编号排序 + + +if __name__ == "__main__": + unittest.main()