Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 88 additions & 0 deletions .github/workflows/yunxiao-github-sync.yml
Original file line number Diff line number Diff line change
@@ -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[@]}"
77 changes: 77 additions & 0 deletions docs/github-yunxiao-sync.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# 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` 需求空间,由云效用户后续人工分配负责人。

预检会按语义解析云效工作流状态:合并的 PR 优先使用“已完成”,如果目标项目
使用“开发完成”则自动使用该名称;取消状态同理支持“已取消”“已关闭”“关闭”。

每个源仓库可以使用不同的 `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 管理。
182 changes: 182 additions & 0 deletions scripts/test_yunxiao_github_sync.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
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_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 = []

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()
Loading
Loading