From 83a6230afbd763b909f18509ec681b6359d4f7a2 Mon Sep 17 00:00:00 2001 From: jiachengzhen Date: Thu, 10 Sep 2026 16:35:50 +0800 Subject: [PATCH 1/3] feat: sync GitHub issues to Yunxiao --- .github/workflows/yunxiao-github-sync.yml | 88 +++ docs/github-yunxiao-sync.md | 74 +++ scripts/test_yunxiao_github_sync.py | 166 +++++ scripts/yunxiao_github_sync.py | 712 +++++++++++++++++++++ tests/yunxiao-github-sync-workflow.test.ts | 54 ++ 5 files changed, 1094 insertions(+) create mode 100644 .github/workflows/yunxiao-github-sync.yml create mode 100644 docs/github-yunxiao-sync.md create mode 100644 scripts/test_yunxiao_github_sync.py create mode 100644 scripts/yunxiao_github_sync.py create mode 100644 tests/yunxiao-github-sync-workflow.test.ts diff --git a/.github/workflows/yunxiao-github-sync.yml b/.github/workflows/yunxiao-github-sync.yml new file mode 100644 index 000000000..9f3ddeedb --- /dev/null +++ b/.github/workflows/yunxiao-github-sync.yml @@ -0,0 +1,88 @@ +name: Sync GitHub to Yunxiao + +on: + issues: + types: [opened, closed, reopened] + pull_request_target: + types: [opened, closed, reopened] + workflow_dispatch: + inputs: + mode: + description: 运行模式 + required: true + type: choice + default: preflight + options: + - preflight + - backfill + state: + description: 回填 GitHub 数据范围 + required: false + type: choice + default: open + options: + - open + - all + apply: + description: 回填时是否实际写入云效 + required: false + type: boolean + default: false + +permissions: + contents: read + issues: read + pull-requests: read + +concurrency: + group: yunxiao-github-sync-${{ github.repository }} + cancel-in-progress: false + +jobs: + sync: + runs-on: ubuntu-latest + timeout-minutes: 30 + + steps: + # pull_request_target uses the trusted default branch. Never execute the + # workflow or script from an untrusted PR branch. + - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Run sync + env: + YUNXIAO_TOKEN: ${{ secrets.YUNXIAO_TOKEN }} + YUNXIAO_API_BASE_URL: ${{ vars.YUNXIAO_API_BASE_URL }} + # memmy-agent defaults to the memmy Yunxiao project. Repository + # Variables can override these values for a fork or another space. + YUNXIAO_PROJECT_ID: ${{ vars.YUNXIAO_PROJECT_ID || '1832b179386e24414d3891e244' }} + YUNXIAO_PROJECT_NAME: ${{ vars.YUNXIAO_PROJECT_NAME || 'memmy' }} + YUNXIAO_PARENT_ID: ${{ vars.YUNXIAO_PARENT_ID }} + YUNXIAO_WORKITEM_CATEGORY: ${{ vars.YUNXIAO_WORKITEM_CATEGORY }} + YUNXIAO_WORKITEM_TYPE_NAME: ${{ vars.YUNXIAO_WORKITEM_TYPE_NAME }} + YUNXIAO_PRIORITY_NAME: ${{ vars.YUNXIAO_PRIORITY_NAME }} + YUNXIAO_DAYS_TO_FINISH: ${{ vars.YUNXIAO_DAYS_TO_FINISH }} + # Optional. Empty means Yunxiao leaves the new work item unassigned. + YUNXIAO_DEFAULT_ASSIGNEE_NAME: ${{ vars.YUNXIAO_DEFAULT_ASSIGNEE_NAME }} + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + args=(--mode "${{ inputs.mode }}" --state "${{ inputs.state }}") + if [ "${{ inputs.mode }}" = "backfill" ] && [ "${{ inputs.apply }}" != "true" ]; then + args+=(--dry-run) + fi + elif [ "${{ github.event_name }}" = "issues" ] || [ "${{ github.event_name }}" = "pull_request_target" ]; then + args=(--mode event --apply) + else + args=(--mode preflight) + fi + + python scripts/yunxiao_github_sync.py "${args[@]}" diff --git a/docs/github-yunxiao-sync.md b/docs/github-yunxiao-sync.md new file mode 100644 index 000000000..2e8e22cf8 --- /dev/null +++ b/docs/github-yunxiao-sync.md @@ -0,0 +1,74 @@ +# GitHub Issue 到云效同步 + +这个仓库使用 GitHub Actions 直接调用云效 OpenAPI,把 GitHub Issue 和 Pull Request 映射成云效工作项。`coding agent` 继续负责开发任务、分支和 PR,不参与这条 CRUD 同步链路。 + +同步链路如下: + +```text +GitHub issues / pull_request_target + -> .github/workflows/yunxiao-github-sync.yml + -> scripts/yunxiao_github_sync.py + -> 云效 OpenAPI + -> 目标空间(project)/目录(parentId)中的工作项 +``` + +## 仓库配置 + +在每个 GitHub 源仓库的 `Settings -> Secrets and variables -> Actions` 中配置: + +| 名称 | 类型 | 说明 | +| --- | --- | --- | +| `YUNXIAO_TOKEN` | Secret | 云效 PAT,只授予目标空间的工作项读写权限 | +| `YUNXIAO_PROJECT_ID` | Variable | 目标云效空间/项目 ID | +| `YUNXIAO_PROJECT_NAME` | Variable | 目标空间/项目名称,用于预检 | +| `YUNXIAO_PARENT_ID` | Variable | 目标目录对应的父工作项 ID;没有目录时留空 | +| `YUNXIAO_DEFAULT_ASSIGNEE_NAME` | Variable | 可选,默认负责人姓名;留空则不自动分配负责人 | +| `YUNXIAO_WORKITEM_CATEGORY` | Variable | 工作项大类,默认 `Req` | +| `YUNXIAO_WORKITEM_TYPE_NAME` | Variable | 工作项类型名称,默认 `需求` | +| `YUNXIAO_PRIORITY_NAME` | Variable | 默认优先级,默认 `中` | +| `YUNXIAO_DAYS_TO_FINISH` | Variable | 计划完成时间距创建时间的天数,默认 `7` | +| `YUNXIAO_API_BASE_URL` | Variable | 可选,默认 `https://openapi-rdc.aliyuncs.com` | + +`memmy-agent` 默认使用云效项目 `memmy`,项目 ID 为 +`1832b179386e24414d3891e244`。该默认值已经写入 workflow;如果仓库 Variables +中配置了同名变量,则以 Variables 为准。 + +`YUNXIAO_DEFAULT_ASSIGNEE_NAME` 可以暂时留空。这样工作项仍会进入 +`memmy` 需求空间,由云效用户后续人工分配负责人。 + +每个源仓库可以使用不同的 `YUNXIAO_PROJECT_ID` 和 `YUNXIAO_PARENT_ID`。例如: + +| GitHub 仓库 | 云效空间 | 云效目录 | +| --- | --- | --- | +| `MemTensor/memmy-agent` | `memmy` (`1832b179386e24414d3891e244`) | 留空,直接进入需求空间 | +| `MemTensor/MemOS` | MemOS 开源项目管理 | MemOS 目录 | +| `MemTensor/MemOS-Cloud-CLI` | CLI 项目 | CLI 目录 | + +你提供的页面 URL 中 `viewIdentifier=d7f112f9d023e2108fa1b0d8` 是云效“需求”列表视图标识, +不是创建工作项 API 的 `parentId`。当前需求是写入 `memmy` 空间,因此 +`YUNXIAO_PARENT_ID` 应保持为空。只有确认了一个真实的父工作项/目录 ID 后,才配置 +`YUNXIAO_PARENT_ID`。 + +如果云效页面中的“目录”只是列表视图,而不是父工作项,应改为配置不同的 +`YUNXIAO_PROJECT_ID` 或保持 `YUNXIAO_PARENT_ID` 为空。 + +## 运行方式 + +第一次接入按以下顺序运行 `Sync GitHub to Yunxiao`: + +1. `mode=preflight`:只检查 Token、空间、工作项类型、状态、优先级和负责人。 +2. `mode=backfill`、`state=open`、`apply=false`:查看回填 dry-run 结果。 +3. 确认结果后再次运行 `mode=backfill`、`state=open`、`apply=true`。 +4. 需要把历史关闭项也纳入同步时,将 `state` 设为 `all`。 + +实时事件会处理 `opened`、`closed` 和 `reopened`。已存在的云效工作项只更新状态,不重复创建。 + +幂等键包含完整源仓库名,例如: + +```text +[GitHub MemTensor/memmy-agent Issue #123] +``` + +因此不同仓库即使使用相同的 Issue 编号,也不会互相覆盖。Issue 标题、链接、正文和 GitHub labels 会写入新建工作项;评论暂不同步。 + +其他 GitHub 仓库接入时复制这个 workflow 和脚本,再为该仓库设置自己的 Variables/Secret 即可。仓库数量继续增长后,可以把脚本移动到同组织的私有 reusable workflow 仓库,源仓库只保留一个调用文件,映射仍由各源仓库自己的 Variables 管理。 diff --git a/scripts/test_yunxiao_github_sync.py b/scripts/test_yunxiao_github_sync.py new file mode 100644 index 000000000..c85cb8bc5 --- /dev/null +++ b/scripts/test_yunxiao_github_sync.py @@ -0,0 +1,166 @@ +from __future__ import annotations + +import importlib.util +import unittest +from pathlib import Path + + +MODULE_PATH = Path(__file__).with_name("yunxiao_github_sync.py") +SPEC = importlib.util.spec_from_file_location("yunxiao_github_sync", MODULE_PATH) +assert SPEC is not None and SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +class YunxiaoSyncTests(unittest.TestCase): + def test_source_key_includes_repository(self) -> None: + item = {"number": 42, "title": "Fix scheduler"} + self.assertEqual( + MODULE.build_source_key("issue", item, "MemTensor/memmy-agent"), + "[GitHub MemTensor/memmy-agent Issue #42]", + ) + + def test_title_includes_source_key(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", + ) + + def test_source_status(self) -> None: + self.assertEqual(MODULE.source_status("issue", {"state": "open"}), "待处理") + self.assertEqual( + MODULE.source_status("pr", {"state": "closed", "merged": True}), + "已完成", + ) + self.assertEqual( + MODULE.source_status("issue", {"state": "closed"}), + "已取消", + ) + + def test_create_payload_contains_parent(self) -> None: + calls = [] + + def transport(method, path, body=None): + calls.append((method, path, body)) + if "search" in path: + return {"data": {"workitems": []}} + return {"id": "new-id"} + + cfg = { + "project_id": "project", + "workitem_category": "Req", + "type_id": "type", + "assignee_id": "assignee", + "priority_id": "priority", + "parent_id": "directory", + "statuses": {"待处理": "pending", "已取消": "cancelled"}, + } + item = { + "number": 1, + "title": "Test", + "html_url": "https://github.com/MemTensor/memmy-agent/issues/1", + "body": "Details", + "created_at": "2026-09-10T00:00:00Z", + "state": "open", + "labels": [], + } + result = MODULE.sync_one( + "org", + cfg, + "issue", + item, + repository="MemTensor/memmy-agent", + apply=True, + label_ids={}, + days_to_finish=7, + create_closed=False, + client=MODULE.YunxiaoClient(transport), + ) + self.assertEqual(result, "created") + self.assertEqual(calls[2][2]["parentId"], "directory") + self.assertIn("Details", calls[2][2]["description"]) + + def test_reopened_item_updates_to_pending(self) -> None: + calls = [] + + def transport(method, path, body=None): + calls.append((method, path, body)) + if "search" in path: + return {"data": {"workitems": [{"id": "existing"}]}} + return {} + + cfg = { + "project_id": "project", + "workitem_category": "Req", + "type_id": "type", + "assignee_id": "assignee", + "priority_id": "priority", + "parent_id": "", + "statuses": {"待处理": "pending", "已取消": "cancelled"}, + } + result = MODULE.sync_one( + "org", + cfg, + "issue", + { + "number": 2, + "title": "Reopen", + "html_url": "https://github.com/MemTensor/memmy-agent/issues/2", + "created_at": "2026-09-10T00:00:00Z", + "state": "open", + "labels": [], + }, + repository="MemTensor/memmy-agent", + apply=True, + label_ids={}, + days_to_finish=7, + create_closed=False, + client=MODULE.YunxiaoClient(transport), + ) + self.assertEqual(result, "updated-status") + self.assertEqual(calls[-1][2], {"status": "pending"}) + + def test_all_state_backfill_can_create_closed_item(self) -> None: + calls = [] + + def transport(method, path, body=None): + calls.append((method, path, body)) + if "search" in path: + return {"data": {"workitems": []}} + return {"id": "new-id"} + + cfg = { + "project_id": "project", + "workitem_category": "Req", + "type_id": "type", + "assignee_id": "assignee", + "priority_id": "priority", + "parent_id": "", + "statuses": {"待处理": "pending", "已取消": "cancelled"}, + } + result = MODULE.sync_one( + "org", + cfg, + "issue", + { + "number": 3, + "title": "Closed before rollout", + "html_url": "https://github.com/MemTensor/memmy-agent/issues/3", + "created_at": "2026-09-10T00:00:00Z", + "state": "closed", + "labels": [], + }, + repository="MemTensor/memmy-agent", + apply=True, + label_ids={}, + days_to_finish=7, + create_closed=True, + client=MODULE.YunxiaoClient(transport), + ) + self.assertEqual(result, "created") + self.assertEqual(calls[-1][2]["status"], "cancelled") + + +if __name__ == "__main__": + unittest.main() diff --git a/scripts/yunxiao_github_sync.py b/scripts/yunxiao_github_sync.py new file mode 100644 index 000000000..6e6a108ff --- /dev/null +++ b/scripts/yunxiao_github_sync.py @@ -0,0 +1,712 @@ +from __future__ import annotations + +import argparse +import json +import os +import sys +import time + +from datetime import datetime, timedelta, timezone +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.parse import quote, urlencode +from urllib.request import Request, urlopen + + +SOURCE_LABELS = {"issue": "Issue", "pr": "PR"} +STATUS_LABELS = ("待处理", "设计中", "开发中", "已完成", "已取消") +DEFAULT_PRIORITY_NAME = "中" +DEFAULT_WORKITEM_CATEGORY = "Req" +DEFAULT_WORKITEM_TYPE_NAME = "需求" +DEFAULT_DAYS_TO_FINISH = 7 +REQUIRED_ENV = ( + "YUNXIAO_PROJECT_ID", + "YUNXIAO_PROJECT_NAME", +) + + +class PreflightError(ValueError): + pass + + +class YunxiaoApiError(RuntimeError): + pass + + +def required_env(name: str) -> str: + value = os.environ.get(name, "").strip() + if not value: + raise PreflightError(f"缺少必需的环境变量:{name}(请在仓库 Variables 中配置)") + return value + + +class UrllibTransport: + def __init__( + self, + token: str, + *, + base_url: str = "https://openapi-rdc.aliyuncs.com", + timeout_seconds: int = 30, + ) -> None: + self._token = token + self._base_url = base_url.rstrip("/") + self._timeout_seconds = timeout_seconds + + def __call__(self, method: str, path: str, body: dict[str, Any] | None = None) -> Any: + data = json.dumps(body, ensure_ascii=False).encode() if body is not None else None + request = Request( + f"{self._base_url}{path}", + data=data, + method=method, + headers={ + "Accept": "application/json", + "Content-Type": "application/json", + "x-yunxiao-token": self._token, + }, + ) + try: + with urlopen(request, timeout=self._timeout_seconds) as response: + payload = response.read().decode() + if not payload.strip(): + return None + return json.loads(payload) + except HTTPError as error: + raise YunxiaoApiError( + f"云效 API HTTP {error.code}: {error.read().decode(errors='replace')[:300]}" + ) from error + except URLError as error: + raise YunxiaoApiError(f"无法连接云效 API:{error.reason}") from error + + +def repository_name(repository: str | None = None) -> str: + value = (repository or os.environ.get("GITHUB_REPOSITORY", "")).strip() + if not value or "/" not in value: + raise PreflightError("缺少有效的 GitHub 仓库标识,要求格式为 owner/repository") + return value + + +def build_source_key( + item_type: str, + item: dict[str, Any], + repository: str | None = None, +) -> str: + return f"[GitHub {repository_name(repository)} {SOURCE_LABELS[item_type]} #{item['number']}]" + + +def build_title( + item_type: str, + item: dict[str, Any], + repository: str | None = None, +) -> str: + return f"{build_source_key(item_type, item, repository)} {item['title']}" + + +def iso_after_days(value: str, days: int) -> str: + created_at = datetime.fromisoformat(value.replace("Z", "+00:00")) + return ( + (created_at + timedelta(days=days)) + .astimezone(timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ) + + +def source_status(item_type: str, item: dict[str, Any]) -> str: + if item.get("state") == "open": + return "待处理" + if item_type == "pr" and (item.get("merged") or item.get("merged_at")): + return "已完成" + return "已取消" + + +def item_id(item: dict[str, Any]) -> str: + for key in ("id", "identifier", "organizationId", "userId", "statusId", "value"): + value = item.get(key) + if value: + return str(value) + raise PreflightError(f"云效对象缺少 id:{item}") + + +def item_name(item: dict[str, Any]) -> str | None: + for key in ("name", "displayName", "userName", "displayValue", "statusName"): + value = item.get(key) + if isinstance(value, str): + return value + return None + + +def list_or_extract(payload: Any, keys: tuple[str, ...]) -> list[dict[str, Any]]: + if isinstance(payload, dict): + for key in (*keys, "items"): + value = payload.get(key) + if isinstance(value, list) and all(isinstance(x, dict) for x in value): + return value + if isinstance(payload, list) and all(isinstance(x, dict) for x in payload): + return payload + return [] + + +def find_by_name(items: list[dict[str, Any]], name: str, label: str) -> dict[str, Any]: + for item in items: + if item_name(item) == name: + return item + raise PreflightError(f"未在云效中找到{label}:{name}") + + +class YunxiaoClient: + def __init__(self, transport: Any) -> None: + self.transport = transport + + def get(self, path: str) -> Any: + return self.transport("GET", path) + + +def preflight( + project_id: str, + project_name: str, + assignee_name: str | None, + client: YunxiaoClient, + *, + workitem_category: str = DEFAULT_WORKITEM_CATEGORY, + workitem_type_name: str = DEFAULT_WORKITEM_TYPE_NAME, + priority_name: str = DEFAULT_PRIORITY_NAME, + parent_id: str | None = None, +) -> dict[str, Any]: + organizations = list_or_extract(client.get("/oapi/v1/platform/organizations"), ()) + if not organizations: + raise PreflightError("PAT 无法访问任何云效组织") + + for organization in organizations: + organization_id = item_id(organization) + project = client.get( + f"/oapi/v1/projex/organizations/{organization_id}/projects/{project_id}" + ) + if isinstance(project, dict) and project.get("name") == project_name: + org = organization_id + break + else: + raise PreflightError(f"未找到项目:{project_name}({project_id})") + + encoded_category = quote(workitem_category, safe="") + types = list_or_extract( + client.get( + f"/oapi/v1/projex/organizations/{org}/projects/{project_id}" + f"/workitemTypes?category={encoded_category}" + ), + ("workitemTypes",), + ) + workitem_type = next( + (value for value in types if item_name(value) == workitem_type_name), + None, + ) + if workitem_type is None: + workitem_type = next( + ( + value + for value in types + if value.get("categoryId", value.get("category")) == workitem_category + ), + None, + ) + if not workitem_type: + raise PreflightError( + f"目标项目没有工作项类型:{workitem_type_name}(category={workitem_category})" + ) + type_id = item_id(workitem_type) + + assignee_id = "" + if assignee_name: + members = list_or_extract( + client.get(f"/oapi/v1/projex/organizations/{org}/projects/{project_id}/members"), + ("members",), + ) + assignee_id = item_id(find_by_name(members, assignee_name, "默认负责人")) + + workflow = list_or_extract( + client.get( + f"/oapi/v1/projex/organizations/{org}/projects/{project_id}" + f"/workitemTypes/{type_id}/workflows" + ), + ("statuses", "workflowStatuses"), + ) + statuses = { + name: item_id(find_by_name(workflow, name, "工作流状态")) + for name in STATUS_LABELS + } + + fields = list_or_extract( + client.get( + f"/oapi/v1/projex/organizations/{org}/projects/{project_id}" + f"/workitemTypes/{type_id}/fields" + ), + ("fields", "fieldConfigs"), + ) + priority_field = next( + ( + field + for field in fields + if field.get("id") == "priority" + or field.get("identifier") == "priority" + or field.get("fieldIdentifier") == "priority" + ), + None, + ) + if not priority_field: + raise PreflightError("工作项类型缺少优先级字段") + options = list_or_extract( + priority_field.get("options") or priority_field.get("values") or {}, + (), + ) + if not options: + options = priority_field.get("options") or priority_field.get("values") or [] + priority = find_by_name(options, priority_name, "默认优先级") + + return { + "org": org, + "project_id": project_id, + "workitem_category": workitem_category, + "type_id": type_id, + "type_name": item_name(workitem_type) or workitem_type_name, + "assignee_id": assignee_id, + "priority_id": item_id(priority), + "statuses": statuses, + "parent_id": parent_id or "", + } + + +def sync_labels( + org: str, + project_id: str, + wanted: set[str], + client: YunxiaoClient, +) -> dict[str, str]: + existing_raw = client.get( + f"/oapi/v1/projex/organizations/{org}/projects/{project_id}/labels" + ) + existing_names: set[str] = set() + name_to_id: dict[str, str] = {} + for label in list_or_extract(existing_raw, ()): + name = item_name(label) or label.get("name", "") + if name: + existing_names.add(name) + name_to_id[name] = item_id(label) + + colors = [ + "#e91e63", + "#9c27b0", + "#673ab7", + "#3f51b5", + "#2196f3", + "#00bcd4", + "#009688", + "#4caf50", + "#8bc34a", + "#cddc39", + "#ffc107", + "#ff9800", + "#ff5722", + ] + for index, name in enumerate(sorted(wanted - existing_names)): + try: + response = client.transport( + "POST", + f"/oapi/v1/projex/organizations/{org}/projects/{project_id}/labels", + {"name": name, "color": colors[index % len(colors)]}, + ) + name_to_id[name] = item_id(response) + time.sleep(0.1) + except YunxiaoApiError: + # A concurrent workflow may have created the same label. + pass + return name_to_id + + +def find_workitem( + org: str, + project_id: str, + prefix: str, + client: YunxiaoClient, + *, + category: str = DEFAULT_WORKITEM_CATEGORY, +) -> str | None: + conditions = json.dumps( + { + "conditionGroups": [ + [ + { + "fieldIdentifier": "subject", + "operator": "CONTAINS", + "value": [prefix], + "className": "string", + "format": "input", + } + ] + ] + }, + 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 + + +def legacy_source_key(item_type: str, item: dict[str, Any]) -> str: + return f"[GitHub {SOURCE_LABELS[item_type]} #{item['number']}]" + + +def find_existing_workitem( + org: str, + project_id: str, + item_type: str, + item: dict[str, Any], + repository: str, + client: YunxiaoClient, + *, + 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, + legacy_source_key(item_type, item), + client, + category=category, + ) + + +def item_description(item: dict[str, Any]) -> str: + url = str(item.get("html_url", "")).strip() + body = str(item.get("body") or "").strip() + if body: + return f"GitHub: {url}\n\n{body}" + return f"GitHub: {url}" + + +def sync_one( + org: str, + cfg: dict[str, Any], + item_type: str, + item: dict[str, Any], + *, + repository: str, + apply: bool, + label_ids: dict[str, str] | None, + days_to_finish: int, + create_closed: bool, + client: YunxiaoClient, +) -> str: + existing_id = find_existing_workitem( + org, + cfg["project_id"], + item_type, + item, + repository, + client, + category=cfg["workitem_category"], + ) + + if existing_id: + if not apply: + return "dry-run-status" + status_name = source_status(item_type, item) + client.transport( + "PUT", + f"/oapi/v1/projex/organizations/{org}/workitems/{existing_id}", + {"status": cfg["statuses"][status_name]}, + ) + return "updated-status" + + if item.get("state") != "open" and not create_closed: + return "skipped-closed" + if not apply: + return "dry-run-create" + + payload: dict[str, Any] = { + "spaceId": cfg["project_id"], + "workitemTypeId": cfg["type_id"], + "subject": build_title(item_type, item, repository), + "description": item_description(item), + "status": cfg["statuses"][source_status(item_type, item)], + "assignedTo": cfg["assignee_id"], + "priority": cfg["priority_id"], + "planStartTime": item["created_at"], + "planFinishTime": iso_after_days(item["created_at"], days_to_finish), + } + if not cfg.get("assignee_id"): + payload.pop("assignedTo") + if cfg.get("parent_id"): + payload["parentId"] = cfg["parent_id"] + + github_labels = [ + label["name"] + for label in item.get("labels", []) + if isinstance(label, dict) and label.get("name") + ] + labels = [label_ids[name] for name in github_labels if label_ids and name in label_ids] + if labels: + payload["labels"] = labels + + client.transport( + "POST", + f"/oapi/v1/projex/organizations/{org}/workitems", + payload, + ) + return "created" + + +def configuration_from_environment(client: YunxiaoClient) -> dict[str, Any]: + return preflight( + required_env("YUNXIAO_PROJECT_ID"), + required_env("YUNXIAO_PROJECT_NAME"), + os.environ.get("YUNXIAO_DEFAULT_ASSIGNEE_NAME", "").strip() or None, + client, + workitem_category=os.environ.get( + "YUNXIAO_WORKITEM_CATEGORY", + DEFAULT_WORKITEM_CATEGORY, + ).strip() + or DEFAULT_WORKITEM_CATEGORY, + workitem_type_name=os.environ.get( + "YUNXIAO_WORKITEM_TYPE_NAME", + DEFAULT_WORKITEM_TYPE_NAME, + ).strip() + or DEFAULT_WORKITEM_TYPE_NAME, + priority_name=os.environ.get( + "YUNXIAO_PRIORITY_NAME", + DEFAULT_PRIORITY_NAME, + ).strip() + or DEFAULT_PRIORITY_NAME, + parent_id=os.environ.get("YUNXIAO_PARENT_ID", "").strip() or None, + ) + + +def days_to_finish() -> int: + raw = os.environ.get("YUNXIAO_DAYS_TO_FINISH", "").strip() + if not raw: + return DEFAULT_DAYS_TO_FINISH + try: + value = int(raw) + except ValueError as error: + raise PreflightError("YUNXIAO_DAYS_TO_FINISH 必须是正整数") from error + if value < 1: + raise PreflightError("YUNXIAO_DAYS_TO_FINISH 必须是正整数") + return value + + +def handle_event(client: YunxiaoClient) -> int: + event_path = os.environ.get("GITHUB_EVENT_PATH") + if not event_path: + print("缺少 GITHUB_EVENT_PATH", file=sys.stderr) + return 2 + with open(event_path, encoding="utf-8") as event_file: + event = json.load(event_file) + + action = event.get("action", "") + if action not in ("opened", "closed", "reopened"): + print(f"忽略事件 action={action}", file=sys.stderr) + return 0 + + item = event.get("issue") or event.get("pull_request") + if not item: + print("事件中缺少 issue/pull_request", file=sys.stderr) + return 1 + + if event.get("issue") and "pull_request" in event["issue"]: + print("issue 事件包含 PR 元数据,跳过", file=sys.stderr) + return 0 + + item_type = "pr" if "pull_request" in event else "issue" + if item_type == "pr" and action == "closed": + item["merged"] = item.get("merged") or event.get("pull_request", {}).get("merged", False) + + repository = repository_name( + event.get("repository", {}).get("full_name") or os.environ.get("GITHUB_REPOSITORY") + ) + cfg = configuration_from_environment(client) + all_labels = { + label["name"] + for label in item.get("labels", []) + if isinstance(label, dict) and label.get("name") + } + label_ids = sync_labels(cfg["org"], cfg["project_id"], all_labels, client) if all_labels else {} + result = sync_one( + cfg["org"], + cfg, + item_type, + item, + repository=repository, + apply=True, + label_ids=label_ids, + days_to_finish=days_to_finish(), + create_closed=False, + client=client, + ) + print(f"{repository} {item_type} #{item['number']}: {result}") + return 0 + + +def github_collection( + repository: str, + path: str, + *, + state: str, + token: str, +) -> list[dict[str, Any]]: + result: list[dict[str, Any]] = [] + page = 1 + headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + } + while True: + query = urlencode({"state": state, "per_page": 100, "page": page}) + request = Request( + f"https://api.github.com/repos/{repository}/{path}?{query}", + headers=headers, + ) + with urlopen(request, timeout=30) as response: + batch = json.loads(response.read().decode()) + if not isinstance(batch, list) or not batch: + return result + result.extend(item for item in batch if isinstance(item, dict)) + if len(batch) < 100: + return result + page += 1 + + +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")) + issues_raw = github_collection(repository, "issues", state=state, token=github_token) + prs = github_collection(repository, "pulls", state=state, token=github_token) + issues = [item for item in issues_raw if "pull_request" not in item] + print(f"GitHub: {len(issues)} {state} issues, {len(prs)} {state} PRs", file=sys.stderr) + + cfg = configuration_from_environment(client) + all_labels: set[str] = set() + for item in issues + prs: + all_labels.update( + label["name"] + for label in item.get("labels", []) + if isinstance(label, dict) and label.get("name") + ) + label_ids = sync_labels(cfg["org"], cfg["project_id"], all_labels, client) if all_labels else {} + + summary: dict[str, Any] = {} + for kind, items, bucket in (("issue", issues, "issues"), ("pr", prs, "prs")): + stats = {"source": len(items), "created": 0, "skipped": 0, "updated": 0, "failed": 0} + for item in items: + try: + result = sync_one( + cfg["org"], + cfg, + kind, + item, + repository=repository, + apply=apply, + label_ids=label_ids, + days_to_finish=days_to_finish(), + create_closed=state == "all", + client=client, + ) + if result.startswith("dry-run"): + stats["skipped"] += 1 + elif result == "created": + stats["created"] += 1 + elif result == "updated-status": + stats["updated"] += 1 + else: + stats["skipped"] += 1 + except (YunxiaoApiError, PreflightError, OSError) as error: + stats["failed"] += 1 + print(f"failed {kind} #{item.get('number')}: {error}", file=sys.stderr) + time.sleep(0.12) + summary[bucket] = stats + + print(json.dumps(summary, ensure_ascii=False)) + return 0 if all(stats["failed"] == 0 for stats in summary.values()) else 1 + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="GitHub → 云效同步") + parser.add_argument("--mode", choices=("preflight", "event", "backfill"), 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") + return parser.parse_args() + + +def main() -> int: + args = parse_args() + token = os.environ.get("YUNXIAO_TOKEN", "").strip() + if not token: + print("缺少 YUNXIAO_TOKEN", file=sys.stderr) + return 2 + + client = YunxiaoClient( + UrllibTransport( + token, + base_url=( + os.environ.get("YUNXIAO_API_BASE_URL", "").strip() + or "https://openapi-rdc.aliyuncs.com" + ), + ) + ) + + try: + if args.mode == "event": + return handle_event(client) + if args.mode == "backfill": + return backfill(client, apply=args.apply and not args.dry_run, state=args.state) + + result = configuration_from_environment(client) + print( + json.dumps( + { + "org": result["org"], + "project_id": result["project_id"], + "parent_id": result["parent_id"], + "workitem_category": result["workitem_category"], + "type_id": result["type_id"], + "type_name": result["type_name"], + "assignee_id": result["assignee_id"], + "priority_id": result["priority_id"], + "statuses": result["statuses"], + }, + ensure_ascii=False, + sort_keys=True, + ) + ) + return 0 + except (PreflightError, YunxiaoApiError, OSError) as error: + print(str(error), file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/yunxiao-github-sync-workflow.test.ts b/tests/yunxiao-github-sync-workflow.test.ts new file mode 100644 index 000000000..e6a4bebdd --- /dev/null +++ b/tests/yunxiao-github-sync-workflow.test.ts @@ -0,0 +1,54 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, it } from "vitest"; +import YAML from "yaml"; + +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; + +describe("GitHub to Yunxiao sync workflow", () => { + it("handles issue and trusted pull request lifecycle events", () => { + expect(workflow.on.issues.types).toEqual(["opened", "closed", "reopened"]); + expect(workflow.on.pull_request_target.types).toEqual([ + "opened", + "closed", + "reopened", + ]); + expect(workflow.on.pull_request).toBeUndefined(); + }); + + it("provides preflight, dry-run backfill, and all-state backfill controls", () => { + const inputs = workflow.on.workflow_dispatch.inputs; + expect(inputs.mode.options).toEqual(["preflight", "backfill"]); + 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("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_TOKEN).toContain("secrets.YUNXIAO_TOKEN"); + expect(runStep.run).toContain("python scripts/yunxiao_github_sync.py"); + expect(runStep.env.YUNXIAO_PROJECT_ID).toContain("1832b179386e24414d3891e244"); + expect(runStep.env.YUNXIAO_PROJECT_NAME).toContain("'memmy'"); + }); + + it("uses minimal read permissions and trusted checkout", () => { + expect(workflow.permissions).toEqual({ + contents: "read", + issues: "read", + "pull-requests": "read", + }); + const checkout = workflow.jobs.sync.steps.find( + (step: Record) => step.uses === "actions/checkout@v4", + ); + expect(checkout.with["persist-credentials"]).toBe(false); + expect(checkout.with.ref).toContain("default_branch"); + }); +}); From 4ccc8bc0c8b7446f0d915d3a87bfb81b8fa4c363 Mon Sep 17 00:00:00 2001 From: jiachengzhen Date: Thu, 10 Sep 2026 17:43:51 +0800 Subject: [PATCH 2/3] fix: support memmy Yunxiao workflow status names --- scripts/test_yunxiao_github_sync.py | 16 ++++++++++++++ scripts/yunxiao_github_sync.py | 33 ++++++++++++++++++++++++----- 2 files changed, 44 insertions(+), 5 deletions(-) diff --git a/scripts/test_yunxiao_github_sync.py b/scripts/test_yunxiao_github_sync.py index c85cb8bc5..aa31e8a99 100644 --- a/scripts/test_yunxiao_github_sync.py +++ b/scripts/test_yunxiao_github_sync.py @@ -38,6 +38,22 @@ def test_source_status(self) -> None: "已取消", ) + def test_resolve_statuses_accepts_memmy_workflow_names(self) -> None: + workflow = [ + {"id": "pending", "name": "待处理"}, + {"id": "developing", "name": "开发中"}, + {"id": "developed", "name": "开发完成"}, + {"id": "cancelled", "name": "已取消"}, + ] + self.assertEqual( + MODULE.resolve_statuses(workflow), + { + "待处理": "pending", + "已完成": "developed", + "已取消": "cancelled", + }, + ) + def test_create_payload_contains_parent(self) -> None: calls = [] diff --git a/scripts/yunxiao_github_sync.py b/scripts/yunxiao_github_sync.py index 6e6a108ff..a5a3e8a23 100644 --- a/scripts/yunxiao_github_sync.py +++ b/scripts/yunxiao_github_sync.py @@ -14,7 +14,14 @@ SOURCE_LABELS = {"issue": "Issue", "pr": "PR"} -STATUS_LABELS = ("待处理", "设计中", "开发中", "已完成", "已取消") +# 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. +STATUS_ALIASES = { + "待处理": ("待处理", "未开始"), + "已完成": ("已完成", "开发完成", "完成"), + "已取消": ("已取消", "已关闭", "关闭"), +} DEFAULT_PRIORITY_NAME = "中" DEFAULT_WORKITEM_CATEGORY = "Req" DEFAULT_WORKITEM_TYPE_NAME = "需求" @@ -153,6 +160,25 @@ def find_by_name(items: list[dict[str, Any]], name: str, label: str) -> dict[str raise PreflightError(f"未在云效中找到{label}:{name}") +def resolve_statuses(workflow: list[dict[str, Any]]) -> dict[str, str]: + names = { + name: item + for item in workflow + if (name := item_name(item)) + } + statuses: dict[str, str] = {} + for canonical, aliases in STATUS_ALIASES.items(): + matched_name = next((name for name in aliases if name in names), None) + if matched_name is None: + supported = "、".join(sorted(names)) or "(空)" + raise PreflightError( + f"未在云效中找到工作流状态:{canonical};" + f"支持的别名:{'、'.join(aliases)};当前状态:{supported}" + ) + statuses[canonical] = item_id(names[matched_name]) + return statuses + + class YunxiaoClient: def __init__(self, transport: Any) -> None: self.transport = transport @@ -229,10 +255,7 @@ def preflight( ), ("statuses", "workflowStatuses"), ) - statuses = { - name: item_id(find_by_name(workflow, name, "工作流状态")) - for name in STATUS_LABELS - } + statuses = resolve_statuses(workflow) fields = list_or_extract( client.get( From 9b668728782d05ce4c9e94dd8defff0a828a2fd4 Mon Sep 17 00:00:00 2001 From: jiachengzhen Date: Thu, 10 Sep 2026 17:45:55 +0800 Subject: [PATCH 3/3] docs: document Yunxiao status aliases --- docs/github-yunxiao-sync.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/github-yunxiao-sync.md b/docs/github-yunxiao-sync.md index 2e8e22cf8..22203fe5f 100644 --- a/docs/github-yunxiao-sync.md +++ b/docs/github-yunxiao-sync.md @@ -36,6 +36,9 @@ GitHub issues / pull_request_target `YUNXIAO_DEFAULT_ASSIGNEE_NAME` 可以暂时留空。这样工作项仍会进入 `memmy` 需求空间,由云效用户后续人工分配负责人。 +预检会按语义解析云效工作流状态:合并的 PR 优先使用“已完成”,如果目标项目 +使用“开发完成”则自动使用该名称;取消状态同理支持“已取消”“已关闭”“关闭”。 + 每个源仓库可以使用不同的 `YUNXIAO_PROJECT_ID` 和 `YUNXIAO_PARENT_ID`。例如: | GitHub 仓库 | 云效空间 | 云效目录 |