feat(workflows): 新增 doc-sync-automation 中英文档一致性守护端到端工作流 #332

Merged
wbtiger merged 3 commits from Taoyouce/gitlink-cli:feat/doc-sync-workflow into master 2026-07-14 22:26:08 +08:00
6 changed files with 527 additions and 0 deletions

1
.gitignore vendored
View File

@ -1,3 +1,4 @@
gitlink-cli.exe
/gitlink-cli
__pycache__/

View File

@ -0,0 +1,83 @@
# 中英文档一致性守护工作流doc-sync-automation
把 [`gitlink-doc-sync` Skill](../../../skills/gitlink-doc-sync/SKILL.md)(中英文档一致性守护)包成**可直接运行的端到端工作流**
> **采集 → 检测 → 报告 → 回写**:用 `repo +tree` 按命名约定自动发现双语文档对,用 `file +view --raw` 拉取两版内容,做**确定性结构比对**(章节大纲 / 代码块 / 表格行数 / 版本号),输出分级(🔴严重 / 🟡中等 / 🟢轻微)漂移报告,并(仅在 `--apply` 时)用 `issue +create` 把报告作为 tracking issue 真实回写到 GitLink。
与仓库内已有能力的关系:`file` 命令组(文件读写)→ `gitlink-doc-sync` SkillAI 语义比对与翻译同步知识)→ **本工作流(确定性可复现闭环)**三层互为支撑而非重复Skill 负责需要语义理解的翻译同步,本工作流负责可进 CI 的确定性漂移检测。
## 架构图
```mermaid
flowchart LR
A["repo +tree<br/>仓库结构采集"] --> B["文档对自动发现<br/>README.md ⇄ README.zh-CN.md<br/>docs/*.md ⇄ docs/*.zh-CN.md"]
B --> C["file +view --raw ×2<br/>拉取双语版本内容"]
C --> D["确定性结构比对<br/>章节大纲 / 代码块 / 表格 / 版本号"]
D --> E["分级漂移报告<br/>🔴严重 / 🟡中等 / 🟢轻微"]
E -->|"dry-run默认"| F["Markdown 报告落盘<br/>退出码 0/2 → CI 门禁"]
E -->|"--apply"| G["issue +create<br/>回写 tracking issue"]
G -.->|"人工确认后"| H["gitlink-doc-sync Skill<br/>AI 语义翻译同步 → file +update → pr +create"]
```
## CI 门禁集成示例
利用退出码语义(`0` 无严重漂移 / `2` 存在严重漂移可直接作为发布门禁。GitLink 引擎(`.gitea/workflows`)示例:
```yaml
name: doc-sync-gate
on:
pull_request:
paths: ["README.md", "README.zh-CN.md", "docs/**"]
jobs:
doc-sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm install -g @gitlink-ai/cli
- env:
GITLINK_TOKEN: ${{ secrets.GITLINK_TOKEN }}
run: |
python3 examples/workflows/doc-sync-automation/scripts/doc_sync_workflow.py \
--owner ${{ github.repository_owner }} --repo ${{ github.event.repository.name }}
```
## 交付物
- `scripts/doc_sync_workflow.py`:文档对发现 + 漂移检测 + 报告 + tracking issue 回写纯标准库Python ≥3.9,零第三方依赖)
- `tests/test_drift.py`:确定性回归护栏(同输入 → 同发现 → 同退出语义)
- `docs/verification.md`:真实平台验证证据
- `examples/demo-outputs/`:对生产环境真实仓库运行的漂移报告
## 快速运行(默认 dry-run不写远端
```bash
npm install -g @gitlink-ai/cli
gitlink-cli auth login
python3 scripts/doc_sync_workflow.py --owner <owner> --repo <repo> --output-dir outputs
```
- 自动发现失败时手动指定文档对:`--pair README.md:README.zh-CN.md`(可多次)
- 指定分支:`--ref develop`
- 真实回写 tracking issue`--apply`(请先在自有仓库演练)
- 退出码:`0` 无严重漂移,`2` 存在严重漂移(可直接作为 CI 门禁)
## 已在真实平台验证
全部证据见 [`docs/verification.md`](docs/verification.md),要点:
| 验证 | 对象 | 结果 |
|------|------|------|
| 文档对自动发现 | 生产 gitlink.org.cn 真实仓库 | ✅ 自动识别 README.md ⇄ README.zh-CN.md |
| 漂移检测 | 本仓库 README 双语版本 | ✅ 检出真实漂移:代码块 32 vs 29、表格 43 vs 40 行 |
| `--apply` 真实回写 | 自有 fork | ✅ tracking issue 创建成功issue #1API 回执确认) |
| 单测 | `tests/test_drift.py` | ✅ 11/11 全绿 |
## 设计要点
- **确定性**:漂移检测只做结构比对(标题大纲、代码块数/行数、表格行数、版本号),同输入必同输出,可进 CI需要语义理解的翻译同步交给 `gitlink-doc-sync` Skill。
- **围栏内解析**:代码块内的 `#` 不会被误判为标题;表格分隔行不计入行数。
- **安全默认**dry-run 为默认行为,`--apply` 才写远端,且只创建 tracking issue可追溯、可关闭不直接改文档。
- **CLI 为唯一依赖**:所有平台交互都通过 `gitlink-cli``repo +tree` / `file +view` / `issue +create`),无直接 HTTP 调用。
> 依赖 `file` 快捷命令组PR #330);在其合并前可用 `--cli` 指向包含该命令的本地构建。

View File

@ -0,0 +1,38 @@
# 真实平台验证记录
验证日期2026-07-05平台生产环境 gitlink.org.cnCLI包含 `file` 命令组PR #330)的本地构建。
## 1. 文档对自动发现 + 漂移检测只读dry-run
```bash
python3 scripts/doc_sync_workflow.py --owner Taoyouce --repo gitlink-cli --output-dir outputs
```
结果(完整报告见 [`../examples/demo-outputs/doc-sync-Taoyouce-gitlink-cli.md`](../examples/demo-outputs/doc-sync-Taoyouce-gitlink-cli.md)
- `repo +tree` 拉取根目录后按命名约定**自动识别** `README.md ⇄ README.zh-CN.md`
- `file +view --raw` 拉取两版全文后检出**真实存在的漂移**
- 🟡 代码块数不一致:英文 32 个 vs 中文 29 个
- 🟡 表格行数不一致:英文 43 行 vs 中文 40 行(功能表滞后)
- 退出码 `0`(无严重漂移)
该漂移与本仓库实际情况一致:英文 README 的部分示例段落未同步到中文版。
## 2. `--apply` 真实回写 tracking issue
```bash
python3 scripts/doc_sync_workflow.py --owner Taoyouce --repo gitlink-cli --apply
```
- `issue +create` 成功在 fork 仓库创建 tracking issue
`[doc-sync] 中英文档漂移报告`issue #1
- 通过 `issue +list` API 回执确认:`project_issues_index=1`,正文为完整漂移报告
## 3. 确定性回归护栏
```bash
python3 tests/test_drift.py
# Ran 9 tests ... OK
```
覆盖:结构解析(标题/代码块/表格/版本号)、代码围栏内标题不误判、四类漂移全部检出、相同文档零发现、同输入同输出确定性、文档对发现约定、报告渲染。

View File

@ -0,0 +1,11 @@
# 文档一致性报告Taoyouce/gitlink-cliref: 默认分支)
## README.md ⇄ README.zh-CN.md
| 等级 | 类型 | 位置 | 说明 |
|------|------|------|------|
| 🟡 中等 | 代码块数不一致 | 全文代码示例 | README.md 有 32 个代码块README.zh-CN.md 有 29 个 |
| 🟡 中等 | 表格行数不一致 | 全文表格 | README.md 共 43 行表格README.zh-CN.md 共 40 行,疑似功能表滞后 |
---
*由 doc-sync-automation 工作流生成(确定性结构比对,同输入同输出)。*

View File

@ -0,0 +1,263 @@
#!/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())

View File

@ -0,0 +1,131 @@
"""确定性回归护栏:同输入 → 同发现 → 同退出语义。"""
import sys
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
from doc_sync_workflow import ( # noqa: E402
compare_structures,
discover_pairs,
parse_structure,
render_report,
)
EN = """# Title
## Features
| a | b |
|---|---|
| 1 | 2 |
| 3 | 4 |
## Install
Requires Go 1.26+.
```bash
make install
```
## Usage
```bash
run --help
```
"""
ZH = """# 标题
## 功能
| a | b |
|---|---|
| 1 | 2 |
## 安装
需要 Go 1.25+
```bash
make install
```
"""
class ParseStructureTest(unittest.TestCase):
def test_parse(self):
s = parse_structure(EN)
self.assertEqual([h[1] for h in s.headings], ["Title", "Features", "Install", "Usage"])
self.assertEqual(s.code_blocks, 2)
self.assertEqual(s.table_rows, 3) # 表头 1 行 + 数据 2 行
self.assertIn("1.26", s.versions)
def test_code_fence_content_not_parsed_as_heading(self):
text = "```bash\n# not a heading\n```\n## Real\n"
s = parse_structure(text)
self.assertEqual([h[1] for h in s.headings], ["Real"])
class CompareTest(unittest.TestCase):
def test_drift_detected(self):
findings = compare_structures(
"README.md", "README.zh-CN.md", parse_structure(EN), parse_structure(ZH)
)
categories = [f.category for f in findings]
self.assertIn("章节数不一致", categories) # 缺 Usage 节 → 严重
self.assertIn("代码块数不一致", categories) # 2 vs 1 → 中等
self.assertIn("表格行数不一致", categories) # 3 vs 2 → 中等
self.assertIn("版本号不一致", categories) # 1.26 vs 1.25 → 轻微
# 严重排在最前
self.assertEqual(findings[0].severity, "严重")
def test_identical_docs_no_findings(self):
findings = compare_structures(
"a.md", "b.md", parse_structure(EN), parse_structure(EN)
)
self.assertEqual(findings, [])
def test_deterministic(self):
f1 = compare_structures("a", "b", parse_structure(EN), parse_structure(ZH))
f2 = compare_structures("a", "b", parse_structure(EN), parse_structure(ZH))
self.assertEqual([vars(f) for f in f1], [vars(f) for f in f2])
class DiscoverTest(unittest.TestCase):
def test_discover(self):
names = ["README.md", "README.zh-CN.md", "LICENSE", "CONTRIBUTING.md"]
self.assertEqual(discover_pairs(names), [("README.md", "README.zh-CN.md")])
def test_no_pair(self):
self.assertEqual(discover_pairs(["README.md", "LICENSE"]), [])
def test_discover_generic_and_nested(self):
names = ["docs/guide.md", "docs/guide.zh-CN.md", "USAGE.md", "USAGE_zh.md", "NOTES.zh.md"]
self.assertEqual(
discover_pairs(names),
[("USAGE.md", "USAGE_zh.md"), ("docs/guide.md", "docs/guide.zh-CN.md")],
)
def test_translation_file_not_treated_as_base(self):
self.assertEqual(discover_pairs(["README.zh-CN.md", "README_zh.md"]), [])
class ReportTest(unittest.TestCase):
def test_report_contains_findings(self):
findings = compare_structures(
"README.md", "README.zh-CN.md", parse_structure(EN), parse_structure(ZH)
)
report = render_report("o", "r", "master", [("README.md", "README.zh-CN.md", findings)])
self.assertIn("🔴 严重", report)
self.assertIn("README.md ⇄ README.zh-CN.md", report)
def test_report_clean(self):
report = render_report("o", "r", "", [("a.md", "b.md", [])])
self.assertIn("无漂移", report)
if __name__ == "__main__":
unittest.main()