From ed1705b5a59754ef7e65d34e25cf89dd91e3b965 Mon Sep 17 00:00:00 2001 From: jiachengzhen Date: Wed, 28 Oct 2026 11:48:06 +0800 Subject: [PATCH] fix(yunxiao): shorten synced card titles without losing source identity --- .github/workflows/yunxiao-github-sync.yml | 5 +- docs/github-yunxiao-sync.md | 41 ++++ scripts/test_yunxiao_github_sync.py | 265 ++++++++++++++++++++- scripts/yunxiao_github_sync.py | 261 ++++++++++++++++---- tests/yunxiao-github-sync-workflow.test.ts | 125 +++++++++- 5 files changed, 641 insertions(+), 56 deletions(-) create mode 100644 docs/github-yunxiao-sync.md diff --git a/.github/workflows/yunxiao-github-sync.yml b/.github/workflows/yunxiao-github-sync.yml index abb25914d..d1c1ceb3c 100644 --- a/.github/workflows/yunxiao-github-sync.yml +++ b/.github/workflows/yunxiao-github-sync.yml @@ -15,6 +15,7 @@ on: options: - preflight - backfill + - rename-titles state: description: 回填 GitHub 数据范围 required: false @@ -24,7 +25,7 @@ on: - open - all apply: - description: 回填时是否实际写入云效 + description: 回填或标题迁移时是否实际写入云效 required: false type: boolean default: false @@ -79,7 +80,7 @@ jobs: if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then args=(--mode "${{ inputs.mode }}" --state "${{ inputs.state }}") - if [ "${{ inputs.mode }}" = "backfill" ]; then + if [ "${{ inputs.mode }}" = "backfill" ] || [ "${{ inputs.mode }}" = "rename-titles" ]; then if [ "${{ inputs.apply }}" = "true" ]; then args+=(--apply) else diff --git a/docs/github-yunxiao-sync.md b/docs/github-yunxiao-sync.md new file mode 100644 index 000000000..8f2f102e4 --- /dev/null +++ b/docs/github-yunxiao-sync.md @@ -0,0 +1,41 @@ +# GitHub → 云效标题同步 + +`Sync GitHub to Yunxiao` 工作流将 GitHub Issue 和社区 PR 同步到仓库变量指定的云效项目。 + +卡片标题使用短格式: + +- `[Issue #594] 原始 Issue 标题` +- `[PR #766] 原始 PR 标题` + +卡片描述首行保留 `GitHub: https://github.com/owner/repository/issues/594`(PR 使用 +`/pull/766`)。这条来源链接用于区分仓库、类型和编号,请保留该行。 +脚本兼容原先的 `[GitHub owner/repository Issue #594]` 和 `[GitHub Issue #594]` +标题;后续生命周期事件会复用已有卡片、缩短前缀并保留当前标题正文。来源缺失或同一来源对应多张卡片时, +同步会报错,避免误改或重复创建。 + +## 整理已有卡片标题 + +在 GitHub Actions 中打开 `Sync GitHub to Yunxiao`,选择 **Run workflow**: + +1. `mode` 选择 `rename-titles`,`apply` 保持关闭,先检查运行日志中的预览统计。 +2. 确认 `failed=0` 和 `would_rename` 数量后,再运行相同模式并勾选 `apply`。 +3. 检查 `renamed`、`skipped`、`failed` 统计,并回查云效列表。 + +该模式只整理目标云效项目中已存在、来源匹配当前 GitHub 仓库的卡片(含已关闭项), +仅写入 `subject`。不会创建历史卡片,也不会修改状态、迭代、负责人、参与者或描述。 +`state` 选项只用于 `backfill`,不限制标题整理范围。 +整理重复执行不会再次改写已经使用短标题的卡片;写前回读发现标题或来源变更时会跳过并报错, +应重新预览后再处理。回读与写入之间仍有短暂竞争窗口,整理期间请避免同时编辑卡片标题。 + +本地对应命令(需要安全注入既有云效凭据和仓库映射变量): + +```sh +python scripts/yunxiao_github_sync.py --mode rename-titles --dry-run +python scripts/yunxiao_github_sync.py --mode rename-titles --apply +``` + +`--dry-run` 优先于 `--apply`。只为改标题时不要使用 `backfill --state all --apply`, +它会同步状态和人员等字段,并可能创建此前未同步的历史工作项。 + +云效 API 参考:[搜索工作项](https://help.aliyun.com/zh/yunxiao/developer-reference/searchworkitems)、 +[更新工作项](https://help.aliyun.com/zh/yunxiao/developer-reference/updateworkitem)。 diff --git a/scripts/test_yunxiao_github_sync.py b/scripts/test_yunxiao_github_sync.py index 627ac6cde..096d0dba2 100644 --- a/scripts/test_yunxiao_github_sync.py +++ b/scripts/test_yunxiao_github_sync.py @@ -1,10 +1,12 @@ from __future__ import annotations import importlib.util +import io import json import os import tempfile import unittest +from contextlib import redirect_stdout from pathlib import Path from unittest.mock import patch @@ -16,6 +18,68 @@ SPEC.loader.exec_module(MODULE) +REPOSITORY = "MemTensor/memmy-agent" +CFG = { + "org": "org", "project_id": "project", "workitem_category": "Req", + "type_id": "type", "assignee_id": "assignee", "priority_id": "priority", + "parent_id": "", "sprint_id": "sprint", "participant_ids": ["participant"], + "statuses": {"待处理": "pending", "已取消": "cancelled"}, +} + + +def source_item(number=42, *, kind="issue", repository=REPOSITORY): + return { + "number": number, "title": "Fix scheduler", "state": "open", "labels": [], + "html_url": MODULE.source_url(kind, number, repository), + "created_at": "2026-09-10T00:00:00Z", "body": "Details", + } + + +def source_card(identifier="card", *, number=42, kind="issue", repository=REPOSITORY, form="short"): + item = source_item(number, kind=kind, repository=repository) + prefix = { + "short": f"[{MODULE.SOURCE_LABELS[kind]} #{number}]", + "long": MODULE.build_source_key(kind, item, repository), + "legacy": MODULE.legacy_source_key(kind, item), + }[form] + return { + "id": identifier, "subject": f"{prefix} Fix scheduler", + "description": MODULE.item_description(item), "status": "closed-custom", + "sprint": "manual-sprint", "assignedTo": "manual-owner", + } + + +class MemoryTransport: + """A stateful API fixture: subject searches see changes made by PUT.""" + + def __init__(self, cards=()): + self.cards = {card["id"]: dict(card) for card in cards} + self.calls = [] + self.omit_description = False + + def __call__(self, method, path, body=None): + self.calls.append((method, path, body)) + if path.endswith("workitems:search"): + key = json.loads(body["conditions"])["conditionGroups"][0][0]["value"][0] + cards = [dict(card) for card in self.cards.values() if key in card["subject"]] + offset = (body["page"] - 1) * body["perPage"] + cards = cards[offset:offset + body["perPage"]] + if self.omit_description: + for card in cards: + card.pop("description", None) + return cards + if method == "GET": + return dict(self.cards[path.rsplit("/", 1)[-1]]) + if method == "PUT": + self.cards[path.rsplit("/", 1)[-1]].update(body) + return None + if method == "POST" and path.endswith("/workitems"): + identifier = f"new-{len(self.cards)}" + self.cards[identifier] = {"id": identifier, **body} + return {"id": identifier} + raise AssertionError((method, path, body)) + + class YunxiaoSyncTests(unittest.TestCase): def test_source_key_includes_repository(self) -> None: item = {"number": 42, "title": "Fix scheduler"} @@ -24,11 +88,14 @@ def test_source_key_includes_repository(self) -> None: "[GitHub MemTensor/memmy-agent Issue #42]", ) - def test_title_includes_source_key(self) -> None: + def test_title_keeps_type_and_number_without_repository_prefix(self) -> None: item = {"number": 42, "title": "Fix scheduler"} self.assertEqual( MODULE.build_title("issue", item, "MemTensor/memmy-agent"), - "[GitHub MemTensor/memmy-agent Issue #42] Fix scheduler", + "[Issue #42] Fix scheduler", + ) + self.assertEqual( + MODULE.build_title("pr", item, REPOSITORY), "[PR #42] Fix scheduler" ) def test_source_status(self) -> None: @@ -153,18 +220,23 @@ def transport(method, path, body=None): client=MODULE.YunxiaoClient(transport), ) self.assertEqual(result, "created") - self.assertEqual(calls[2][2]["parentId"], "directory") - self.assertEqual(calls[2][2]["sprint"], "sprint") - self.assertEqual(calls[2][2]["participants"], ["participant-a", "participant-b"]) - self.assertIn("Details", calls[2][2]["description"]) + self.assertEqual(calls[-1][2]["parentId"], "directory") + self.assertEqual(calls[-1][2]["sprint"], "sprint") + self.assertEqual(calls[-1][2]["participants"], ["participant-a", "participant-b"]) + self.assertIn("Details", calls[-1][2]["description"]) + self.assertEqual(calls[-1][2]["subject"], "[Issue #1] Test") def test_reopened_item_updates_to_pending(self) -> None: calls = [] def transport(method, path, body=None): calls.append((method, path, body)) + existing = source_card("existing", number=2, form="long") + existing["subject"] = "[GitHub MemTensor/memmy-agent Issue #2] Reopen" if "search" in path: - return {"data": {"workitems": [{"id": "existing"}]}} + return {"data": {"workitems": [existing]}} + if method == "GET": + return existing return {} cfg = { @@ -202,6 +274,7 @@ def transport(method, path, body=None): calls[-1][2], { "status": "pending", + "subject": "[Issue #2] Reopen", "assignedTo": "assignee", "participants": ["participant-a", "participant-b"], }, @@ -249,6 +322,184 @@ def transport(method, path, body=None): self.assertEqual(result, "created") self.assertEqual(calls[-1][2]["status"], "cancelled") + def find_existing(self, transport, *, kind="issue", number=42): + return MODULE.find_existing_workitem( + "org", "project", kind, source_item(number, kind=kind), REPOSITORY, + MODULE.YunxiaoClient(transport), category="Req", + ) + + def sync(self, transport): + return MODULE.sync_one( + "org", CFG, "issue", source_item(), repository=REPOSITORY, + apply=True, label_ids={}, days_to_finish=7, create_closed=False, + client=MODULE.YunxiaoClient(transport), + ) + + def rename(self, transport, *, apply=False): + output = io.StringIO() + with ( + patch.dict(os.environ, {"GITHUB_REPOSITORY": REPOSITORY}), + patch.object(MODULE, "configuration_from_environment", return_value=CFG), + patch.object(MODULE.time, "sleep"), + redirect_stdout(output), + ): + result = MODULE.rename_titles(MODULE.YunxiaoClient(transport), apply=apply) + return result, json.loads(output.getvalue())["titles"] + + def test_description_source_requires_the_leading_source_line(self): + url = MODULE.source_url("issue", 42, REPOSITORY) + self.assertEqual(MODULE.description_source_url(f"GitHub: {url}\n\nBody"), url.casefold()) + self.assertEqual( + MODULE.description_source_url(f'

GitHub: {url}

Body

'), + url.casefold(), + ) + self.assertIsNone(MODULE.description_source_url(f"Body mentions\nGitHub: {url}")) + self.assertIsNone(MODULE.description_source_url(f"GitHub: {url}#issuecomment-1")) + + def test_sync_after_create_and_after_rename_reuses_the_same_card(self): + transport = MemoryTransport() + self.assertEqual(self.sync(transport), "created") + self.assertEqual(self.sync(transport), "updated-status") + self.assertEqual(len(transport.cards), 1) + self.assertEqual(transport.cards["new-0"]["subject"], "[Issue #42] Fix scheduler") + for form in ("long", "legacy"): + with self.subTest(form=form): + transport = MemoryTransport([source_card(form=form)]) + self.assertEqual(self.sync(transport), "updated-status") + self.assertEqual(self.sync(transport), "updated-status") + self.assertEqual(list(transport.cards), ["card"]) + self.assertEqual(transport.cards["card"]["subject"], "[Issue #42] Fix scheduler") + + def test_same_number_from_another_repository_is_not_selected(self): + transport = MemoryTransport([ + source_card("foreign", repository="Other/repository"), + source_card("target"), + ]) + self.assertEqual(self.find_existing(transport), "target") + + def test_sync_preserves_the_human_edited_title_suffix(self): + for form in ("long", "legacy", "short"): + with self.subTest(form=form): + card = source_card(form=form) + card["subject"] = card["subject"].replace("Fix scheduler", "Human-maintained title") + transport = MemoryTransport([card]) + self.assertEqual(self.sync(transport), "updated-status") + self.assertEqual(transport.cards["card"]["subject"], "[Issue #42] Human-maintained title") + + def test_issue_and_pr_with_same_number_are_separate(self): + transport = MemoryTransport([source_card("issue"), source_card("pr", kind="pr")]) + self.assertEqual(self.find_existing(transport, kind="pr"), "pr") + + def test_missing_search_description_uses_workitem_detail(self): + transport = MemoryTransport([source_card()]) + transport.omit_description = True + self.assertEqual(self.find_existing(transport), "card") + self.assertTrue(any(method == "GET" for method, _, _ in transport.calls)) + + def test_matching_candidate_on_a_later_page_is_found(self): + cards = [source_card(f"foreign-{i}", repository=f"Other/repository-{i}") for i in range(4)] + transport = MemoryTransport([*cards, source_card("target")]) + with patch.object(MODULE, "SEARCH_PAGE_SIZE", 2): + self.assertEqual(self.find_existing(transport), "target") + self.assertIn(3, [body["page"] for _, path, body in transport.calls if path.endswith(":search")]) + + def test_duplicates_across_title_formats_block_writes(self): + transport = MemoryTransport([source_card("a"), source_card("b", form="long")]) + with self.assertRaisesRegex(MODULE.PreflightError, "多个"): + self.sync(transport) + self.assertFalse(any(method == "PUT" for method, _, _ in transport.calls)) + + def test_unverifiable_matching_card_blocks_duplicate_creation(self): + card = source_card() + card["description"] = "Manual text without a source line" + transport = MemoryTransport([card]) + with self.assertRaisesRegex(MODULE.PreflightError, "缺少可验证"): + self.sync(transport) + self.assertEqual(len(transport.cards), 1) + + def test_malformed_search_response_does_not_look_like_no_matches(self): + with self.assertRaisesRegex(MODULE.PreflightError, "无法解析"): + self.find_existing(lambda *args: {"unexpected": []}) + + def test_search_stops_at_the_api_pagination_window(self): + transport = MemoryTransport([source_card(str(i)) for i in range(3)]) + with ( + patch.object(MODULE, "SEARCH_PAGE_SIZE", 2), + patch.object(MODULE, "SEARCH_WINDOW", 2), + self.assertRaisesRegex(MODULE.PreflightError, "分页窗口"), + ): + self.find_existing(transport) + + def test_title_cleanup_preview_has_no_writes(self): + transport = MemoryTransport([source_card(form="long")]) + before = json.dumps(transport.cards) + result, stats = self.rename(transport) + self.assertEqual(result, 0) + self.assertEqual(stats["would_rename"], 1) + self.assertEqual(stats["renamed"], 0) + self.assertEqual(json.dumps(transport.cards), before) + self.assertTrue(all(method in ("GET", "POST") and not path.endswith("/workitems") + for method, path, _ in transport.calls)) + + def test_title_cleanup_collects_all_pages_then_changes_subject_only(self): + cards = [source_card(str(i), number=i + 1, form="long") for i in range(5)] + cards.append(source_card("legacy", number=6, form="legacy")) + cards.append(source_card("foreign", repository="Other/repo", form="legacy")) + transport = MemoryTransport(cards) + with patch.object(MODULE, "SEARCH_PAGE_SIZE", 2): + result, stats = self.rename(transport, apply=True) + self.assertEqual(result, 0) + self.assertEqual(stats["renamed"], 6) + self.assertEqual(stats["skipped"], 1) + for before in cards: + after = transport.cards[before["id"]] + for field in ("description", "status", "sprint", "assignedTo"): + self.assertEqual(before[field], after[field]) + writes = [body for method, _, body in transport.calls if method == "PUT"] + self.assertEqual(len(writes), 6) + self.assertTrue(all(set(body) == {"subject"} for body in writes)) + transport.calls.clear() + _, repeated = self.rename(transport, apply=True) + self.assertEqual(repeated["renamed"], 0) + self.assertFalse(any(method == "PUT" for method, _, _ in transport.calls)) + + def test_title_cleanup_duplicate_sources_are_not_renamed(self): + transport = MemoryTransport([source_card("a", form="long"), source_card("b", form="legacy")]) + result, stats = self.rename(transport, apply=True) + self.assertEqual(result, 1) + self.assertEqual(stats["failed"], 2) + self.assertFalse(any(method == "PUT" for method, _, _ in transport.calls)) + + def test_title_cleanup_detects_duplicate_already_short_cards(self): + transport = MemoryTransport([source_card("a"), source_card("b", form="long")]) + result, stats = self.rename(transport, apply=True) + self.assertEqual(result, 1) + self.assertEqual(stats["failed"], 2) + self.assertEqual(stats["renamed"], 0) + self.assertFalse(any(method == "PUT" for method, _, _ in transport.calls)) + + def test_title_cleanup_does_not_overwrite_a_concurrent_edit(self): + transport = MemoryTransport([source_card(form="long")]) + + def concurrent_transport(method, path, body=None): + if method == "GET": + transport.cards["card"]["subject"] = "Manually edited title" + return transport(method, path, body) + + result, stats = self.rename(concurrent_transport, apply=True) + self.assertEqual(result, 1) + self.assertEqual(stats["failed"], 1) + self.assertFalse(any(method == "PUT" for method, _, _ in transport.calls)) + + def test_explicit_dry_run_overrides_apply_for_title_cleanup(self): + with ( + patch.dict(os.environ, {"YUNXIAO_TOKEN": "test-token"}), + patch.object(MODULE.sys, "argv", ["sync", "--mode", "rename-titles", "--apply", "--dry-run"]), + patch.object(MODULE, "rename_titles", return_value=0) as rename, + ): + self.assertEqual(MODULE.main(), 0) + self.assertFalse(rename.call_args.kwargs["apply"]) + if __name__ == "__main__": unittest.main() diff --git a/scripts/yunxiao_github_sync.py b/scripts/yunxiao_github_sync.py index c3e230e6c..43bc94c26 100644 --- a/scripts/yunxiao_github_sync.py +++ b/scripts/yunxiao_github_sync.py @@ -3,10 +3,12 @@ import argparse import json import os +import re import sys import time from datetime import datetime, timedelta, timezone +from html.parser import HTMLParser from typing import Any from urllib.error import HTTPError, URLError from urllib.parse import quote, urlencode @@ -14,6 +16,8 @@ SOURCE_LABELS = {"issue": "Issue", "pr": "PR"} +SEARCH_PAGE_SIZE = 200 +SEARCH_WINDOW = 10000 # Yunxiao projects can customize workflow names. Keep the canonical names used # by the sync state machine, then resolve them to the first matching name in # the target project's workflow. @@ -116,7 +120,57 @@ def build_title( item: dict[str, Any], repository: str | None = None, ) -> str: - return f"{build_source_key(item_type, item, repository)} {item['title']}" + return f"[{SOURCE_LABELS[item_type]} #{item['number']}] {item['title']}" + + +def source_url(item_type: str, number: int, repository: str) -> str: + path = "issues" if item_type == "issue" else "pull" + return f"https://github.com/{repository_name(repository)}/{path}/{number}" + + +def shortened_subject( + subject: str, item_type: str, item: dict[str, Any], repository: str +) -> str | None: + short_key = f"[{SOURCE_LABELS[item_type]} #{item['number']}]" + for key in (short_key, build_source_key(item_type, item, repository), legacy_source_key(item_type, item)): + if subject == key or subject.startswith(f"{key} "): + return short_key + subject[len(key):] + return None + + +class DescriptionText(HTMLParser): + """Read the leading source line from Markdown or Yunxiao rich text.""" + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.parts: list[str] = [] + + def handle_data(self, data: str) -> None: + self.parts.append(data) + + def handle_starttag(self, tag: str, attrs: Any) -> None: + if tag in ("br", "p", "div"): + self.parts.append("\n") + + def handle_endtag(self, tag: str) -> None: + if tag in ("p", "div"): + self.parts.append("\n") + + +def description_source_url(description: Any) -> str | None: + if not isinstance(description, str): + return None + parser = DescriptionText() + parser.feed(description) + first_line = next( + (line.strip() for line in "".join(parser.parts).splitlines() if line.strip()), + "", + ) + match = re.fullmatch( + r"GitHub:\s+(https://github\.com/[^/\s]+/[^/\s]+/(?:issues|pull)/[1-9][0-9]*/?)", + first_line, + ) + return match.group(1).rstrip("/").casefold() if match else None def iso_after_days(value: str, days: int) -> str: @@ -371,14 +425,14 @@ def sync_labels( return name_to_id -def find_workitem( +def search_workitems( org: str, project_id: str, prefix: str, client: YunxiaoClient, *, category: str = DEFAULT_WORKITEM_CATEGORY, -) -> str | None: +) -> list[dict[str, Any]]: conditions = json.dumps( { "conditionGroups": [ @@ -395,24 +449,50 @@ def find_workitem( }, ensure_ascii=False, ) - response = client.transport( - "POST", - f"/oapi/v1/projex/organizations/{org}/workitems:search", - {"spaceId": project_id, "category": category, "conditions": conditions}, - ) - data = ( - response - if isinstance(response, list) - else (response or {}).get("data", response) - if isinstance(response, dict) - else response - ) - items = ( - (data.get("workitems", data.get("items", [])) if isinstance(data, dict) else data) - if data - else [] - ) - return item_id(items[0]) if items else None + result: list[dict[str, Any]] = [] + seen: set[str] = set() + for page in range(1, SEARCH_WINDOW // SEARCH_PAGE_SIZE + 1): + response = client.transport( + "POST", + f"/oapi/v1/projex/organizations/{org}/workitems:search", + { + "spaceId": project_id, + "category": category, + "conditions": conditions, + "page": page, + "perPage": SEARCH_PAGE_SIZE, + "orderBy": "gmtCreate", + "sort": "asc", + }, + ) + data = response.get("data", response) if isinstance(response, dict) else response + if isinstance(data, dict): + data = data.get("workitems", data.get("items")) + if not isinstance(data, list) or not all(isinstance(x, dict) for x in data): + raise PreflightError("无法解析云效工作项搜索结果,停止以避免重复建卡") + for workitem in data: + identifier = item_id(workitem) + if identifier in seen: + raise PreflightError("云效搜索分页返回重复工作项,请重试") + seen.add(identifier) + result.append(workitem) + if len(data) < SEARCH_PAGE_SIZE: + return result + raise PreflightError("云效搜索达到 10000 条分页窗口,请缩小范围后重试") + + +def workitem_details( + org: str, workitem: dict[str, Any], client: YunxiaoClient +) -> dict[str, Any]: + if "description" in workitem and "subject" in workitem: + return workitem + identifier = item_id(workitem) + detail = client.get(f"/oapi/v1/projex/organizations/{org}/workitems/{identifier}") + if isinstance(detail, dict): + detail = detail.get("data", detail) + if not isinstance(detail, dict) or item_id(detail) != identifier: + raise PreflightError(f"无法核对云效工作项:{identifier}") + return detail def legacy_source_key(item_type: str, item: dict[str, Any]) -> str: @@ -429,25 +509,31 @@ def find_existing_workitem( *, category: str, ) -> str | None: - current_key = build_source_key(item_type, item, repository) - existing_id = find_workitem( - org, - project_id, - current_key, - client, - category=category, - ) - if existing_id: - return existing_id - # MemOS PR #2159 used a repository-less key. Keep this fallback during - # migration so a new workflow cannot duplicate those existing work items. - return find_workitem( - org, - project_id, + # Display titles are short; the first description line identifies the source. + # Search all historical forms so a rename cannot cause a duplicate card. + keys = ( + f"[{SOURCE_LABELS[item_type]} #{item['number']}]", + build_source_key(item_type, item, repository), legacy_source_key(item_type, item), - client, - category=category, ) + expected_url = source_url(item_type, item["number"], repository).casefold() + matches: set[str] = set() + for key in keys: + for candidate in search_workitems(org, project_id, key, client, category=category): + candidate = workitem_details(org, candidate, client) + subject = candidate.get("subject", "") + if subject != key and not subject.startswith(f"{key} "): + continue + candidate_url = description_source_url(candidate.get("description")) + if not candidate_url: + raise PreflightError( + f"工作项 {item_id(candidate)} 缺少可验证的 GitHub 来源,停止同步" + ) + if candidate_url == expected_url: + matches.add(item_id(candidate)) + if len(matches) > 1: + raise PreflightError(f"同一 GitHub 来源匹配到多个云效工作项:{sorted(matches)}") + return next(iter(matches), None) def item_description(item: dict[str, Any]) -> str: @@ -484,8 +570,19 @@ def sync_one( if existing_id: if not apply: return "dry-run-status" + current = workitem_details(org, {"id": existing_id}, client) + subject = shortened_subject(current.get("subject", ""), item_type, item, repository) + if ( + subject is None + or description_source_url(current.get("description")) + != source_url(item_type, item["number"], repository).casefold() + ): + raise PreflightError(f"工作项 {existing_id} 的来源或标题已变更,请重新同步") status_name = source_status(item_type, item) - update_payload: dict[str, Any] = {"status": cfg["statuses"][status_name]} + update_payload: dict[str, Any] = { + "status": cfg["statuses"][status_name], + "subject": subject, + } if cfg.get("sprint_id"): update_payload["sprint"] = cfg["sprint_id"] if cfg.get("assignee_id"): @@ -680,6 +777,86 @@ def github_collection( page += 1 +def rename_titles(client: YunxiaoClient, *, apply: bool) -> int: + """Shorten existing cards only; never create cards or reset other fields.""" + repository = repository_name() + cfg = configuration_from_environment(client) + org = cfg["org"] + candidates: dict[str, dict[str, Any]] = {} + # Finish reading every page before any PUT changes the search results. + for prefix in ( + f"[GitHub {repository} ", "[GitHub Issue #", "[GitHub PR #", "[Issue #", "[PR #" + ): + for workitem in search_workitems( + org, cfg["project_id"], prefix, client, category=cfg["workitem_category"] + ): + candidates[item_id(workitem)] = workitem + + pattern = re.compile( + rf"^\[GitHub (?:{re.escape(repository)} )?(Issue|PR) #([1-9][0-9]*)\]( .*)?$", + re.DOTALL, + ) + short_pattern = re.compile(r"^\[(Issue|PR) #([1-9][0-9]*)\]( .*)?$", re.DOTALL) + stats = {"source": len(candidates), "would_rename": 0, "renamed": 0, "skipped": 0, "failed": 0} + planned: dict[str, list[tuple[str, str, str]]] = {} + for identifier, candidate in candidates.items(): + try: + candidate = workitem_details(org, candidate, client) + old_title = candidate.get("subject", "") + match = pattern.fullmatch(old_title) or short_pattern.fullmatch(old_title) + if not match: + stats["skipped"] += 1 + continue + label, number, suffix = match.groups() + kind = "issue" if label == "Issue" else "pr" + expected_url = source_url(kind, int(number), repository).casefold() + actual_url = description_source_url(candidate.get("description")) + if not actual_url: + raise PreflightError("缺少可验证的 GitHub 来源") + if actual_url != expected_url: + stats["skipped"] += 1 + continue + new_title = f"[{label} #{number}]{suffix or ''}" + planned.setdefault(expected_url, []).append((identifier, old_title, new_title)) + except (YunxiaoApiError, PreflightError, OSError) as error: + stats["failed"] += 1 + print(f"failed rename {identifier}: {error}", file=sys.stderr) + + for expected_url, changes in planned.items(): + if len(changes) != 1: + stats["failed"] += len(changes) + print(f"同一 GitHub 来源有多个工作项,跳过整理:{expected_url}", file=sys.stderr) + continue + identifier, old_title, new_title = changes[0] + if old_title == new_title: + stats["skipped"] += 1 + continue + if not apply: + stats["would_rename"] += 1 + continue + try: + # Re-read before writing so a manual edit during the scan is not lost. + current = workitem_details(org, {"id": identifier}, client) + if ( + current.get("subject") != old_title + or description_source_url(current.get("description")) != expected_url + ): + raise PreflightError("工作项在扫描后已变更,请重新预览") + client.transport( + "PUT", + f"/oapi/v1/projex/organizations/{org}/workitems/{identifier}", + {"subject": new_title}, + ) + stats["renamed"] += 1 + except (YunxiaoApiError, PreflightError, OSError) as error: + stats["failed"] += 1 + print(f"failed rename {identifier}: {error}", file=sys.stderr) + time.sleep(0.12) + + print(json.dumps({"titles": stats}, ensure_ascii=False)) + return 1 if stats["failed"] else 0 + + def backfill(client: YunxiaoClient, *, apply: bool, state: str) -> int: github_token = os.environ.get("GH_TOKEN", "").strip() repository = repository_name(os.environ.get("GITHUB_REPOSITORY")) @@ -740,7 +917,9 @@ def backfill(client: YunxiaoClient, *, apply: bool, state: str) -> int: def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="GitHub → 云效同步") - parser.add_argument("--mode", choices=("preflight", "event", "backfill"), default="preflight") + parser.add_argument( + "--mode", choices=("preflight", "event", "backfill", "rename-titles"), default="preflight" + ) parser.add_argument("--state", choices=("open", "all"), default="open") parser.add_argument("--apply", action="store_true") parser.add_argument("--dry-run", action="store_true") @@ -769,6 +948,8 @@ def main() -> int: return handle_event(client) if args.mode == "backfill": return backfill(client, apply=args.apply and not args.dry_run, state=args.state) + if args.mode == "rename-titles": + return rename_titles(client, apply=args.apply and not args.dry_run) result = configuration_from_environment(client) print( diff --git a/tests/yunxiao-github-sync-workflow.test.ts b/tests/yunxiao-github-sync-workflow.test.ts index 151487196..5497435d8 100644 --- a/tests/yunxiao-github-sync-workflow.test.ts +++ b/tests/yunxiao-github-sync-workflow.test.ts @@ -1,5 +1,7 @@ -import { readFileSync } from "node:fs"; -import { resolve } from "node:path"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { delimiter, join, resolve } from "node:path"; import { describe, expect, it } from "vitest"; import YAML from "yaml"; @@ -7,6 +9,55 @@ const repoRoot = resolve(import.meta.dirname, ".."); const workflowPath = resolve(repoRoot, ".github/workflows/yunxiao-github-sync.yml"); const workflowSource = readFileSync(workflowPath, "utf8"); const workflow = YAML.parse(workflowSource) as Record; +const runStep = workflow.jobs.sync.steps.find( + (step: Record) => step.name === "Run sync", +); + +function runSyncStep({ + eventName, + mode = "", + state = "", + apply = "", +}: { + eventName: string; + mode?: string; + state?: string; + apply?: string; +}): string[] { + const expressions: Record = { + "github.event_name": eventName, + "inputs.mode": mode, + "inputs.state": state, + "inputs.apply": apply, + }; + const script = runStep.run.replace( + /\$\{\{\s*([^}]+?)\s*\}\}/g, + (_match: string, expression: string) => { + const key = expression.trim(); + if (!Object.hasOwn(expressions, key)) { + throw new Error(`Unexpected workflow expression: ${key}`); + } + return expressions[key]; + }, + ); + const directory = mkdtempSync(join(tmpdir(), "memmy-yunxiao-workflow-")); + try { + // Capture the real shell invocation without executing the sync or its APIs. + writeFileSync(join(directory, "python"), '#!/bin/sh\nprintf \'%s\\0\' "$@"\n', { + mode: 0o755, + }); + const output = execFileSync("bash", ["-c", script], { + cwd: repoRoot, + encoding: "utf8", + env: { + PATH: `${directory}${delimiter}${process.env.PATH ?? ""}`, + }, + }); + return output.split("\0").slice(0, -1); + } finally { + rmSync(directory, { recursive: true, force: true }); + } +} describe("GitHub to Yunxiao sync workflow", () => { it("handles issue and trusted pull request lifecycle events", () => { @@ -19,18 +70,78 @@ describe("GitHub to Yunxiao sync workflow", () => { expect(workflow.on.pull_request).toBeUndefined(); }); - it("provides preflight, dry-run backfill, and all-state backfill controls", () => { + it("provides preflight, backfill, and title-only migration controls", () => { const inputs = workflow.on.workflow_dispatch.inputs; - expect(inputs.mode.options).toEqual(["preflight", "backfill"]); + expect(inputs.mode.options).toEqual(["preflight", "backfill", "rename-titles"]); expect(inputs.state.options).toEqual(["open", "all"]); expect(inputs.apply.type).toBe("boolean"); expect(workflow.jobs.sync.steps.at(-1).run).toContain("--dry-run"); }); + it.each(["backfill", "rename-titles"])( + "executes %s in dry-run mode when apply is false or omitted", + (mode) => { + for (const apply of ["false", ""]) { + expect( + runSyncStep({ eventName: "workflow_dispatch", mode, state: "open", apply }), + ).toEqual([ + "scripts/yunxiao_github_sync.py", + "--mode", + mode, + "--state", + "open", + "--dry-run", + ]); + } + }, + ); + + it.each(["backfill", "rename-titles"])( + "executes %s with writes only when apply is true", + (mode) => { + expect( + runSyncStep({ eventName: "workflow_dispatch", mode, state: "all", apply: "true" }), + ).toEqual([ + "scripts/yunxiao_github_sync.py", + "--mode", + mode, + "--state", + "all", + "--apply", + ]); + }, + ); + + it.each(["issues", "pull_request_target"])( + "keeps %s lifecycle events in apply mode", + (eventName) => { + expect(runSyncStep({ eventName })).toEqual([ + "scripts/yunxiao_github_sync.py", + "--mode", + "event", + "--apply", + ]); + }, + ); + + it("keeps preflight read-only even when apply is true", () => { + expect( + runSyncStep({ + eventName: "workflow_dispatch", + mode: "preflight", + state: "open", + apply: "true", + }), + ).toEqual([ + "scripts/yunxiao_github_sync.py", + "--mode", + "preflight", + "--state", + "open", + ]); + }); + it("routes each repository through its own Yunxiao mapping variables", () => { - const runStep = workflow.jobs.sync.steps.find( - (step: Record) => step.name === "Run sync", - ); expect(runStep.env.YUNXIAO_PROJECT_ID).toContain("vars.YUNXIAO_PROJECT_ID"); expect(runStep.env.YUNXIAO_PARENT_ID).toContain("vars.YUNXIAO_PARENT_ID"); expect(runStep.env.YUNXIAO_SPRINT_ID).toContain("vars.YUNXIAO_SPRINT_ID");