From 32c0422721d6931ea42f061db661f450d5965a04 Mon Sep 17 00:00:00 2001 From: sunqi Date: Sat, 25 Jul 2026 13:25:01 +0800 Subject: [PATCH 1/5] feat: add Yunxiao sync preflight Made-with: Proma --- .github/workflows/yunxiao-github-sync.yml | 32 +++ scripts/yunxiao_github_sync.py | 313 ++++++++++++++++++++++ tests/test_yunxiao_github_sync.py | 132 +++++++++ 3 files changed, 477 insertions(+) create mode 100644 .github/workflows/yunxiao-github-sync.yml create mode 100644 scripts/yunxiao_github_sync.py create mode 100644 tests/test_yunxiao_github_sync.py diff --git a/.github/workflows/yunxiao-github-sync.yml b/.github/workflows/yunxiao-github-sync.yml new file mode 100644 index 000000000..7fa0b5198 --- /dev/null +++ b/.github/workflows/yunxiao-github-sync.yml @@ -0,0 +1,32 @@ +name: Yunxiao GitHub Sync Preflight + +on: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: yunxiao-github-sync-preflight-${{ github.repository }} + cancel-in-progress: false + +jobs: + preflight: + name: Validate Yunxiao configuration + runs-on: ubuntu-latest + timeout-minutes: 5 + env: + YUNXIAO_PROJECT_ID: 2eac3f84ef1a3482535bd0e255 + YUNXIAO_PROJECT_NAME: MemOS开源项目管理 + YUNXIAO_DEFAULT_ASSIGNEE_NAME: 孙起 + YUNXIAO_DEFAULT_PRIORITY_NAME: 中 + + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + - name: Run read-only preflight + env: + YUNXIAO_TOKEN: ${{ secrets.YUNXIAO_TOKEN }} + run: python scripts/yunxiao_github_sync.py --mode preflight diff --git a/scripts/yunxiao_github_sync.py b/scripts/yunxiao_github_sync.py new file mode 100644 index 000000000..413163ed1 --- /dev/null +++ b/scripts/yunxiao_github_sync.py @@ -0,0 +1,313 @@ +from __future__ import annotations + +import argparse +import json +import os +import sys + +from datetime import UTC, datetime, timedelta +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + + +SOURCE_LABELS = { + "issue": "Issue", + "pr": "PR", +} + + +class PreflightError(ValueError): + pass + + +class YunxiaoApiError(RuntimeError): + pass + + +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) -> Any: + request = Request( + f"{self._base_url}{path}", + method=method, + headers={ + "Accept": "application/json", + "x-yunxiao-token": self._token, + }, + ) + try: + with urlopen(request, timeout=self._timeout_seconds) as response: + payload = response.read().decode() + except HTTPError as error: + request_id = error.headers.get("x-request-id", "unknown") + raise YunxiaoApiError( + f"云效 API 请求失败:HTTP {error.code},request_id={request_id}" + ) from error + except URLError as error: + raise YunxiaoApiError(f"无法连接云效 API:{error.reason}") from error + + try: + return json.loads(payload) + except json.JSONDecodeError as error: + raise YunxiaoApiError("云效 API 返回了非 JSON 响应") from error + + +class YunxiaoClient: + def __init__(self, transport: Any) -> None: + self._transport = transport + + def get(self, path: str) -> Any: + return self._transport("GET", path) + + +def build_source_key(item_type: str, item: dict[str, Any]) -> str: + return f"[GitHub {SOURCE_LABELS[item_type]} #{item['number']}]" + + +def build_title(item_type: str, item: dict[str, Any]) -> str: + return f"{build_source_key(item_type, item)} {item['title']}" + + +def iso_after_days(value: str, days: int) -> str: + created_at = datetime.fromisoformat(value.replace("Z", "+00:00")) + finished_at = created_at + timedelta(days=days) + return finished_at.astimezone(UTC).isoformat().replace("+00:00", "Z") + + +def build_create_payload( + *, + item_type: str, + item: dict[str, Any], + workitem_type_id: str, + assigned_to: str, + priority: str, + status: str, + project_id: str, +) -> dict[str, str]: + return { + "spaceId": project_id, + "workitemTypeId": workitem_type_id, + "subject": build_title(item_type, item), + "description": f"GitHub: {item['html_url']}", + "status": status, + "assignedTo": assigned_to, + "priority": priority, + "planStartTime": item["created_at"], + "planFinishTime": iso_after_days(item["created_at"], 7), + } + + +def build_status_update_payload(status: str) -> dict[str, str]: + return {"status": status} + + +def source_status(item_type: str, item: dict[str, Any]) -> str: + if item["state"] == "open": + return "待处理" + if item_type == "pr" and item.get("merged"): + return "已完成" + return "已取消" + + +def _data(response: Any) -> Any: + return response.get("data", response) if isinstance(response, dict) else response + + +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"云效对象缺少标识符:{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_data(response: Any, label: str, keys: tuple[str, ...] = ()) -> list[dict[str, Any]]: + data = _data(response) + if isinstance(data, dict): + for key in (*keys, "items"): + value = data.get(key) + if isinstance(value, list): + data = value + break + if not isinstance(data, list) or not all(isinstance(item, dict) for item in data): + raise PreflightError(f"云效{label}响应格式不符合预期") + return data + + +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}") + + +def _project_name(project: Any) -> str | None: + return _item_name(project) if isinstance(project, dict) else None + + +def _field_identifier(field: dict[str, Any]) -> str | None: + for key in ("identifier", "fieldIdentifier", "propertyKey"): + value = field.get(key) + if isinstance(value, str): + return value + return None + + +def _field_options(field: dict[str, Any]) -> list[dict[str, Any]]: + for key in ("options", "values", "fieldOptions"): + value = field.get(key) + if isinstance(value, list) and all(isinstance(item, dict) for item in value): + return value + raise PreflightError("优先级字段没有可用选项") + + +def preflight( + client: YunxiaoClient, + *, + project_id: str, + project_name: str, + assignee_name: str, + priority_name: str, +) -> dict[str, Any]: + user = _data(client.get("/oapi/v1/platform/user")) + if not isinstance(user, dict): + raise PreflightError("无法通过 PAT 获取云效用户信息") + + organizations = _list_data(client.get("/oapi/v1/platform/organizations"), "组织列表") + expected_statuses = ("待处理", "已完成", "已取消") + + for organization in organizations: + organization_id = _item_id(organization) + project_path = f"/oapi/v1/projex/organizations/{organization_id}/projects/{project_id}" + project = _data(client.get(project_path)) + if _project_name(project) != project_name: + continue + + types_path = ( + f"/oapi/v1/projex/organizations/{organization_id}/projects/{project_id}" + "/workitemTypes?category=Req" + ) + workitem_types = _list_data(client.get(types_path), "工作项类型", ("workitemTypes",)) + requirement_type = next( + ( + item + for item in workitem_types + if item.get("categoryId", item.get("category")) == "Req" + ), + None, + ) + if requirement_type is None: + raise PreflightError("目标项目没有需求(Req)工作项类型") + workitem_type_id = _item_id(requirement_type) + + members_path = ( + f"/oapi/v1/projex/organizations/{organization_id}/projects/{project_id}/members" + ) + assignee = _find_by_name( + _list_data(client.get(members_path), "项目成员", ("members",)), + assignee_name, + "默认负责人", + ) + + workflow_path = ( + f"/oapi/v1/projex/organizations/{organization_id}/projects/{project_id}" + f"/workitemTypes/{workitem_type_id}/workflows" + ) + workflow_items = _list_data( + client.get(workflow_path), "工作流", ("statuses", "workflowStatuses") + ) + statuses = { + status_name: _item_id(_find_by_name(workflow_items, status_name, "工作流状态")) + for status_name in expected_statuses + } + + fields_path = ( + f"/oapi/v1/projex/organizations/{organization_id}/projects/{project_id}" + f"/workitemTypes/{workitem_type_id}/fields" + ) + fields = _list_data(client.get(fields_path), "工作项字段", ("fields", "fieldConfigs")) + priority_field = next( + (field for field in fields if _field_identifier(field) == "priority"), None + ) + if priority_field is None: + raise PreflightError("需求工作项缺少优先级字段") + priority = _find_by_name(_field_options(priority_field), priority_name, "默认优先级") + + return { + "organization_id": organization_id, + "project_id": project_id, + "workitem_type_id": workitem_type_id, + "assignee_id": _item_id(assignee), + "priority_id": _item_id(priority), + "statuses": statuses, + } + + raise PreflightError(f"未找到项目:{project_name}({project_id})") + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="GitHub Issue/PR 云效同步工具") + parser.add_argument("--mode", choices=("preflight",), default="preflight") + parser.add_argument( + "--project-id", + default=os.environ.get("YUNXIAO_PROJECT_ID", "2eac3f84ef1a3482535bd0e255"), + ) + parser.add_argument( + "--project-name", + default=os.environ.get("YUNXIAO_PROJECT_NAME", "MemOS开源项目管理"), + ) + parser.add_argument( + "--assignee-name", + default=os.environ.get("YUNXIAO_DEFAULT_ASSIGNEE_NAME", "孙起"), + ) + parser.add_argument( + "--priority-name", + default=os.environ.get("YUNXIAO_DEFAULT_PRIORITY_NAME", "中"), + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + token = os.environ.get("YUNXIAO_TOKEN") + if not token: + print("缺少 YUNXIAO_TOKEN 环境变量", file=sys.stderr) + return 2 + + try: + result = preflight( + YunxiaoClient(UrllibTransport(token)), + project_id=args.project_id, + project_name=args.project_name, + assignee_name=args.assignee_name, + priority_name=args.priority_name, + ) + except (PreflightError, YunxiaoApiError) as error: + print(str(error), file=sys.stderr) + return 1 + + print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_yunxiao_github_sync.py b/tests/test_yunxiao_github_sync.py new file mode 100644 index 000000000..771e27827 --- /dev/null +++ b/tests/test_yunxiao_github_sync.py @@ -0,0 +1,132 @@ +from __future__ import annotations + +import importlib.util + +from pathlib import Path + + +MODULE_PATH = Path(__file__).parents[1] / "scripts" / "yunxiao_github_sync.py" +SPEC = importlib.util.spec_from_file_location("yunxiao_github_sync", MODULE_PATH) +assert SPEC is not None +assert SPEC.loader is not None +MODULE = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(MODULE) + + +def test_build_title_and_key_for_issue() -> None: + item = { + "number": 42, + "title": "Fix scheduler", + "created_at": "2026-07-25T01:02:03Z", + } + + assert MODULE.build_source_key("issue", item) == "[GitHub Issue #42]" + assert MODULE.build_title("issue", item) == "[GitHub Issue #42] Fix scheduler" + + +def test_build_create_payload_sets_sla_and_only_allowed_fields() -> None: + item = { + "number": 42, + "title": "Fix scheduler", + "html_url": "https://github.com/MemTensor/MemOS/issues/42", + "created_at": "2026-07-25T01:02:03Z", + } + + payload = MODULE.build_create_payload( + item_type="issue", + item=item, + workitem_type_id="req-id", + assigned_to="sunqi-id", + priority="medium-id", + status="pending-id", + project_id="project-id", + ) + + assert payload["assignedTo"] == "sunqi-id" + assert payload["priority"] == "medium-id" + assert payload["planStartTime"] == "2026-07-25T01:02:03Z" + assert payload["planFinishTime"] == "2026-08-01T01:02:03Z" + assert payload["subject"] == "[GitHub Issue #42] Fix scheduler" + assert payload["description"] == "GitHub: https://github.com/MemTensor/MemOS/issues/42" + assert set(payload).isdisjoint({"creator", "comments", "labels"}) + + +def test_build_status_update_payload_only_contains_status() -> None: + assert MODULE.build_status_update_payload("done-id") == {"status": "done-id"} + + +def test_map_pr_closed_status_distinguishes_merged() -> None: + assert MODULE.source_status("pr", {"state": "closed", "merged": True}) == "已完成" + assert MODULE.source_status("pr", {"state": "closed", "merged": False}) == "已取消" + + +def test_preflight_handles_documented_raw_response_shapes() -> None: + calls: list[tuple[str, str]] = [] + responses = { + "GET /oapi/v1/platform/user": {"userId": "sync-user", "userName": "孙起"}, + "GET /oapi/v1/platform/organizations": [ + {"organizationId": "org-id", "name": "MemTensor"}, + ], + "GET /oapi/v1/projex/organizations/org-id/projects/project-id": { + "id": "project-id", + "name": "MemOS开源项目管理", + }, + "GET /oapi/v1/projex/organizations/org-id/projects/project-id/workitemTypes?category=Req": [ + {"id": "req-id", "categoryId": "Req", "name": "需求"}, + ], + "GET /oapi/v1/projex/organizations/org-id/projects/project-id/members": [ + {"userId": "sunqi-id", "userName": "孙起"}, + ], + "GET /oapi/v1/projex/organizations/org-id/projects/project-id/workitemTypes/req-id/workflows": { + "statuses": [ + {"statusId": "pending-id", "displayValue": "待处理"}, + {"statusId": "done-id", "displayValue": "已完成"}, + {"statusId": "cancelled-id", "displayValue": "已取消"}, + ], + }, + "GET /oapi/v1/projex/organizations/org-id/projects/project-id/workitemTypes/req-id/fields": [ + { + "fieldIdentifier": "priority", + "values": [{"value": "medium-id", "displayValue": "中"}], + }, + ], + } + + def transport(method: str, path: str) -> object: + calls.append((method, path)) + return responses[f"{method} {path}"] + + result = MODULE.preflight( + MODULE.YunxiaoClient(transport), + project_id="project-id", + project_name="MemOS开源项目管理", + assignee_name="孙起", + priority_name="中", + ) + + assert result == { + "organization_id": "org-id", + "project_id": "project-id", + "workitem_type_id": "req-id", + "assignee_id": "sunqi-id", + "priority_id": "medium-id", + "statuses": { + "待处理": "pending-id", + "已完成": "done-id", + "已取消": "cancelled-id", + }, + } + assert {method for method, _ in calls} == {"GET"} + + +def test_preflight_workflow_only_runs_read_only_preflight() -> None: + workflow = Path(__file__).parents[1] / ".github" / "workflows" / "yunxiao-github-sync.yml" + content = workflow.read_text() + + assert "workflow_dispatch:" in content + assert "YUNXIAO_TOKEN: ${{ secrets.YUNXIAO_TOKEN }}" in content + assert "--mode preflight" in content + assert "YUNXIAO_PROJECT_ID: 2eac3f84ef1a3482535bd0e255" in content + assert "issues:" not in content + assert "pull_request" not in content + assert "--apply" not in content From ded2df64f18ef6c348de5b41943e1bb2a65226c9 Mon Sep 17 00:00:00 2001 From: sunqi Date: Sat, 25 Jul 2026 13:30:25 +0800 Subject: [PATCH 2/5] fix: support Yunxiao priority field schema Made-with: Proma --- scripts/yunxiao_github_sync.py | 2 +- tests/test_yunxiao_github_sync.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/yunxiao_github_sync.py b/scripts/yunxiao_github_sync.py index 413163ed1..d4ff993a5 100644 --- a/scripts/yunxiao_github_sync.py +++ b/scripts/yunxiao_github_sync.py @@ -165,7 +165,7 @@ def _project_name(project: Any) -> str | None: def _field_identifier(field: dict[str, Any]) -> str | None: - for key in ("identifier", "fieldIdentifier", "propertyKey"): + for key in ("id", "identifier", "fieldIdentifier", "propertyKey"): value = field.get(key) if isinstance(value, str): return value diff --git a/tests/test_yunxiao_github_sync.py b/tests/test_yunxiao_github_sync.py index 771e27827..19b5dd977 100644 --- a/tests/test_yunxiao_github_sync.py +++ b/tests/test_yunxiao_github_sync.py @@ -86,8 +86,8 @@ def test_preflight_handles_documented_raw_response_shapes() -> None: }, "GET /oapi/v1/projex/organizations/org-id/projects/project-id/workitemTypes/req-id/fields": [ { - "fieldIdentifier": "priority", - "values": [{"value": "medium-id", "displayValue": "中"}], + "id": "priority", + "options": [{"id": "medium-id", "displayValue": "中"}], }, ], } From fe8f366d498277ebac3d6715c73c9680f75e1e30 Mon Sep 17 00:00:00 2001 From: sunqi Date: Sat, 25 Jul 2026 13:42:26 +0800 Subject: [PATCH 3/5] fix: support Python 3.10 and Windows tests Made-with: Proma --- scripts/yunxiao_github_sync.py | 4 ++-- tests/test_yunxiao_github_sync.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/yunxiao_github_sync.py b/scripts/yunxiao_github_sync.py index d4ff993a5..dcdc49efa 100644 --- a/scripts/yunxiao_github_sync.py +++ b/scripts/yunxiao_github_sync.py @@ -5,7 +5,7 @@ import os import sys -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone from typing import Any from urllib.error import HTTPError, URLError from urllib.request import Request, urlopen @@ -82,7 +82,7 @@ def build_title(item_type: str, item: dict[str, Any]) -> str: def iso_after_days(value: str, days: int) -> str: created_at = datetime.fromisoformat(value.replace("Z", "+00:00")) finished_at = created_at + timedelta(days=days) - return finished_at.astimezone(UTC).isoformat().replace("+00:00", "Z") + return finished_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") def build_create_payload( diff --git a/tests/test_yunxiao_github_sync.py b/tests/test_yunxiao_github_sync.py index 19b5dd977..81d84062f 100644 --- a/tests/test_yunxiao_github_sync.py +++ b/tests/test_yunxiao_github_sync.py @@ -121,7 +121,7 @@ def transport(method: str, path: str) -> object: def test_preflight_workflow_only_runs_read_only_preflight() -> None: workflow = Path(__file__).parents[1] / ".github" / "workflows" / "yunxiao-github-sync.yml" - content = workflow.read_text() + content = workflow.read_text(encoding="utf-8") assert "workflow_dispatch:" in content assert "YUNXIAO_TOKEN: ${{ secrets.YUNXIAO_TOKEN }}" in content From 281b46ba16da3f64d637ec2d935e92a2e2cbae40 Mon Sep 17 00:00:00 2001 From: sunqi Date: Sat, 25 Jul 2026 14:55:05 +0800 Subject: [PATCH 4/5] feat: add event sync, backfill modes and label support Made-with: Proma --- .github/workflows/yunxiao-github-sync.yml | 60 ++- scripts/yunxiao_github_sync.py | 570 +++++++++++++++------- tests/test_yunxiao_github_sync.py | 179 ++++--- 3 files changed, 548 insertions(+), 261 deletions(-) diff --git a/.github/workflows/yunxiao-github-sync.yml b/.github/workflows/yunxiao-github-sync.yml index 7fa0b5198..3caa258f0 100644 --- a/.github/workflows/yunxiao-github-sync.yml +++ b/.github/workflows/yunxiao-github-sync.yml @@ -1,32 +1,68 @@ -name: Yunxiao GitHub Sync Preflight +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 + apply: + description: "回填时是否实际写入(选 false 则仅 dry-run)" + required: false + type: boolean + default: false permissions: contents: read + issues: read + pull-requests: read concurrency: - group: yunxiao-github-sync-preflight-${{ github.repository }} + group: yunxiao-github-sync-${{ github.repository }} cancel-in-progress: false +env: + YUNXIAO_PROJECT_ID: 2eac3f84ef1a3482535bd0e255 + YUNXIAO_PROJECT_NAME: MemOS开源项目管理 + YUNXIAO_DEFAULT_ASSIGNEE_NAME: 孙起 + YUNXIAO_DEFAULT_PRIORITY_NAME: 中 + jobs: - preflight: - name: Validate Yunxiao configuration + sync: runs-on: ubuntu-latest - timeout-minutes: 5 - env: - YUNXIAO_PROJECT_ID: 2eac3f84ef1a3482535bd0e255 - YUNXIAO_PROJECT_NAME: MemOS开源项目管理 - YUNXIAO_DEFAULT_ASSIGNEE_NAME: 孙起 - YUNXIAO_DEFAULT_PRIORITY_NAME: 中 + timeout-minutes: 30 steps: - uses: actions/checkout@v4 + with: + ref: ${{ github.event.repository.default_branch }} + - uses: actions/setup-python@v5 with: python-version: "3.12" - - name: Run read-only preflight + + - name: Run sync env: YUNXIAO_TOKEN: ${{ secrets.YUNXIAO_TOKEN }} - run: python scripts/yunxiao_github_sync.py --mode preflight + GH_TOKEN: ${{ github.token }} + run: | + if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then + ARGS="--mode ${{ inputs.mode }}" + if [ "${{ inputs.mode }}" = "backfill" ] && [ "${{ inputs.apply }}" != "true" ]; then + ARGS="$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/scripts/yunxiao_github_sync.py b/scripts/yunxiao_github_sync.py index dcdc49efa..74d80eef7 100644 --- a/scripts/yunxiao_github_sync.py +++ b/scripts/yunxiao_github_sync.py @@ -4,6 +4,7 @@ import json import os import sys +import time from datetime import datetime, timedelta, timezone from typing import Any @@ -11,10 +12,10 @@ from urllib.request import Request, urlopen -SOURCE_LABELS = { - "issue": "Issue", - "pr": "PR", -} +SOURCE_LABELS = {"issue": "Issue", "pr": "PR"} +STATUS_LABELS = ("待处理", "已完成", "已取消") + +# ---------- errors ---------- class PreflightError(ValueError): @@ -25,6 +26,9 @@ class YunxiaoApiError(RuntimeError): pass +# ---------- transport ---------- + + class UrllibTransport: def __init__( self, @@ -37,38 +41,33 @@ def __init__( self._base_url = base_url.rstrip("/") self._timeout_seconds = timeout_seconds - def __call__(self, method: str, path: str) -> Any: + 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: - request_id = error.headers.get("x-request-id", "unknown") raise YunxiaoApiError( - f"云效 API 请求失败:HTTP {error.code},request_id={request_id}" + 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 - try: - return json.loads(payload) - except json.JSONDecodeError as error: - raise YunxiaoApiError("云效 API 返回了非 JSON 响应") from error - - -class YunxiaoClient: - def __init__(self, transport: Any) -> None: - self._transport = transport - def get(self, path: str) -> Any: - return self._transport("GET", path) +# ---------- pure helpers ---------- def build_source_key(item_type: str, item: dict[str, Any]) -> str: @@ -81,76 +80,50 @@ def build_title(item_type: str, item: dict[str, Any]) -> str: def iso_after_days(value: str, days: int) -> str: created_at = datetime.fromisoformat(value.replace("Z", "+00:00")) - finished_at = created_at + timedelta(days=days) - return finished_at.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") - - -def build_create_payload( - *, - item_type: str, - item: dict[str, Any], - workitem_type_id: str, - assigned_to: str, - priority: str, - status: str, - project_id: str, -) -> dict[str, str]: - return { - "spaceId": project_id, - "workitemTypeId": workitem_type_id, - "subject": build_title(item_type, item), - "description": f"GitHub: {item['html_url']}", - "status": status, - "assignedTo": assigned_to, - "priority": priority, - "planStartTime": item["created_at"], - "planFinishTime": iso_after_days(item["created_at"], 7), - } - - -def build_status_update_payload(status: str) -> dict[str, str]: - return {"status": status} + 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["state"] == "open": + if item.get("state") == "open": return "待处理" if item_type == "pr" and item.get("merged"): return "已完成" return "已取消" -def _data(response: Any) -> Any: - return response.get("data", response) if isinstance(response, dict) else response +# ---------- preflight helpers ---------- 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"云效对象缺少标识符:{item}") + v = item.get(key) + if v: + return str(v) + 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 + v = item.get(key) + if isinstance(v, str): + return v return None -def _list_data(response: Any, label: str, keys: tuple[str, ...] = ()) -> list[dict[str, Any]]: - data = _data(response) - if isinstance(data, dict): - for key in (*keys, "items"): - value = data.get(key) - if isinstance(value, list): - data = value - break - if not isinstance(data, list) or not all(isinstance(item, dict) for item in data): - raise PreflightError(f"云效{label}响应格式不符合预期") - return data +def _list_or_extract(payload: Any, keys: tuple[str, ...]) -> list[dict[str, Any]]: + if isinstance(payload, dict): + for k in (*keys, "items"): + v = payload.get(k) + if isinstance(v, list) and all(isinstance(x, dict) for x in v): + return v + 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]: @@ -160,152 +133,387 @@ def _find_by_name(items: list[dict[str, Any]], name: str, label: str) -> dict[st raise PreflightError(f"未在云效中找到{label}:{name}") -def _project_name(project: Any) -> str | None: - return _item_name(project) if isinstance(project, dict) else None - - -def _field_identifier(field: dict[str, Any]) -> str | None: - for key in ("id", "identifier", "fieldIdentifier", "propertyKey"): - value = field.get(key) - if isinstance(value, str): - return value - return None - - -def _field_options(field: dict[str, Any]) -> list[dict[str, Any]]: - for key in ("options", "values", "fieldOptions"): - value = field.get(key) - if isinstance(value, list) and all(isinstance(item, dict) for item in value): - return value - raise PreflightError("优先级字段没有可用选项") +# ---------- preflight ---------- def preflight( - client: YunxiaoClient, - *, + org: str, project_id: str, project_name: str, assignee_name: str, priority_name: str, + client: YunxiaoClient, ) -> dict[str, Any]: - user = _data(client.get("/oapi/v1/platform/user")) - if not isinstance(user, dict): - raise PreflightError("无法通过 PAT 获取云效用户信息") - - organizations = _list_data(client.get("/oapi/v1/platform/organizations"), "组织列表") - expected_statuses = ("待处理", "已完成", "已取消") - - for organization in organizations: - organization_id = _item_id(organization) - project_path = f"/oapi/v1/projex/organizations/{organization_id}/projects/{project_id}" - project = _data(client.get(project_path)) - if _project_name(project) != project_name: - continue - - types_path = ( - f"/oapi/v1/projex/organizations/{organization_id}/projects/{project_id}" - "/workitemTypes?category=Req" - ) - workitem_types = _list_data(client.get(types_path), "工作项类型", ("workitemTypes",)) - requirement_type = next( - ( - item - for item in workitem_types - if item.get("categoryId", item.get("category")) == "Req" - ), - None, - ) - if requirement_type is None: - raise PreflightError("目标项目没有需求(Req)工作项类型") - workitem_type_id = _item_id(requirement_type) + organizations = _list_or_extract(client.get("/oapi/v1/platform/organizations"), ()) + if not organizations: + raise PreflightError("PAT 无法访问任何云效组织") + + for org_data in organizations: + org_id = _item_id(org_data) + project = client.get(f"/oapi/v1/projex/organizations/{org_id}/projects/{project_id}") + if isinstance(project, dict) and project.get("name") == project_name: + org = org_id + break + else: + raise PreflightError(f"未找到项目:{project_name}({project_id})") + + types = _list_or_extract( + client.get( + f"/oapi/v1/projex/organizations/{org}/projects/{project_id}/workitemTypes?category=Req" + ), + ("workitemTypes",), + ) + req_type = next((t for t in types if t.get("categoryId", t.get("category")) == "Req"), None) + if not req_type: + raise PreflightError("目标项目没有需求(Req)工作项类型") + type_id = _item_id(req_type) + + members = _list_or_extract( + client.get(f"/oapi/v1/projex/organizations/{org}/projects/{project_id}/members"), + ("members",), + ) + assignee = _find_by_name(members, assignee_name, "默认负责人") - members_path = ( - f"/oapi/v1/projex/organizations/{organization_id}/projects/{project_id}/members" - ) - assignee = _find_by_name( - _list_data(client.get(members_path), "项目成员", ("members",)), - assignee_name, - "默认负责人", - ) + workflow = _list_or_extract( + client.get( + f"/oapi/v1/projex/organizations/{org}/projects/{project_id}/workitemTypes/{type_id}/workflows" + ), + ("statuses", "workflowStatuses"), + ) + statuses = {n: _item_id(_find_by_name(workflow, n, "工作流状态")) for n in STATUS_LABELS} - workflow_path = ( - f"/oapi/v1/projex/organizations/{organization_id}/projects/{project_id}" - f"/workitemTypes/{workitem_type_id}/workflows" - ) - workflow_items = _list_data( - client.get(workflow_path), "工作流", ("statuses", "workflowStatuses") - ) - statuses = { - status_name: _item_id(_find_by_name(workflow_items, status_name, "工作流状态")) - for status_name in expected_statuses - } + fields = _list_or_extract( + client.get( + f"/oapi/v1/projex/organizations/{org}/projects/{project_id}/workitemTypes/{type_id}/fields" + ), + ("fields", "fieldConfigs"), + ) + pf = next( + ( + f + for f in fields + if f.get("id") == "priority" + or f.get("identifier") == "priority" + or f.get("fieldIdentifier") == "priority" + ), + None, + ) + if not pf: + raise PreflightError("需求工作项缺少优先级字段") + opts = _list_or_extract(pf.get("options") or pf.get("values") or {}, ()) + if not opts: + opts = pf.get("options") or pf.get("values") or [] + priority = _find_by_name(opts, priority_name, "默认优先级") + + return { + "org": org, + "project_id": project_id, + "type_id": type_id, + "assignee_id": _item_id(assignee), + "priority_id": _item_id(priority), + "statuses": statuses, + } - fields_path = ( - f"/oapi/v1/projex/organizations/{organization_id}/projects/{project_id}" - f"/workitemTypes/{workitem_type_id}/fields" + +# ---------- labels ---------- + + +def sync_labels( + org: str, project_id: str, wanted: set[str], client: YunxiaoClient +) -> dict[str, str]: + """Ensure every name in *wanted* has a label; return {name: id}.""" + 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 lbl in _list_or_extract(existing_raw, ()): + n = _item_name(lbl) or lbl.get("name", "") + if n: + existing_names.add(n) + name_to_id[n] = _item_id(lbl) + colors = [ + "#e91e63", + "#9c27b0", + "#673ab7", + "#3f51b5", + "#2196f3", + "#00bcd4", + "#009688", + "#4caf50", + "#8bc34a", + "#cddc39", + "#ffc107", + "#ff9800", + "#ff5722", + ] + for i, name in enumerate(sorted(wanted - existing_names)): + resp = client._transport( + "POST", + f"/oapi/v1/projex/organizations/{org}/projects/{project_id}/labels", + {"name": name, "color": colors[i % len(colors)]}, ) - fields = _list_data(client.get(fields_path), "工作项字段", ("fields", "fieldConfigs")) - priority_field = next( - (field for field in fields if _field_identifier(field) == "priority"), None + name_to_id[name] = _item_id(resp) + time.sleep(0.1) + return name_to_id + + +# ---------- search ---------- + + +def find_workitem(org: str, project_id: str, prefix: str, client: YunxiaoClient) -> str | None: + conditions = json.dumps( + { + "conditionGroups": [ + [ + { + "fieldIdentifier": "subject", + "operator": "CONTAINS", + "value": [prefix], + "className": "string", + "format": "input", + } + ] + ] + }, + ensure_ascii=False, + ) + resp = client._transport( + "POST", + f"/oapi/v1/projex/organizations/{org}/workitems:search", + {"spaceId": project_id, "category": "Req", "conditions": conditions}, + ) + data = ( + resp + if isinstance(resp, list) + else (resp or {}).get("data", resp) + if isinstance(resp, dict) + else resp + ) + 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 + + +# ---------- sync one ---------- + + +def sync_one( + org: str, + cfg: dict[str, Any], + item_type: str, + item: dict[str, Any], + *, + apply: bool, + label_ids: dict[str, str] | None, + client: YunxiaoClient, +) -> str: + source_key = build_source_key(item_type, item) + existing_id = find_workitem(org, cfg["project_id"], source_key, client) + + if existing_id: + if item.get("state") == "open": + return "skipped-open" + if not apply: + return "dry-run-status" + status_name = source_status(item_type, item) + status_id = cfg["statuses"][status_name] + client._transport( + "PUT", + f"/oapi/v1/projex/organizations/{org}/workitems/{existing_id}", + {"status": status_id}, ) - if priority_field is None: - raise PreflightError("需求工作项缺少优先级字段") - priority = _find_by_name(_field_options(priority_field), priority_name, "默认优先级") - - return { - "organization_id": organization_id, - "project_id": project_id, - "workitem_type_id": workitem_type_id, - "assignee_id": _item_id(assignee), - "priority_id": _item_id(priority), - "statuses": statuses, + return "updated-status" + else: + if item.get("state") != "open": + return "skipped-closed" + if not apply: + return "dry-run-create" + status_id = cfg["statuses"]["待处理"] + payload: dict[str, Any] = { + "spaceId": cfg["project_id"], + "workitemTypeId": cfg["type_id"], + "subject": build_title(item_type, item), + "description": f"GitHub: {item['html_url']}", + "status": status_id, + "assignedTo": cfg["assignee_id"], + "priority": cfg["priority_id"], + "planStartTime": item["created_at"], + "planFinishTime": iso_after_days(item["created_at"], 7), } + # labels + github_labels = [l["name"] for l in item.get("labels", [])] + lids = [label_ids[n] for n in github_labels if n in label_ids] if label_ids else [] + if lids: + payload["labels"] = lids + client._transport("POST", f"/oapi/v1/projex/organizations/{org}/workitems", payload) + return "created" - raise PreflightError(f"未找到项目:{project_name}({project_id})") +# ---------- event ---------- -def parse_args() -> argparse.Namespace: - parser = argparse.ArgumentParser(description="GitHub Issue/PR 云效同步工具") - parser.add_argument("--mode", choices=("preflight",), default="preflight") - parser.add_argument( - "--project-id", - default=os.environ.get("YUNXIAO_PROJECT_ID", "2eac3f84ef1a3482535bd0e255"), - ) - parser.add_argument( - "--project-name", - default=os.environ.get("YUNXIAO_PROJECT_NAME", "MemOS开源项目管理"), - ) - parser.add_argument( - "--assignee-name", - default=os.environ.get("YUNXIAO_DEFAULT_ASSIGNEE_NAME", "孙起"), + +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) as f: + event = json.load(f) + 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 + # Determine type + if event.get("issue") and "pull_request" in event["issue"]: + # Some issue events carry pr metadata; skip these (they come via pull_request_target separately) + 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) + + cfg = preflight( + "", + os.environ.get("YUNXIAO_PROJECT_ID", "2eac3f84ef1a3482535bd0e255"), + os.environ.get("YUNXIAO_PROJECT_NAME", "MemOS开源项目管理"), + os.environ.get("YUNXIAO_DEFAULT_ASSIGNEE_NAME", "孙起"), + os.environ.get("YUNXIAO_DEFAULT_PRIORITY_NAME", "中"), + client, ) - parser.add_argument( - "--priority-name", - default=os.environ.get("YUNXIAO_DEFAULT_PRIORITY_NAME", "中"), + org = cfg["org"] + all_labels = {l["name"] for l in item.get("labels", [])} + label_ids = sync_labels(org, cfg["project_id"], all_labels, client) if all_labels else {} + result = sync_one(org, cfg, item_type, item, apply=True, label_ids=label_ids, client=client) + print(f"{item_type} #{item['number']}: {result}") + return 0 + + +# ---------- backfill ---------- + + +def backfill(client: YunxiaoClient, *, apply: bool) -> int: + import urllib.request as ur + + github_token = os.environ.get("GH_TOKEN", "") + repo = os.environ.get("GITHUB_REPOSITORY", "MemTensor/MemOS") + hdrs = {"Accept": "application/vnd.github+json", "Authorization": f"Bearer {github_token}"} + + def gh(path: str) -> Any: + req = ur.Request(f"https://api.github.com/repos/{repo}/{path}", headers=hdrs) + with ur.urlopen(req, timeout=30) as resp: + return json.loads(resp.read().decode()) + + issues_raw = gh("issues?state=open&per_page=100&sort=created&direction=desc") + prs_raw = gh("pulls?state=open&per_page=100&sort=created&direction=desc") + + issues = [x for x in issues_raw if "pull_request" not in x] + prs = list(prs_raw) + print(f"GitHub: {len(issues)} open issues, {len(prs)} open PRs", file=sys.stderr) + + cfg = preflight( + "", + os.environ.get("YUNXIAO_PROJECT_ID", "2eac3f84ef1a3482535bd0e255"), + os.environ.get("YUNXIAO_PROJECT_NAME", "MemOS开源项目管理"), + os.environ.get("YUNXIAO_DEFAULT_ASSIGNEE_NAME", "孙起"), + os.environ.get("YUNXIAO_DEFAULT_PRIORITY_NAME", "中"), + client, ) - return parser.parse_args() + org = cfg["org"] + + all_labels: set[str] = set() + for item in issues + prs: + all_labels.update(l["name"] for l in item.get("labels", [])) + label_ids = sync_labels(org, cfg["project_id"], all_labels, client) if all_labels else {} + + summary: dict[str, Any] = {} + for kind, items_list, bucket in (("issue", issues, "issues"), ("pr", prs, "prs")): + stats = {"source": len(items_list), "created": 0, "skipped": 0, "updated": 0, "failed": 0} + for item in items_list: + try: + r = sync_one(org, cfg, kind, item, apply=apply, label_ids=label_ids, client=client) + if r.startswith("dry-run"): + stats["skipped"] += 1 + elif r == "created": + stats["created"] += 1 + elif r == "updated-status": + stats["updated"] += 1 + else: + stats["skipped"] += 1 + except (YunxiaoApiError, PreflightError, OSError) as e: + stats["failed"] += 1 + print(f"failed {kind} #{item['number']}: {e}", file=sys.stderr) + time.sleep(0.12) + summary[bucket] = stats + + print(json.dumps(summary, ensure_ascii=False)) + return 0 if all(s["failed"] == 0 for s in summary.values()) else 1 + + +# ---------- CLI ---------- + + +class YunxiaoClient: + def __init__(self, transport: Any) -> None: + self._transport = transport + + def get(self, path: str) -> Any: + return self._transport("GET", path) + + +def parse_args() -> argparse.Namespace: + p = argparse.ArgumentParser(description="GitHub → 云效 同步") + p.add_argument("--mode", choices=("preflight", "event", "backfill"), default="preflight") + p.add_argument("--apply", action="store_true") + p.add_argument("--dry-run", action="store_true") + return p.parse_args() def main() -> int: args = parse_args() token = os.environ.get("YUNXIAO_TOKEN") if not token: - print("缺少 YUNXIAO_TOKEN 环境变量", file=sys.stderr) + print("缺少 YUNXIAO_TOKEN", file=sys.stderr) return 2 + transport = UrllibTransport(token) + client = YunxiaoClient(transport) + + if args.mode == "event": + return handle_event(client) + if args.mode == "backfill": + return backfill(client, apply=args.apply and not args.dry_run) try: result = preflight( - YunxiaoClient(UrllibTransport(token)), - project_id=args.project_id, - project_name=args.project_name, - assignee_name=args.assignee_name, - priority_name=args.priority_name, + "", + os.environ.get("YUNXIAO_PROJECT_ID", "2eac3f84ef1a3482535bd0e255"), + os.environ.get("YUNXIAO_PROJECT_NAME", "MemOS开源项目管理"), + os.environ.get("YUNXIAO_DEFAULT_ASSIGNEE_NAME", "孙起"), + os.environ.get("YUNXIAO_DEFAULT_PRIORITY_NAME", "中"), + client, ) except (PreflightError, YunxiaoApiError) as error: print(str(error), file=sys.stderr) return 1 - - print(json.dumps(result, ensure_ascii=False, sort_keys=True)) + print( + json.dumps( + { + "org": result["org"], + "project_id": result["project_id"], + "type_id": result["type_id"], + "assignee_id": result["assignee_id"], + "priority_id": result["priority_id"], + "statuses": result["statuses"], + }, + ensure_ascii=False, + sort_keys=True, + ) + ) return 0 diff --git a/tests/test_yunxiao_github_sync.py b/tests/test_yunxiao_github_sync.py index 81d84062f..cbf57aa1a 100644 --- a/tests/test_yunxiao_github_sync.py +++ b/tests/test_yunxiao_github_sync.py @@ -3,67 +3,36 @@ import importlib.util from pathlib import Path +from typing import Any MODULE_PATH = Path(__file__).parents[1] / "scripts" / "yunxiao_github_sync.py" SPEC = importlib.util.spec_from_file_location("yunxiao_github_sync", MODULE_PATH) -assert SPEC is not None -assert SPEC.loader is not None +assert SPEC is not None and SPEC.loader is not None MODULE = importlib.util.module_from_spec(SPEC) SPEC.loader.exec_module(MODULE) -def test_build_title_and_key_for_issue() -> None: - item = { - "number": 42, - "title": "Fix scheduler", - "created_at": "2026-07-25T01:02:03Z", - } - +def test_build_title_and_key() -> None: + item = {"number": 42, "title": "Fix scheduler", "created_at": "2026-07-25T01:02:03Z"} assert MODULE.build_source_key("issue", item) == "[GitHub Issue #42]" assert MODULE.build_title("issue", item) == "[GitHub Issue #42] Fix scheduler" -def test_build_create_payload_sets_sla_and_only_allowed_fields() -> None: - item = { - "number": 42, - "title": "Fix scheduler", - "html_url": "https://github.com/MemTensor/MemOS/issues/42", - "created_at": "2026-07-25T01:02:03Z", - } - - payload = MODULE.build_create_payload( - item_type="issue", - item=item, - workitem_type_id="req-id", - assigned_to="sunqi-id", - priority="medium-id", - status="pending-id", - project_id="project-id", - ) - - assert payload["assignedTo"] == "sunqi-id" - assert payload["priority"] == "medium-id" - assert payload["planStartTime"] == "2026-07-25T01:02:03Z" - assert payload["planFinishTime"] == "2026-08-01T01:02:03Z" - assert payload["subject"] == "[GitHub Issue #42] Fix scheduler" - assert payload["description"] == "GitHub: https://github.com/MemTensor/MemOS/issues/42" - assert set(payload).isdisjoint({"creator", "comments", "labels"}) - - -def test_build_status_update_payload_only_contains_status() -> None: - assert MODULE.build_status_update_payload("done-id") == {"status": "done-id"} +def test_iso_after_days() -> None: + assert MODULE.iso_after_days("2026-07-25T01:02:03Z", 7) == "2026-08-01T01:02:03Z" -def test_map_pr_closed_status_distinguishes_merged() -> None: +def test_source_status() -> None: + assert MODULE.source_status("issue", {"state": "open"}) == "待处理" assert MODULE.source_status("pr", {"state": "closed", "merged": True}) == "已完成" assert MODULE.source_status("pr", {"state": "closed", "merged": False}) == "已取消" + assert MODULE.source_status("issue", {"state": "closed"}) == "已取消" -def test_preflight_handles_documented_raw_response_shapes() -> None: +def test_preflight_documented_schema() -> None: calls: list[tuple[str, str]] = [] responses = { - "GET /oapi/v1/platform/user": {"userId": "sync-user", "userName": "孙起"}, "GET /oapi/v1/platform/organizations": [ {"organizationId": "org-id", "name": "MemTensor"}, ], @@ -85,48 +54,122 @@ def test_preflight_handles_documented_raw_response_shapes() -> None: ], }, "GET /oapi/v1/projex/organizations/org-id/projects/project-id/workitemTypes/req-id/fields": [ - { - "id": "priority", - "options": [{"id": "medium-id", "displayValue": "中"}], - }, + {"id": "priority", "options": [{"id": "medium-id", "displayValue": "中"}]}, ], } - def transport(method: str, path: str) -> object: + def transport(method: str, path: str, body: object = None) -> object: calls.append((method, path)) return responses[f"{method} {path}"] result = MODULE.preflight( + "", + "project-id", + "MemOS开源项目管理", + "孙起", + "中", MODULE.YunxiaoClient(transport), - project_id="project-id", - project_name="MemOS开源项目管理", - assignee_name="孙起", - priority_name="中", ) - assert result == { - "organization_id": "org-id", + "org": "org-id", "project_id": "project-id", - "workitem_type_id": "req-id", + "type_id": "req-id", "assignee_id": "sunqi-id", "priority_id": "medium-id", - "statuses": { - "待处理": "pending-id", - "已完成": "done-id", - "已取消": "cancelled-id", - }, + "statuses": {"待处理": "pending-id", "已完成": "done-id", "已取消": "cancelled-id"}, } - assert {method for method, _ in calls} == {"GET"} - + assert {m for m, _ in calls} == {"GET"} + + +def test_sync_one_create() -> None: + calls: list[tuple[str, str, dict[str, Any] | None]] = [] + + def transport(method: str, path: str, body: dict[str, Any] | None = None) -> Any: + calls.append((method, path, body)) + if "search" in path: + return {"data": {"workitems": []}} + if method == "POST": + return {"id": "new-id"} + raise AssertionError(f"unexpected {method} {path}") + + cfg = { + "org": "org", + "project_id": "p", + "type_id": "t", + "assignee_id": "a", + "priority_id": "pr", + "statuses": {"待处理": "s1", "已完成": "s2", "已取消": "s3"}, + } + item = { + "number": 1, + "title": "Test", + "html_url": "https://g.com", + "created_at": "2026-07-25T01:02:03Z", + "state": "open", + "labels": [{"name": "bug"}], + } + r = MODULE.sync_one( + "org", + cfg, + "issue", + item, + apply=True, + label_ids={"bug": "lid1"}, + client=MODULE.YunxiaoClient(transport), + ) + assert r == "created" + assert calls[1][2] is not None + assert calls[1][2]["labels"] == ["lid1"] + + +def test_sync_one_close_updates_status() -> None: + calls: list[tuple[str, str, dict[str, Any] | None]] = [] + + def transport(method: str, path: str, body: dict[str, Any] | None = None) -> Any: + calls.append((method, path, body)) + if "search" in path: + return {"data": {"workitems": [{"id": "existing"}]}} + return {} + + cfg = { + "org": "org", + "project_id": "p", + "type_id": "t", + "assignee_id": "a", + "priority_id": "pr", + "statuses": {"待处理": "s1", "已完成": "s2", "已取消": "s3"}, + } + r = MODULE.sync_one( + "org", + cfg, + "issue", + { + "number": 1, + "title": "T", + "html_url": "x", + "state": "closed", + "created_at": "2026-07-25T01:02:03Z", + "labels": [], + }, + apply=True, + label_ids={}, + client=MODULE.YunxiaoClient(transport), + ) + assert r == "updated-status" + assert calls[1][2] == {"status": "s3"} -def test_preflight_workflow_only_runs_read_only_preflight() -> None: - workflow = Path(__file__).parents[1] / ".github" / "workflows" / "yunxiao-github-sync.yml" - content = workflow.read_text(encoding="utf-8") +def test_workflow_structure() -> None: + content = ( + Path(__file__).parents[1] / ".github" / "workflows" / "yunxiao-github-sync.yml" + ).read_text(encoding="utf-8") + assert "pull_request_target:" in content + assert "issues:" in content assert "workflow_dispatch:" in content assert "YUNXIAO_TOKEN: ${{ secrets.YUNXIAO_TOKEN }}" in content - assert "--mode preflight" in content - assert "YUNXIAO_PROJECT_ID: 2eac3f84ef1a3482535bd0e255" in content - assert "issues:" not in content - assert "pull_request" not in content - assert "--apply" not in content + assert "--mode event" in content + assert "mode:" in content + assert "preflight" in content + assert "backfill" in content + assert "contents: read" in content + assert "checkout@v4" in content From 47ed38bb98f748565b3351dcf92fd44ced21ef59 Mon Sep 17 00:00:00 2001 From: sunqi Date: Sat, 25 Jul 2026 15:19:30 +0800 Subject: [PATCH 5/5] feat: update status model to 6 states for open-source workflow Made-with: Proma --- scripts/yunxiao_github_sync.py | 8 +++--- tests/test_yunxiao_github_sync.py | 42 ++++++++++++++++++++++++------- 2 files changed, 37 insertions(+), 13 deletions(-) diff --git a/scripts/yunxiao_github_sync.py b/scripts/yunxiao_github_sync.py index 74d80eef7..7f7ac3b02 100644 --- a/scripts/yunxiao_github_sync.py +++ b/scripts/yunxiao_github_sync.py @@ -13,7 +13,7 @@ SOURCE_LABELS = {"issue": "Issue", "pr": "PR"} -STATUS_LABELS = ("待处理", "已完成", "已取消") +STATUS_LABELS = ("待响应", "处理中", "待验证", "已完成", "已关闭", "不予处理") # ---------- errors ---------- @@ -90,10 +90,10 @@ def iso_after_days(value: str, days: int) -> str: def source_status(item_type: str, item: dict[str, Any]) -> str: if item.get("state") == "open": - return "待处理" + return "待响应" if item_type == "pr" and item.get("merged"): return "已完成" - return "已取消" + return "已关闭" # ---------- preflight helpers ---------- @@ -330,7 +330,7 @@ def sync_one( return "skipped-closed" if not apply: return "dry-run-create" - status_id = cfg["statuses"]["待处理"] + status_id = cfg["statuses"]["待响应"] payload: dict[str, Any] = { "spaceId": cfg["project_id"], "workitemTypeId": cfg["type_id"], diff --git a/tests/test_yunxiao_github_sync.py b/tests/test_yunxiao_github_sync.py index cbf57aa1a..a4fc2725f 100644 --- a/tests/test_yunxiao_github_sync.py +++ b/tests/test_yunxiao_github_sync.py @@ -24,10 +24,10 @@ def test_iso_after_days() -> None: def test_source_status() -> None: - assert MODULE.source_status("issue", {"state": "open"}) == "待处理" + assert MODULE.source_status("issue", {"state": "open"}) == "待响应" assert MODULE.source_status("pr", {"state": "closed", "merged": True}) == "已完成" - assert MODULE.source_status("pr", {"state": "closed", "merged": False}) == "已取消" - assert MODULE.source_status("issue", {"state": "closed"}) == "已取消" + assert MODULE.source_status("pr", {"state": "closed", "merged": False}) == "已关闭" + assert MODULE.source_status("issue", {"state": "closed"}) == "已关闭" def test_preflight_documented_schema() -> None: @@ -48,9 +48,12 @@ def test_preflight_documented_schema() -> None: ], "GET /oapi/v1/projex/organizations/org-id/projects/project-id/workitemTypes/req-id/workflows": { "statuses": [ - {"statusId": "pending-id", "displayValue": "待处理"}, + {"statusId": "pending-id", "displayValue": "待响应"}, + {"statusId": "in-progress-id", "displayValue": "处理中"}, + {"statusId": "verify-id", "displayValue": "待验证"}, {"statusId": "done-id", "displayValue": "已完成"}, - {"statusId": "cancelled-id", "displayValue": "已取消"}, + {"statusId": "closed-id", "displayValue": "已关闭"}, + {"statusId": "wontfix-id", "displayValue": "不予处理"}, ], }, "GET /oapi/v1/projex/organizations/org-id/projects/project-id/workitemTypes/req-id/fields": [ @@ -76,7 +79,14 @@ def transport(method: str, path: str, body: object = None) -> object: "type_id": "req-id", "assignee_id": "sunqi-id", "priority_id": "medium-id", - "statuses": {"待处理": "pending-id", "已完成": "done-id", "已取消": "cancelled-id"}, + "statuses": { + "待响应": "pending-id", + "处理中": "in-progress-id", + "待验证": "verify-id", + "已完成": "done-id", + "已关闭": "closed-id", + "不予处理": "wontfix-id", + }, } assert {m for m, _ in calls} == {"GET"} @@ -98,7 +108,14 @@ def transport(method: str, path: str, body: dict[str, Any] | None = None) -> Any "type_id": "t", "assignee_id": "a", "priority_id": "pr", - "statuses": {"待处理": "s1", "已完成": "s2", "已取消": "s3"}, + "statuses": { + "待响应": "s1", + "处理中": "s2", + "待验证": "s3", + "已完成": "s4", + "已关闭": "s5", + "不予处理": "s6", + }, } item = { "number": 1, @@ -137,7 +154,14 @@ def transport(method: str, path: str, body: dict[str, Any] | None = None) -> Any "type_id": "t", "assignee_id": "a", "priority_id": "pr", - "statuses": {"待处理": "s1", "已完成": "s2", "已取消": "s3"}, + "statuses": { + "待响应": "s1", + "处理中": "s2", + "待验证": "s3", + "已完成": "s4", + "已关闭": "s5", + "不予处理": "s6", + }, } r = MODULE.sync_one( "org", @@ -156,7 +180,7 @@ def transport(method: str, path: str, body: dict[str, Any] | None = None) -> Any client=MODULE.YunxiaoClient(transport), ) assert r == "updated-status" - assert calls[1][2] == {"status": "s3"} + assert calls[1][2] == {"status": "s5"} def test_workflow_structure() -> None: