gitlink-cli/examples/workflows/doc-sync-automation/scripts/doc_sync_workflow.py

264 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""中英文档一致性守护工作流doc-sync-automation
采集 → 检测 → 报告 → 回写(可选):
1. 采集:通过 gitlink-cli 发现双语文档对并拉取两版内容
2. 检测:确定性结构比对(章节大纲 / 代码块 / 表格行数 / 版本号)
3. 报告:输出分级(严重/中等/轻微Markdown 漂移报告
4. 回写:--apply 时把报告作为 tracking issue 提交到 GitLink
纯标准库实现Python >= 3.9gitlink-cli 为唯一外部依赖。
默认 dry-run不写远端。
"""
import argparse
import json
import re
import subprocess
import sys
from dataclasses import dataclass, field
from pathlib import Path
# 翻译文档命名约定:主文档 X.md -> X{后缀}
TRANSLATION_SUFFIXES = [".zh-CN.md", "_zh.md", ".zh.md", "-zh.md"]
SEVERITY_ORDER = {"严重": 0, "中等": 1, "轻微": 2}
SEVERITY_ICON = {"严重": "🔴", "中等": "🟡", "轻微": "🟢"}
@dataclass
class Finding:
severity: str # 严重 | 中等 | 轻微
category: str
location: str
detail: str
@dataclass
class DocStructure:
headings: list = field(default_factory=list) # [(level, text)]
code_blocks: int = 0
code_lines: int = 0
table_rows: int = 0
versions: list = field(default_factory=list) # 形如 Go 1.26 / v0.2.0 的版本号
def run_cli(args, cli="gitlink-cli"):
"""调用 gitlink-cli 并返回 stdout 文本。"""
result = subprocess.run(
[cli, *args], capture_output=True, text=True, check=False
)
if result.returncode != 0:
raise RuntimeError(
f"gitlink-cli {' '.join(args)} 失败: {result.stderr.strip() or result.stdout.strip()}"
)
return result.stdout
def fetch_file(owner, repo, path, ref, cli="gitlink-cli"):
args = ["file", "+view", "--owner", owner, "--repo", repo, "--path", path, "--raw"]
if ref:
args += ["--ref", ref]
return run_cli(args, cli=cli)
def list_entries(owner, repo, ref, path="", cli="gitlink-cli"):
args = ["repo", "+tree", "--owner", owner, "--repo", repo, "--format", "json"]
if ref:
args += ["--ref", ref]
if path:
args += ["--path", path]
out = run_cli(args, cli=cli)
payload = json.loads(out)
data = payload.get("data", payload)
if isinstance(data, str):
data = json.loads(data)
entries = data.get("entries", data) if isinstance(data, dict) else data
names = []
if isinstance(entries, list):
for e in entries:
if isinstance(e, dict) and e.get("name"):
names.append(e["name"])
return names
def discover_pairs(names):
"""按命名约定从文件名(可含路径前缀)列表中发现文档对。"""
nameset = set(names)
pairs = []
for name in sorted(nameset):
if not name.endswith(".md") or any(name.endswith(s) for s in TRANSLATION_SUFFIXES):
continue
stem = name[: -len(".md")]
for suffix in TRANSLATION_SUFFIXES:
translation = stem + suffix
if translation in nameset:
pairs.append((name, translation))
break
return pairs
VERSION_RE = re.compile(r"\b(?:go|node(?:\.js)?|python|v)\s?(\d+\.\d+(?:\.\d+)?)\b", re.I)
def parse_structure(text):
"""提取文档结构:标题大纲、代码块、表格行、版本号。"""
s = DocStructure()
in_code = False
code_lines = 0
for line in text.splitlines():
stripped = line.strip()
if stripped.startswith("```"):
if in_code:
s.code_blocks += 1
s.code_lines += code_lines
code_lines = 0
in_code = not in_code
continue
if in_code:
code_lines += 1
continue
m = re.match(r"^(#{1,6})\s+(.*)$", stripped)
if m:
s.headings.append((len(m.group(1)), m.group(2).strip()))
continue
if stripped.startswith("|") and stripped.endswith("|") and not re.match(r"^\|[\s:|-]+\|$", stripped):
s.table_rows += 1
s.versions.extend(v for v in VERSION_RE.findall(line))
return s
def compare_structures(base_path, trans_path, base, trans):
"""确定性漂移检测,返回 Finding 列表。"""
findings = []
# 1. 章节数量漂移(结构不对齐 = 严重信号)
b_top = [h for h in base.headings if h[0] <= 2]
t_top = [h for h in trans.headings if h[0] <= 2]
if len(b_top) != len(t_top):
more, fewer = (base_path, trans_path) if len(b_top) > len(t_top) else (trans_path, base_path)
findings.append(Finding(
"严重", "章节数不一致", "一级/二级标题",
f"{more}{max(len(b_top), len(t_top))} 节,{fewer} 只有 {min(len(b_top), len(t_top))} 节,疑似缺失章节",
))
# 2. 代码块漂移(示例不同步 = 中等)
if base.code_blocks != trans.code_blocks:
findings.append(Finding(
"中等", "代码块数不一致", "全文代码示例",
f"{base_path}{base.code_blocks} 个代码块,{trans_path}{trans.code_blocks}",
))
elif abs(base.code_lines - trans.code_lines) > max(5, base.code_lines // 20):
findings.append(Finding(
"中等", "代码行数漂移", "全文代码示例",
f"代码行数 {base.code_lines} vs {trans.code_lines},差异超过 5%",
))
# 3. 表格行数漂移(功能表滞后 = 中等)
if base.table_rows != trans.table_rows:
findings.append(Finding(
"中等", "表格行数不一致", "全文表格",
f"{base_path}{base.table_rows} 行表格,{trans_path}{trans.table_rows} 行,疑似功能表滞后",
))
# 4. 版本号漂移(轻微)
b_ver, t_ver = sorted(set(base.versions)), sorted(set(trans.versions))
if b_ver != t_ver:
only_b = [v for v in b_ver if v not in t_ver]
only_t = [v for v in t_ver if v not in b_ver]
findings.append(Finding(
"轻微", "版本号不一致", "安装/依赖说明",
f"{base_path} 出现: {only_b or ''};仅 {trans_path} 出现: {only_t or ''}",
))
findings.sort(key=lambda f: SEVERITY_ORDER[f.severity])
return findings
def render_report(owner, repo, ref, results):
lines = [f"# 文档一致性报告:{owner}/{repo}ref: {ref or '默认分支'}", ""]
total = sum(len(f) for _, _, f in results)
if total == 0:
lines.append("✅ 所有文档对结构一致,未检测到漂移。")
for base_path, trans_path, findings in results:
lines.append(f"## {base_path}{trans_path}")
lines.append("")
if not findings:
lines.append("✅ 无漂移。")
lines.append("")
continue
lines.append("| 等级 | 类型 | 位置 | 说明 |")
lines.append("|------|------|------|------|")
for f in findings:
lines.append(f"| {SEVERITY_ICON[f.severity]} {f.severity} | {f.category} | {f.location} | {f.detail} |")
lines.append("")
lines.append("---")
lines.append("*由 doc-sync-automation 工作流生成(确定性结构比对,同输入同输出)。*")
return "\n".join(lines)
def create_tracking_issue(owner, repo, report, cli="gitlink-cli"):
out = run_cli([
"issue", "+create", "--owner", owner, "--repo", repo,
"--title", "[doc-sync] 中英文档漂移报告",
"--body", report,
"--format", "json",
], cli=cli)
return out
def main():
parser = argparse.ArgumentParser(description="中英文档一致性守护工作流")
parser.add_argument("--owner", required=True)
parser.add_argument("--repo", required=True)
parser.add_argument("--ref", default="")
parser.add_argument("--pair", action="append", default=[],
help="手动指定文档对,格式 base.md:translation.md可多次")
parser.add_argument("--apply", action="store_true",
help="把漂移报告作为 tracking issue 回写到 GitLink默认 dry-run")
parser.add_argument("--output-dir", default="outputs")
parser.add_argument("--cli", default="gitlink-cli")
args = parser.parse_args()
if args.pair:
pairs = [tuple(p.split(":", 1)) for p in args.pair]
else:
names = list_entries(args.owner, args.repo, args.ref, cli=args.cli)
if "docs" in names:
names += [
f"docs/{n}"
for n in list_entries(args.owner, args.repo, args.ref, path="docs", cli=args.cli)
]
pairs = discover_pairs(names)
if not pairs:
print("未发现双语文档对(可用 --pair 手动指定)", file=sys.stderr)
return 1
results = []
for base_path, trans_path in pairs:
base_text = fetch_file(args.owner, args.repo, base_path, args.ref, cli=args.cli)
trans_text = fetch_file(args.owner, args.repo, trans_path, args.ref, cli=args.cli)
findings = compare_structures(
base_path, trans_path, parse_structure(base_text), parse_structure(trans_text)
)
results.append((base_path, trans_path, findings))
report = render_report(args.owner, args.repo, args.ref, results)
out_dir = Path(args.output_dir)
out_dir.mkdir(parents=True, exist_ok=True)
report_path = out_dir / f"doc-sync-{args.owner}-{args.repo}.md"
report_path.write_text(report, encoding="utf-8")
print(report)
print(f"\n报告已保存:{report_path}", file=sys.stderr)
severe = sum(1 for _, _, fs in results for f in fs if f.severity == "严重")
if args.apply and any(fs for _, _, fs in results):
create_tracking_issue(args.owner, args.repo, report, cli=args.cli)
print("已创建 tracking issue。", file=sys.stderr)
return 2 if severe else 0
if __name__ == "__main__":
sys.exit(main())