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
10 changes: 8 additions & 2 deletions docs/github-yunxiao-sync.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
同步链路如下:

```text
GitHub issues / pull_request_target
GitHub issues / external pull_request_target
-> .github/workflows/yunxiao-github-sync.yml
-> scripts/yunxiao_github_sync.py
-> 云效 OpenAPI
Expand Down Expand Up @@ -70,7 +70,13 @@ GitHub issues / pull_request_target
3. 确认结果后再次运行 `mode=backfill`、`state=open`、`apply=true`。
4. 需要把历史关闭项也纳入同步时,将 `state` 设为 `all`。

实时事件会处理 `opened`、`closed` 和 `reopened`。已存在的云效工作项只更新状态,不重复创建。
实时事件会处理 `opened`、`closed` 和 `reopened`。Pull Request 使用 GitHub 的
`author_association` 判断来源,只同步社区身份 `CONTRIBUTOR`、`FIRST_TIMER`、
`FIRST_TIME_CONTRIBUTOR` 和 `NONE`;仓库内部的 `OWNER`、`MEMBER`、
`COLLABORATOR` 以及未知身份会跳过。Issue 不做这个过滤。

已存在的云效工作项只更新状态,不重复创建。历史回填也会沿用同一条 PR 过滤规则,
不会把内部 PR 新建到云效。已经存在的内部 PR 工作项不会被自动删除。

幂等键包含完整源仓库名,例如:

Expand Down
57 changes: 57 additions & 0 deletions scripts/test_yunxiao_github_sync.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
from __future__ import annotations

import importlib.util
import json
import os
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch


MODULE_PATH = Path(__file__).with_name("yunxiao_github_sync.py")
Expand Down Expand Up @@ -38,6 +42,59 @@ def test_source_status(self) -> None:
"已取消",
)

def test_only_community_pull_requests_are_synced(self) -> None:
for association in (
"CONTRIBUTOR",
"FIRST_TIMER",
"FIRST_TIME_CONTRIBUTOR",
"NONE",
"contributor",
):
self.assertTrue(
MODULE.should_sync_pull_request(
{"author_association": association}
)
)

def test_internal_pull_request_event_skips_before_cloud_api_calls(self) -> None:
event = {
"action": "opened",
"repository": {"full_name": "MemTensor/memmy-agent"},
"pull_request": {
"number": 99,
"title": "Internal change",
"state": "open",
"author_association": "MEMBER",
},
}
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
suffix=".json",
) as event_file:
json.dump(event, event_file)
event_file.flush()
with (
patch.dict(
os.environ,
{"GITHUB_EVENT_PATH": event_file.name},
clear=False,
),
patch.object(
MODULE,
"configuration_from_environment",
side_effect=AssertionError("internal PR must not call Yunxiao"),
),
):
self.assertEqual(MODULE.handle_event(object()), 0)

for association in ("OWNER", "MEMBER", "COLLABORATOR", "MANNEQUIN", ""):
self.assertFalse(
MODULE.should_sync_pull_request(
{"author_association": association}
)
)

def test_resolve_statuses_accepts_memmy_workflow_names(self) -> None:
workflow = [
{"id": "pending", "name": "待处理"},
Expand Down
33 changes: 31 additions & 2 deletions scripts/yunxiao_github_sync.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,16 @@
DEFAULT_WORKITEM_TYPE_NAME = "需求"
DEFAULT_DAYS_TO_FINISH = 7
DEFAULT_PARTICIPANT_NAMES = ("徐之淇", "贾澄臻")
# GitHub exposes the relationship between a pull-request author and the
# repository. Only community-facing associations enter the Yunxiao space.
EXTERNAL_PR_AUTHOR_ASSOCIATIONS = frozenset(
{
"CONTRIBUTOR",
"FIRST_TIMER",
"FIRST_TIME_CONTRIBUTOR",
"NONE",
}
)
REQUIRED_ENV = (
"YUNXIAO_PROJECT_ID",
"YUNXIAO_PROJECT_NAME",
Expand Down Expand Up @@ -127,6 +137,12 @@ def source_status(item_type: str, item: dict[str, Any]) -> str:
return "已取消"


def should_sync_pull_request(item: dict[str, Any]) -> bool:
"""Return whether a pull request was submitted by a community user."""
association = str(item.get("author_association") or "").strip().upper()
return association in EXTERNAL_PR_AUTHOR_ASSOCIATIONS


def item_id(item: dict[str, Any]) -> str:
for key in ("id", "identifier", "organizationId", "userId", "statusId", "value"):
value = item.get(key)
Expand Down Expand Up @@ -604,6 +620,14 @@ def handle_event(client: YunxiaoClient) -> int:
repository = repository_name(
event.get("repository", {}).get("full_name") or os.environ.get("GITHUB_REPOSITORY")
)
if item_type == "pr" and not should_sync_pull_request(item):
association = str(item.get("author_association") or "").strip().upper() or "UNKNOWN"
print(
f"{repository} pr #{item['number']}: skipped internal author_association={association}",
file=sys.stderr,
)
return 0

cfg = configuration_from_environment(client)
all_labels = {
label["name"]
Expand Down Expand Up @@ -660,9 +684,14 @@ 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)
prs_raw = 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)
prs = [item for item in prs_raw if should_sync_pull_request(item)]
print(
f"GitHub: {len(issues)} {state} issues, {len(prs)} external {state} PRs "
f"(skipped {len(prs_raw) - len(prs)} internal PRs)",
file=sys.stderr,
)

cfg = configuration_from_environment(client)
all_labels: set[str] = set()
Expand Down
Loading