diff --git a/docs/github-yunxiao-sync.md b/docs/github-yunxiao-sync.md index d4ef20559..e44ce2d3f 100644 --- a/docs/github-yunxiao-sync.md +++ b/docs/github-yunxiao-sync.md @@ -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 @@ -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 工作项不会被自动删除。 幂等键包含完整源仓库名,例如: diff --git a/scripts/test_yunxiao_github_sync.py b/scripts/test_yunxiao_github_sync.py index 2c38d3e9a..627ac6cde 100644 --- a/scripts/test_yunxiao_github_sync.py +++ b/scripts/test_yunxiao_github_sync.py @@ -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") @@ -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": "待处理"}, diff --git a/scripts/yunxiao_github_sync.py b/scripts/yunxiao_github_sync.py index c0158af1e..c3e230e6c 100644 --- a/scripts/yunxiao_github_sync.py +++ b/scripts/yunxiao_github_sync.py @@ -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", @@ -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) @@ -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"] @@ -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()