From 2e7a11afad06d7f991cb2b4200bdd3e925075b23 Mon Sep 17 00:00:00 2001
From: saladday <1203511142@qq.com>
Date: Wed, 7 Oct 2026 21:24:50 +0800
Subject: [PATCH] fix(ci): restore structured Feishu review cards
---
.github/workflows/ci-review.yml | 6 +--
docs/maintainers.md | 2 +-
docs/zh/maintainers.md | 4 +-
scripts/ci_review.py | 96 ++++++++++++++++++++-------------
scripts/ci_review_test.py | 56 +++++++++++++++----
5 files changed, 112 insertions(+), 52 deletions(-)
diff --git a/.github/workflows/ci-review.yml b/.github/workflows/ci-review.yml
index 2f328e429..2bc1069bd 100644
--- a/.github/workflows/ci-review.yml
+++ b/.github/workflows/ci-review.yml
@@ -48,7 +48,7 @@ jobs:
persist-credentials: false
- name: Prepare the report schema
id: schema
- run: python3 scripts/ci_review.py schema >> "$GITHUB_OUTPUT"
+ run: python3 scripts/ci_review.py schema "${{ matrix.kind }}" >> "$GITHUB_OUTPUT"
- name: Review with Claude Code
id: llm
continue-on-error: true
@@ -78,7 +78,7 @@ jobs:
${{ matrix.instructions }}
- 按 JSON schema 返回中文报告。status 为 ok(未发现问题)、issues(发现有证据的问题)或 incomplete(审查失败、范围未检查完整或检查结果尚未完成)。有问题且仍有未检查项时,用 issues 并在 summary 中说明缺项。summary 无问题时简短,有问题时保留依据和建议,遵守 schema 的长度限制。
+ 按 JSON schema 返回中文报告。status 为 ok(未发现问题)、issues(发现有证据的问题)或 incomplete(审查失败、范围未检查完整或检查结果尚未完成)。有问题且仍有未检查项时,用 issues 并在对应字段中说明缺项。按 schema 把各项结论分别填入字段,不要重复标题;changes 用 1–3 条 Markdown 列表,其他字段无问题时一句话,有问题时保留依据和建议,遵守各字段长度限制。
围绕本次 diff 和受影响的文档展开,证据充分后输出结果,避免重复核对同一结论。
只读审查,不修改仓库,不触发新的 CI,不发送消息。两份报告会由后续 job 合并发送。
claude_args: >-
@@ -89,7 +89,7 @@ jobs:
env:
REVIEW_OUTCOME: ${{ steps.llm.outcome }}
REVIEW_RESULT: ${{ steps.llm.outputs.structured_output }}
- run: python3 scripts/ci_review.py collect "$RUNNER_TEMP/review-${{ matrix.kind }}.json"
+ run: python3 scripts/ci_review.py collect "${{ matrix.kind }}" "$RUNNER_TEMP/review-${{ matrix.kind }}.json"
- uses: actions/upload-artifact@v6
if: always()
with:
diff --git a/docs/maintainers.md b/docs/maintainers.md
index 7f3ac6626..a410ad2d1 100644
--- a/docs/maintainers.md
+++ b/docs/maintainers.md
@@ -194,7 +194,7 @@ Use **Actions → core-check → Run workflow** for a manual full check. For a t
The `CI review and Feishu notification` workflow runs after a PR merges into main. A matrix runs **Code review** and **Docs review** in independent LLM contexts with `fail-fast: false`. Both read the merged commit and PR diff. Code review checks implementation, repository rules and existing CI results; it does not start another test run. Docs review checks changed behavior against documentation even when no docs changed. When docs change, it also checks contradictions, duplicated facts, topic ownership under CONTRIBUTING, and English/Chinese agreement. Findings include file and line references, supporting evidence and a minimal correction. Reviews read the repository without editing it.
-Each review returns a structured result (`ok`, `issues` or `incomplete`) and a Chinese summary, retained as an Actions artifact for seven days. **Combined Feishu notification** waits for both jobs and sends one Card 2.0 message containing both results through the existing `FEISHU_WEBHOOK_URL`; `FEISHU_WEBHOOK_SECRET` optionally signs it. Only this notification job receives the webhook secrets. Failed, missing or invalid reports appear as incomplete, alongside any available result from the other review. The notification job runs even when a review fails. Delivery requires Feishu's `code=0` response; a failed or ambiguous request is not automatically retried, avoiding duplicate messages. Each review has a 25-minute execution timeout. Review and delivery failures do not block merges, and closing an unmerged PR does not trigger this workflow. The workflow checks out only the merged commit with notification credentials.
+Each review returns a structured result (`ok`, `issues` or `incomplete`) and Chinese sections, retained as an Actions artifact for seven days. **Combined Feishu notification** waits for both jobs and sends one Card 2.0 message containing both results through the existing `FEISHU_WEBHOOK_URL`; `FEISHU_WEBHOOK_SECRET` optionally signs it. The card uses fixed sections for PR details, behavior changes, code and repository rules, CI results, and documentation review, separated by dividers. Its header shows green for no findings, red for findings, and yellow for an incomplete review without findings. Only this notification job receives the webhook secrets. Failed, missing or invalid reports appear as incomplete, alongside any available result from the other review. The notification job runs even when a review fails. Delivery requires Feishu's `code=0` response; a failed or ambiguous request is not automatically retried, avoiding duplicate messages. Each review has a 25-minute execution timeout. Review and delivery failures do not block merges, and closing an unmerged PR does not trigger this workflow. The workflow checks out only the merged commit with notification credentials.
Browser jobs own separate fixtures and servers; increasing workers against the shared mutable fixture is unsafe. Failed browser jobs retain reports/traces for seven days. Native failure phase summaries are retained for seven days and detailed output stays in the Actions logs; credentials and temporary installation trees are not uploaded. Successful native archives are uploaded only for explicit manual packaging or releases, without recompressing the compressed archive. Release distribution artifacts retain their existing recovery policy; failed publication can reuse the original build as described above.
diff --git a/docs/zh/maintainers.md b/docs/zh/maintainers.md
index 6fb303605..f6b2b17e3 100644
--- a/docs/zh/maintainers.md
+++ b/docs/zh/maintainers.md
@@ -1,7 +1,7 @@
---
title: "构建并发布 OpenAgentCore"
source: docs/maintainers.md
-source_hash: e3435d01696a172a0a0bcd5acecf4167410389f2c249730c9333f5d24027248d
+source_hash: aaadeb3a5e99b8d926e9f78b7fc41c7aba808e0d2af58969119e67954f9d7a58
---
本指南面向负责构建和发布 OpenAgentCore 的维护者。要安装 Core 和 Web,请使用 [安装指南](getting-started/install.md)。安装器代码遵循的规则见 [部署](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/README.md) 和 [节点安装器](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/deploy/node/README.md);必需检查见 [CONTRIBUTING](https://github.com/MiniMax-AI/OpenAgentCore/blob/main/CONTRIBUTING.md#required-checks)。
@@ -198,7 +198,7 @@ Go 模块和工作区输入会选择后端、API(包括容器)、原生和
`CI review and Feishu notification` 工作流在 PR 合入 main 后运行。矩阵中的 **Code review** 和 **Docs review** 使用独立的 LLM 上下文,并设置 `fail-fast: false`。两者读取合并后的提交和 PR 差异。代码审查检查实现、仓库规则和已有 CI 结果,不触发新一轮测试。文档审查会核对行为变化与文档是否一致,即使没有修改文档;修改文档时,还会检查矛盾、重复维护的事实、CONTRIBUTING 规定的主题归属及中英文含义。每项问题包含文件和行号、依据及最小修改建议。审查只读取仓库,不修改文件。
-每项审查返回结构化结果(`ok`、`issues` 或 `incomplete`)及中文摘要,作为 Actions 构建产物保留七天。**Combined Feishu notification** 等待两项审查结束,通过已有的 `FEISHU_WEBHOOK_URL` 发送一张包含两份结果的 Card 2.0 卡片;可选的 `FEISHU_WEBHOOK_SECRET` 用于签名。只有通知 job 能读取 webhook 密钥。审查失败、报告缺失或格式无效时标为未完成,另一项已有的结果照常展示。某项审查失败时,通知 job 仍会运行。只有飞书返回 `code=0` 才确认送达;请求失败或送达状态不明时不自动重试,以免重复发消息。每项审查的执行超时为 25 分钟。审查和发送失败不会阻止合并;关闭未合并的 PR 不触发此流程。持有通知凭据时,工作流只检出合并后的提交。
+每项审查返回结构化结果(`ok`、`issues` 或 `incomplete`)及分项中文结论,作为 Actions 构建产物保留七天。**Combined Feishu notification** 等待两项审查结束,通过已有的 `FEISHU_WEBHOOK_URL` 发送一张包含两份结果的 Card 2.0 卡片;可选的 `FEISHU_WEBHOOK_SECRET` 用于签名。卡片固定分为 PR 信息、行为变化、代码与仓库规则、CI 结果和文档审查,各区之间使用分隔线。未发现问题时标题为绿色,发现问题时为红色,审查未完成且尚未发现问题时为黄色。只有通知 job 能读取 webhook 密钥。审查失败、报告缺失或格式无效时标为未完成,另一项已有的结果照常展示。某项审查失败时,通知 job 仍会运行。只有飞书返回 `code=0` 才确认送达;请求失败或送达状态不明时不自动重试,以免重复发消息。每项审查的执行超时为 25 分钟。审查和发送失败不会阻止合并;关闭未合并的 PR 不触发此流程。持有通知凭据时,工作流只检出合并后的提交。
浏览器作业各自拥有独立的固定数据和服务;对共享可变固定数据增加 worker 数不安全。失败的浏览器作业保留报告与 trace 七天。原生失败阶段摘要保留七天,详细输出留在 Actions 日志中;凭据和临时安装目录不上传。成功的原生归档仅用于显式手动打包或发布时上传,不重新压缩已压缩的归档。发布分发产物保留现有恢复策略;失败发布可以按前述方式复用原构建。
diff --git a/scripts/ci_review.py b/scripts/ci_review.py
index a6b97b887..e7fb16645 100644
--- a/scripts/ci_review.py
+++ b/scripts/ci_review.py
@@ -15,46 +15,50 @@
STATUSES = {"ok": "未发现问题", "issues": "发现问题", "incomplete": "未完成"}
-MAX_SUMMARY = 2000
-SCHEMA = {
- "type": "object",
- "properties": {
- "status": {"type": "string", "enum": list(STATUSES)},
- "summary": {"type": "string", "minLength": 1, "maxLength": MAX_SUMMARY},
- },
- "required": ["status", "summary"],
- "additionalProperties": False,
+MAX_SECTION = 900
+REPORT_FIELDS = {
+ "code": {"changes": "实际行为变化,1–3 条 Markdown 列表", "review": "代码正确性与仓库规则审查结论", "ci": "已有 CI 结果;失败或未完成时给出具体检查项"},
+ "docs": {"consistency": "文档与代码一致性", "organization": "文档矛盾、重复与归属;未改文档时写本次未修改文档"},
}
-def incomplete(reason):
- return {"status": "incomplete", "summary": reason}
+def report_schema(kind):
+ properties = {"status": {"type": "string", "enum": list(STATUSES)}}
+ properties.update({name: {"type": "string", "minLength": 1, "maxLength": MAX_SECTION, "description": description}
+ for name, description in REPORT_FIELDS[kind].items()})
+ return {"type": "object", "properties": properties, "required": list(properties), "additionalProperties": False}
-def parse_report(raw):
+def incomplete(reason, kind):
+ fields = dict.fromkeys(REPORT_FIELDS[kind], "未完成。")
+ fields[next(iter(fields))] = reason
+ return {"status": "incomplete", **fields}
+
+
+def parse_report(raw, kind):
try:
report = json.loads(raw)
except (ValueError, TypeError):
- return incomplete("未收到有效报告,请查看审查日志。")
- if (not isinstance(report, dict) or set(report) != set(SCHEMA["required"])
+ return incomplete("未收到有效报告,请查看审查日志。", kind)
+ if (not isinstance(report, dict) or set(report) != {"status", *REPORT_FIELDS[kind]}
or not isinstance(report["status"], str) or report["status"] not in STATUSES
- or not isinstance(report["summary"], str) or not report["summary"].strip()
- or len(report["summary"]) > MAX_SUMMARY):
- return incomplete("报告格式不完整,请查看审查日志。")
+ or any(not isinstance(report[name], str) or not report[name].strip()
+ or len(report[name]) > MAX_SECTION for name in REPORT_FIELDS[kind])):
+ return incomplete("报告格式不完整,请查看审查日志。", kind)
return report
-def collect(outcome, raw):
+def collect(outcome, raw, kind):
if outcome != "success":
- return incomplete("审查未成功结束,请查看审查日志。")
- return parse_report(raw)
+ return incomplete("审查未成功结束,请查看审查日志。", kind)
+ return parse_report(raw, kind)
def read_report(directory, kind):
try:
- return parse_report((directory / f"review-{kind}.json").read_text())
+ return parse_report((directory / f"review-{kind}.json").read_text(), kind)
except (OSError, UnicodeError):
- return incomplete("审查报告缺失,请查看审查日志。")
+ return incomplete("审查报告缺失,请查看审查日志。", kind)
def markdown_text(value):
@@ -65,26 +69,44 @@ def build_card(event, reports, run_url):
pr = event["pull_request"]
statuses = {report["status"] for report in reports.values()}
color = "red" if "issues" in statuses else "yellow" if "incomplete" in statuses else "green"
- elements = [{"tag": "markdown", "content": (
- f"**{markdown_text(pr['title'][:200])}**\n"
- f"{markdown_text(pr['user']['login'])} · [PR #{pr['number']}]({pr['html_url']})"
- )}]
- for kind, title in (("code", "代码审查与 CI"), ("docs", "文档审查")):
- report = reports[kind]
+ heading = "❌ 审查发现问题" if "issues" in statuses else "⚠️ 审查未完成" if "incomplete" in statuses else "✅ 审查通过"
+ code, docs = reports["code"], reports["docs"]
+
+ def text(value):
# Feishu mentions use HTML-like tags; reports are ordinary Markdown.
- summary = report["summary"].replace("<", "<").replace(">", ">")
- elements.append({"tag": "markdown", "content": f"**{title}:{STATUSES[report['status']]}**\n{summary}"})
+ return value.replace("<", "<").replace(">", ">")
+
+ sections = [
+ ("1. 哪个 PR", f"**github id:** {markdown_text(pr['user']['login'])}\n"
+ f"**标题:** {markdown_text(pr['title'][:200])}\n**链接:** [PR #{pr['number']}]({pr['html_url']})"),
+ ("2. 改了什么", text(code["changes"])),
+ ("3. 代码与仓库规则", text(code["review"])),
+ ("4. CI 结果", text(code["ci"])),
+ (f"5. 文档审查 · {STATUSES[docs['status']]}",
+ f"**文档与代码一致性**\n{text(docs['consistency'])}\n\n"
+ f"**文档矛盾、重复与归属**\n{text(docs['organization'])}"),
+ ]
+ elements = []
+ for title, content in sections:
+ if elements:
+ elements.append({"tag": "hr"})
+ elements.append({"tag": "markdown", "content": f"**{title}**\n\n{content}"})
elements.append({"tag": "markdown", "content": f"[查看审查日志]({run_url})"})
return {
"msg_type": "interactive",
"card": {
"schema": "2.0",
- "header": {"template": color, "title": {"tag": "plain_text", "content": f"CI 审查 · PR #{pr['number']}"}},
+ "header": {"template": color, "title": {"tag": "plain_text", "content": f"{heading} · PR #{pr['number']}"}},
"body": {"elements": elements},
},
}
+def card_markdown(card):
+ return "\n\n".join("---" if element["tag"] == "hr" else element["content"]
+ for element in card["card"]["body"]["elements"]) + "\n"
+
+
def send_card(webhook, secret, card):
if not webhook:
raise ValueError("FEISHU_WEBHOOK_URL is not configured")
@@ -110,14 +132,16 @@ def send_card(webhook, secret, card):
def main():
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
- commands.add_parser("schema")
- commands.add_parser("collect").add_argument("output", type=Path)
+ commands.add_parser("schema").add_argument("kind", choices=REPORT_FIELDS)
+ collector = commands.add_parser("collect")
+ collector.add_argument("kind", choices=REPORT_FIELDS)
+ collector.add_argument("output", type=Path)
commands.add_parser("notify").add_argument("directory", type=Path)
args = parser.parse_args()
if args.command == "schema":
- print("schema=" + json.dumps(SCHEMA, separators=(",", ":")))
+ print("schema=" + json.dumps(report_schema(args.kind), separators=(",", ":")))
elif args.command == "collect":
- report = collect(os.environ.get("REVIEW_OUTCOME"), os.environ.get("REVIEW_RESULT", ""))
+ report = collect(os.environ.get("REVIEW_OUTCOME"), os.environ.get("REVIEW_RESULT", ""), args.kind)
args.output.write_text(json.dumps(report, ensure_ascii=False))
else:
event = json.loads(Path(os.environ["GITHUB_EVENT_PATH"]).read_text())
@@ -127,7 +151,7 @@ def main():
summary = os.environ.get("GITHUB_STEP_SUMMARY")
if summary:
with open(summary, "a") as output:
- output.write("\n\n".join(element["content"] for element in card["card"]["body"]["elements"]) + "\n")
+ output.write(card_markdown(card))
send_card(os.environ.get("FEISHU_WEBHOOK_URL"), os.environ.get("FEISHU_WEBHOOK_SECRET"), card)
print("代码与文档审查结果已合并发送至飞书群。")
diff --git a/scripts/ci_review_test.py b/scripts/ci_review_test.py
index 05161c0a8..c80fdfd99 100644
--- a/scripts/ci_review_test.py
+++ b/scripts/ci_review_test.py
@@ -16,38 +16,73 @@ class ReviewTests(unittest.TestCase):
run_url = "https://github.com/org/repo/actions/runs/123"
def setUp(self):
- self.ok = {"status": "ok", "summary": "未发现问题"}
+ self.ok = {"status": "ok", "changes": "- 更新安装流程", "review": "未发现问题", "ci": "全部通过 ✅"}
+ self.docs = {"status": "ok", "consistency": "与代码一致", "organization": "无矛盾或重复"}
def card(self, code=None, docs=None):
- return review.build_card(self.event, {"code": code or self.ok, "docs": docs or self.ok}, self.run_url)
+ return review.build_card(self.event, {"code": code or self.ok, "docs": docs or self.docs}, self.run_url)
@patch("ci_review.urllib.request.urlopen")
def test_two_reports_make_one_delivery(self, post):
post.return_value = io.BytesIO(b'{"code":0}')
- card = self.card(docs={"status": "issues", "summary": "docs/install.md:12 与代码不符"})
+ card = self.card(docs={**self.docs, "status": "issues", "consistency": "docs/install.md:12 与代码不符"})
review.send_card("https://example.invalid/webhook", "", card)
post.assert_called_once()
payload = json.loads(post.call_args.args[0].data)
self.assertEqual(payload["card"]["schema"], "2.0")
self.assertEqual(payload["card"]["header"]["template"], "red")
- text = "\n".join(item["content"] for item in payload["card"]["body"]["elements"])
- self.assertIn("代码审查与 CI:未发现问题", text)
- self.assertIn("文档审查:发现问题", text)
+ text = review.card_markdown(payload)
+ self.assertIn("3. 代码与仓库规则", text)
+ self.assertIn("文档审查 · 发现问题", text)
self.assertIn("docs/install.md:12", text)
self.assertIn(self.run_url, text)
self.assertNotIn("sign", payload)
+ def test_fixed_sections_preserve_markdown_and_separators(self):
+ card = self.card()
+ elements = card["card"]["body"]["elements"]
+ self.assertEqual(sum(element["tag"] == "hr" for element in elements), 4)
+ text = review.card_markdown(card)
+ for heading in ("1. 哪个 PR", "2. 改了什么", "3. 代码与仓库规则", "4. CI 结果", "5. 文档审查"):
+ self.assertIn(heading, text)
+ self.assertIn("- 更新安装流程", text)
+ self.assertIn("全部通过 ✅", text)
+ self.assertIn("文档与代码一致性", text)
+ self.assertIn("文档矛盾、重复与归属", text)
+ self.assertIn("---", text)
+ self.assertEqual(card["card"]["header"]["template"], "green")
+ self.assertIn("✅ 审查通过", card["card"]["header"]["title"]["content"])
+
+ def test_pending_ci_does_not_label_the_code_review_incomplete(self):
+ card = self.card(code={**self.ok, "status": "incomplete", "ci": "backend 仍在运行"})
+ text = review.card_markdown(card)
+ self.assertIn("**3. 代码与仓库规则**", text)
+ self.assertIn("未发现问题", text)
+ self.assertIn("backend 仍在运行", text)
+ self.assertEqual(card["card"]["header"]["template"], "yellow")
+
+ def test_each_kind_requires_its_own_sections(self):
+ for kind, report in (("code", self.ok), ("docs", self.docs)):
+ self.assertEqual(review.parse_report(json.dumps(report), kind), report)
+ self.assertEqual(set(review.report_schema(kind)["required"]), set(report))
+ for field in review.REPORT_FIELDS[kind]:
+ for value in (None, " ", "x" * (review.MAX_SECTION + 1)):
+ invalid = {**report, field: value}
+ self.assertEqual(review.parse_report(json.dumps(invalid), kind)["status"], "incomplete")
+ missing = {key: value for key, value in report.items() if key != field}
+ self.assertEqual(review.parse_report(json.dumps(missing), kind)["status"], "incomplete")
+
def test_failed_action_cannot_publish_a_success_report(self):
for outcome in ("failure", "cancelled", "skipped", None):
with self.subTest(outcome=outcome):
- result = review.collect(outcome, json.dumps(self.ok))
+ result = review.collect(outcome, json.dumps(self.ok), "code")
self.assertEqual(result["status"], "incomplete")
self.assertEqual(self.card(code=result)["card"]["header"]["template"], "yellow")
def test_invalid_or_missing_reports_are_incomplete(self):
for raw in ("", "not json", "null", "[]", '{"status":"ok"}', '{"status":[],"summary":"x"}', '{"status":"ok","summary":" "}', json.dumps({"status": "ok", "summary": "x" * 2001})):
with self.subTest(raw=raw[:50]):
- self.assertEqual(review.collect("success", raw)["status"], "incomplete")
+ self.assertEqual(review.collect("success", raw, "code")["status"], "incomplete")
root = Path.home() / ".oac/tests"
root.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(dir=root) as directory:
@@ -85,8 +120,9 @@ def test_optional_signing(self, post, _clock):
@patch("ci_review.urllib.request.urlopen")
def test_bounded_unicode_reports_and_mentions(self, post):
post.return_value = io.BytesIO(b'{"code":0}')
- report = {"status": "issues", "summary": "所有人" + "问" * 1950}
- review.send_card("https://example.invalid/webhook", "", self.card(report, report))
+ reports = {kind: {"status": "issues", **dict.fromkeys(fields, "所有人" + "问" * (review.MAX_SECTION - 20))}
+ for kind, fields in review.REPORT_FIELDS.items()}
+ review.send_card("https://example.invalid/webhook", "", self.card(reports["code"], reports["docs"]))
data = post.call_args.args[0].data
self.assertLess(len(data), 20000)
self.assertNotIn(b"