diff --git a/.github/aicodingflow-tests/test_resolve_pr_event.py b/.github/aicodingflow-tests/test_resolve_pr_event.py index 89a8d06..b7571a1 100644 --- a/.github/aicodingflow-tests/test_resolve_pr_event.py +++ b/.github/aicodingflow-tests/test_resolve_pr_event.py @@ -75,6 +75,17 @@ def test_pull_request_event_reuses_existing_payload(self) -> None: event, ) + def test_pull_request_target_event_reuses_existing_payload(self) -> None: + with tempfile.TemporaryDirectory() as directory: + event_path = Path(directory) / "event.json" + event = {"pull_request": pr_payload(number=12)} + event_path.write_text(json.dumps(event), encoding="utf-8") + + self.assertEqual( + resolver.resolve_event("owner/repo", "pull_request_target", event_path, ""), + event, + ) + def test_workflow_dispatch_fetches_pr_payload(self) -> None: fetched = pr_payload(number=12) with mock.patch.object(resolver, "fetch_pr", return_value=fetched) as fetch_pr: @@ -131,6 +142,36 @@ def test_review_state_marks_same_repo_non_draft_pr_reviewable(self) -> None: }, ) + def test_review_state_marks_pull_request_target_non_draft_pr_reviewable(self) -> None: + state = resolver.review_state( + {"pull_request": pr_payload(number=14)}, + "owner/repo", + "pull_request_target", + ) + + self.assertEqual(state["reviewable"], "true") + self.assertEqual(state["skip_reason"], "") + + def test_review_state_skips_pull_request_target_fork_pr(self) -> None: + state = resolver.review_state( + {"pull_request": pr_payload(number=15, head_repo="fork/repo")}, + "owner/repo", + "pull_request_target", + ) + + self.assertEqual(state["reviewable"], "false") + self.assertEqual(state["skip_reason"], "head repository is not the current repository") + + def test_review_state_skips_pull_request_target_draft_pr(self) -> None: + state = resolver.review_state( + {"pull_request": pr_payload(number=16, draft=True)}, + "owner/repo", + "pull_request_target", + ) + + self.assertEqual(state["reviewable"], "false") + self.assertEqual(state["skip_reason"], "draft") + def test_review_state_skips_draft_and_closed_prs(self) -> None: self.assertEqual( resolver.review_state({"pull_request": pr_payload(draft=True)}, "owner/repo")["reviewable"], @@ -141,12 +182,21 @@ def test_review_state_skips_draft_and_closed_prs(self) -> None: "false", ) - def test_review_state_allows_open_non_draft_fork_prs(self) -> None: - self.assertEqual( - resolver.review_state({"pull_request": pr_payload(head_repo="fork/repo")}, "owner/repo")["reviewable"], - "true", + def test_review_state_allows_open_non_draft_fork_prs_for_manual_entry(self) -> None: + state = resolver.review_state({"pull_request": pr_payload(head_repo="fork/repo")}, "owner/repo") + + self.assertEqual(state["reviewable"], "true") + self.assertEqual(state["skip_reason"], "") + + def test_review_state_allows_manual_workflow_dispatch_for_fork_pr(self) -> None: + state = resolver.review_state( + {"pull_request": pr_payload(head_repo="fork/repo")}, + "owner/repo", + "workflow_dispatch", ) + self.assertEqual(state["reviewable"], "true") + def test_review_state_allows_valid_comment_review_for_open_non_draft_pr(self) -> None: self.assertEqual( resolver.review_state( @@ -157,6 +207,16 @@ def test_review_state_allows_valid_comment_review_for_open_non_draft_pr(self) -> "true", ) + def test_review_state_skips_issue_comment_review_for_fork_pr(self) -> None: + state = resolver.review_state( + {"pull_request": pr_payload(head_repo="fork/repo"), "review_command": True}, + "owner/repo", + "issue_comment", + ) + + self.assertEqual(state["reviewable"], "false") + self.assertEqual(state["skip_reason"], "head repository is not the current repository") + def test_review_state_skips_issue_comments_without_valid_review_command(self) -> None: state = resolver.review_state( {"pull_request": pr_payload(draft=False), "review_command": False}, diff --git a/.github/aicodingflow-tests/test_review_workflow_dispatch.py b/.github/aicodingflow-tests/test_review_workflow_dispatch.py index 28ccc03..aed2f28 100644 --- a/.github/aicodingflow-tests/test_review_workflow_dispatch.py +++ b/.github/aicodingflow-tests/test_review_workflow_dispatch.py @@ -47,19 +47,22 @@ def test_workflows_use_node24_action_runtime(self) -> None: if action.startswith("actions/upload-artifact@"): self.assertEqual(action, "actions/upload-artifact@v7") - def test_review_workflow_uses_manual_and_comment_triggers_only(self) -> None: + def test_review_workflow_accepts_pull_request_target_and_manual_comment_triggers(self) -> None: data = workflow(".github/workflows/review-pr.yml") triggers = data[True] self.assertNotIn("pull_request", triggers) - self.assertNotIn("pull_request_target", triggers) + self.assertEqual( + triggers["pull_request_target"]["types"], + ["opened", "reopened", "synchronize", "ready_for_review"], + ) self.assertEqual(data["permissions"]["statuses"], "write") self.assertEqual(triggers["issue_comment"]["types"], ["created"]) self.assertTrue(triggers["workflow_dispatch"]["inputs"]["pr_number"]["required"]) job_gate = data["jobs"]["preflight"]["if"] + self.assertIn("github.event_name == 'pull_request_target'", job_gate) + self.assertIn("github.event.pull_request.head.repo.full_name == github.repository", job_gate) self.assertIn("github.event_name == 'workflow_dispatch'", job_gate) - self.assertNotIn("github.event_name == 'pull_request'", job_gate) - self.assertNotIn("github.event.pull_request.head.repo.full_name == github.repository", job_gate) self.assertIn("github.event_name == 'issue_comment'", job_gate) self.assertIn("github.event.issue.pull_request != null", job_gate) self.assertIn("contains(github.event.comment.body, format('@{0}', vars.AGENT_LOGIN))", job_gate) diff --git a/.github/scripts/resolve_pr_event.py b/.github/scripts/resolve_pr_event.py index 1a54483..b5577f7 100644 --- a/.github/scripts/resolve_pr_event.py +++ b/.github/scripts/resolve_pr_event.py @@ -74,10 +74,10 @@ def resolve_event( pr_number: str, agent_login: str = "", ) -> dict[str, Any]: - if event_name == "pull_request": + if event_name in {"pull_request", "pull_request_target"}: event = load_json(event_path) if "pull_request" not in event: - raise SystemExit("pull_request event payload is missing pull_request") + raise SystemExit(f"{event_name} event payload is missing pull_request") return event if event_name == "issue_comment": @@ -111,18 +111,21 @@ def review_state(event: dict[str, Any], repo: str, event_name: str = "") -> dict draft = bool(pr.get("draft")) state = str(pr.get("state") or "").lower() is_open = state == "open" + is_same_repo_entry = event_name not in {"pull_request_target", "issue_comment"} or head_repo == repo is_comment_review = event_name == "issue_comment" and event.get("review_command") is True if event_name == "workflow_dispatch": reviewable = is_open elif event_name == "issue_comment": - reviewable = is_open and not draft and is_comment_review + reviewable = is_open and not draft and is_comment_review and is_same_repo_entry else: - reviewable = is_open and not draft + reviewable = is_open and not draft and is_same_repo_entry if not is_open: skip_reason = "closed" elif draft and event_name != "workflow_dispatch": skip_reason = "draft" + elif event_name in {"pull_request_target", "issue_comment"} and not is_same_repo_entry: + skip_reason = "head repository is not the current repository" elif event_name == "issue_comment" and not is_comment_review: skip_reason = "missing valid @AGENT_LOGIN /review command" else: diff --git a/.github/workflows/review-pr.yml b/.github/workflows/review-pr.yml index d961f47..b703cdf 100644 --- a/.github/workflows/review-pr.yml +++ b/.github/workflows/review-pr.yml @@ -8,6 +8,8 @@ on: required: true issue_comment: types: [created] + pull_request_target: + types: [opened, reopened, synchronize, ready_for_review] permissions: contents: read @@ -21,14 +23,18 @@ concurrency: jobs: preflight: if: >- - github.event_name == 'workflow_dispatch' || ( - github.event_name == 'issue_comment' && - github.event.issue.pull_request != null && - vars.AGENT_LOGIN != '' && - contains(github.event.comment.body, format('@{0}', vars.AGENT_LOGIN)) && - contains(github.event.comment.body, '/review') - ) + github.event_name == 'pull_request_target' || + github.event_name == 'workflow_dispatch' || + ( + github.event_name == 'issue_comment' && + github.event.issue.pull_request != null && + vars.AGENT_LOGIN != '' && + contains(github.event.comment.body, format('@{0}', vars.AGENT_LOGIN)) && + contains(github.event.comment.body, '/review') + ) + ) && + (github.event_name != 'pull_request_target' || github.event.pull_request.head.repo.full_name == github.repository) runs-on: ubuntu-latest env: FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true" diff --git a/docs/product/raw/pr-review-verdict.md b/docs/product/raw/pr-review-verdict.md index c00abbf..9f23f26 100644 --- a/docs/product/raw/pr-review-verdict.md +++ b/docs/product/raw/pr-review-verdict.md @@ -12,11 +12,12 @@ AI PR Review 默认由目标项目的 CI 成功路径通过 `workflow_dispatch` 时,应在自己的 CI 成功路径中 dispatch `review-pr.yml`,而不是修改已安装的 `review-pr.yml`。 -AI PR Review 也可以手动通过 `workflow_dispatch` 输入 PR number 触发。当 open 且非 -draft 的 PR conversation comment 中包含发给 Actions variable `AGENT_LOGIN` 指定账号的 +AI PR Review 也会由 `pull_request_target` 监听同仓库 PR 的 opened、reopened、synchronize +和 ready_for_review 事件触发;该自动入口只接受同仓库 head,fork PR 会被 workflow gate 和 resolver 两层跳过。维护者仍可通过 `workflow_dispatch` 显式评审 fork PR。当 open 且非 +draft、同仓库 head 的 PR conversation comment 中包含发给 Actions variable `AGENT_LOGIN` 指定账号的 body-level command `@AGENT_LOGIN /review` 时,也可以从 `issue_comment` 事件触发。 -手动触发和 comment 触发会先解析目标 PR,再复用 review 流程。`review-pr.yml` 不直接监听 -GitHub `pull_request` 事件。 +手动触发、comment 触发和 `pull_request_target` 触发会先解析目标 PR,再复用 review 流程。 +`review-pr.yml` 不监听 GitHub `pull_request` 事件。 `workflow_dispatch` 触发的 review workflow 允许 `github-actions[bot]` 运行 Codex review action,因此使用 GitHub Actions 自带 `GITHUB_TOKEN` 的 CI dispatch 可以运行 AI @@ -29,9 +30,7 @@ mention、quoted line、fenced code block、普通句子中的提及,以及带 `@AGENT_LOGIN /review ...` 都不会触发 AI review。普通 issue comment 和 PR inline review comment 也不是该入口。 -只有 open、非 draft 且 head repository 与当前仓库一致的 PR 会继续进入 AI review。 -closed PR、draft PR 或来自 fork 的 PR 会被跳过,不会运行 agent、发布 review 或上传 -review artifact。 +自动 `pull_request_target` 入口只有 open、非 draft 且 head repository 与当前仓库一致的 PR 会继续进入 AI review。有效 comment 入口仅可评审同仓库 head PR;维护者可通过 `workflow_dispatch` 显式评审 fork 或 draft PR。closed PR 会被跳过,draft PR 只在自动和 comment 入口被跳过。 GitHub workflow 运行 AI PR Review 时,`pr_diff.txt` 来自 GitHub PR diff API,而不是 checkout 后对 base/head SHA 执行本地 `git diff`。workflow 会在读取 diff 前后校验 PR diff --git a/docs/product/wiki/concepts/ai-pr-review-workflow.md b/docs/product/wiki/concepts/ai-pr-review-workflow.md index 30d2885..5ba57c4 100644 --- a/docs/product/wiki/concepts/ai-pr-review-workflow.md +++ b/docs/product/wiki/concepts/ai-pr-review-workflow.md @@ -17,11 +17,11 @@ AI PR Review 负责在 PR 满足可评审条件时运行机器评审,并根据 ## 触发入口 -- AI PR Review 默认由目标项目 CI 成功路径通过 `workflow_dispatch` 输入 PR number 触发;AICodingFlow 参考 `CI` workflow 在非 draft、同仓库 head PR 的测试和编译检查成功后 dispatch `review-pr.yml`。 +- AI PR Review 可由 `pull_request_target` 在同仓库 PR 的 opened、reopened、synchronize、ready_for_review 事件触发,也可由目标项目 CI 成功路径通过 `workflow_dispatch` 输入 PR number 触发。 - 目标项目已有自己的 CI 时,应在自己的 CI 成功路径中 dispatch `review-pr.yml`,而不是修改已安装的 `review-pr.yml`。 - `workflow_dispatch` 可以通过 PR number 手动触发 review。 -- PR conversation comment mention Actions variable `AGENT_LOGIN` 指定账号时,可以从 `issue_comment` 事件触发。 -- `review-pr.yml` 不直接监听 GitHub `pull_request` 事件。 +- 同仓库 head、open 且非 draft PR conversation comment mention Actions variable `AGENT_LOGIN` 指定账号时,可以从 `issue_comment` 事件触发。 +- `review-pr.yml` 不监听 GitHub `pull_request` 事件;自动 `pull_request_target` 入口会被触发器门控和 resolver 双重限制为同仓库 head,维护者仍可通过 `workflow_dispatch` 显式评审 fork PR。 - 手动触发和 comment 触发会先解析目标 PR,再复用 review 流程。 - `workflow_dispatch` 触发允许 `github-actions[bot]` 运行 Codex review action;使用 GitHub App token、PAT 或第三方 CI dispatch 时,bot allowlist 需要匹配实际触发 actor,或改用有人类账号权限的 token 触发。 - Comment 触发要求 `@AGENT_LOGIN /review` 独占一行,允许前后空白。 @@ -30,8 +30,8 @@ AI PR Review 负责在 PR 满足可评审条件时运行机器评审,并根据 ## Reviewable 条件 -- 只有 open、非 draft 且 head repository 与当前仓库一致的 PR 会继续进入 AI review。 -- closed PR、draft PR 或来自 fork 的 PR 会被跳过。 +- 自动 `pull_request_target` 入口只有 open、非 draft 且 head repository 与当前仓库一致的 PR 会继续进入 AI review;`workflow_dispatch` 可显式评审 fork 或 draft PR,comment 入口仅接受同仓库 head PR。 +- closed PR 会被跳过;draft PR 只在自动 `pull_request_target` 和 comment 入口被跳过。 - 被跳过的 PR 不会运行 agent、发布 review 或上传 review artifact。 ## Skill 选择 diff --git a/docs/product/wiki/summaries/pr-review-verdict.md b/docs/product/wiki/summaries/pr-review-verdict.md index 703e275..5801706 100644 --- a/docs/product/wiki/summaries/pr-review-verdict.md +++ b/docs/product/wiki/summaries/pr-review-verdict.md @@ -28,17 +28,17 @@ Source: [docs/product/raw/pr-review-verdict.md](../../raw/pr-review-verdict.md) ## 触发与 skill 选择 -- AI PR Review 默认由目标项目 CI 成功路径通过 `workflow_dispatch` 输入 PR number 触发;AICodingFlow 参考 `CI` workflow 在非 draft、同仓库 head PR 的测试和编译检查成功后 dispatch `review-pr.yml`。 +- AI PR Review 可由 `pull_request_target` 在同仓库 PR 的 opened、reopened、synchronize、ready_for_review 事件触发,也可由目标项目 CI 成功路径通过 `workflow_dispatch` 输入 PR number 触发。 - 目标项目已有自己的 CI 时,应在自己的 CI 成功路径中 dispatch `review-pr.yml`,而不是修改已安装的 `review-pr.yml`。 -- AI PR Review 也可以手动通过 `workflow_dispatch` 输入 PR number 触发,或由 open 且非 draft PR conversation comment 中的 `@AGENT_LOGIN /review` body-level command 从 `issue_comment` 事件触发。 -- `review-pr.yml` 不直接监听 GitHub `pull_request` 事件。 +- AI PR Review 也可以手动通过 `workflow_dispatch` 输入 PR number 触发,或由同仓库 head、open 且非 draft PR conversation comment 中的 `@AGENT_LOGIN /review` body-level command 从 `issue_comment` 事件触发。 +- `review-pr.yml` 不监听 GitHub `pull_request` 事件;自动 `pull_request_target` 入口会被触发器门控和 resolver 双重限制为同仓库 head,维护者仍可通过 `workflow_dispatch` 显式评审 fork PR。 - `workflow_dispatch` 触发允许 `github-actions[bot]` 运行 Codex review action;如果目标仓库改用 GitHub App token、PAT 或第三方 CI dispatch,需要让 bot allowlist 与实际触发 actor 匹配,或改用有人类账号权限的 token 触发。 - 手动触发和 comment 触发会先解析目标 PR,再复用 review 流程。 - Comment 触发要求 `@AGENT_LOGIN /review` 独占一行,允许前后空白。 - 裸 `/review`、单纯 `@AGENT_LOGIN` mention、quoted line、fenced code block、普通句子中的提及,以及带额外参数的 `@AGENT_LOGIN /review ...` 都不会触发 AI review。 - 普通 issue comment 和 PR inline review comment 不是 comment 触发入口。 -- 只有 open、非 draft 且 head repository 与当前仓库一致的 PR 会继续进入 AI review。 -- closed PR、draft PR 或来自 fork 的 PR 会被跳过,不会运行 agent、发布 review 或上传 review artifact。 +- 自动 `pull_request_target` 入口只有 open、非 draft 且 head repository 与当前仓库一致的 PR 会继续进入 AI review;`workflow_dispatch` 可显式评审 fork PR;comment 入口仅接受同仓库 head PR。 +- closed PR 会被跳过;draft PR 只在自动 `pull_request_target` 和 comment 入口被跳过,`workflow_dispatch` 仍可显式评审 draft PR。 - spec-only PR 使用 `review-spec-repo`;其他 code PR 使用 `review-pr-repo`。 - 仓库本地 wrapper skill 补充本仓库评审偏好,不改变核心输出契约。