Browse Source

fix: reconcile automated review requests

Turtle 1 month ago
parent
commit
fe834c7f92

+ 6 - 0
.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-08-comment-only-review-routing.md
+2026-09-08-comment-only-review-routing.md: 050905285b2291b34da9873d19c2f122c088a9e5
+2026-09-08-comment-only-review-routing.zh.md: b98f5d70b4d5c0fd27df1c393238b0802e420e09

+ 41 - 0
.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.md

@@ -0,0 +1,41 @@
+# Agent Note: Exclude documentation and comment-only changes from review routing
+
+Status: implemented
+
+English | [中文](2026-09-08-comment-only-review-routing.zh.md)
+
+## Problem
+
+Directory ownership alone treats documentation and comment edits like executable changes. These edits do not require the automatic code-owner request that protects behavior changes.
+
+GitHub may omit or truncate a file patch. A scanner that assumes every patch is complete can miss executable changes that occur outside the supplied hunks.
+
+## Decision
+
+Review routing classifies every old and new path in this order: test, documentation, comment-only, then reviewable code. Test classification wins when a test path also has a documentation extension. Every filename ending in `.md` or `.yaml`, matched without case sensitivity, is documentation. A `.yml` file is not documentation under this rule.
+
+Comment-only classification applies only to files with `status: modified` and a declared source-comment syntax. The scanner reconstructs the before and after text for each patch hunk, removes comments outside quoted strings, removes empty lines left by comments, and requires the remaining text to be identical.
+
+The scanner counts added and deleted patch lines and compares them with GitHub's file record before accepting a comment-only result. A missing patch, a count mismatch, a rename, an unsupported extension, or a comment form that remains visible to the lexer keeps the file reviewable. This fail-safe result can request an unnecessary review but cannot suppress a known code change.
+
+The supported lexical rules cover C-style line and block comments, hash comments, SQL comments, CSS block comments, and HTML comments for an explicit extension set in the scanner. Comment directives such as JSDoc tags, lint controls, compiler controls, and coverage controls are comments for routing purposes.
+
+## Verification
+
+[Scanner tests](../../../../.github/review-ownership/request-review.test.mjs) cover documentation extensions, supported comment forms, quoted comment markers, executable token changes, incomplete patches, renames, unsupported extensions, exclusion precedence, and the no-request result when every file is excluded.
+
+## Alternatives considered
+
+**Keep every non-test file reviewable.** This requests code owners for documentation and comment maintenance even though the routing policy is intended to identify executable changes.
+
+**Infer arbitrary semantic equivalence.** Proving behavior equivalence across the repository's languages requires language toolchains and still cannot assign one stable meaning to generated files, configuration, or build directives. The scanner performs only lexical comment removal.
+
+**Trust every patch returned by GitHub.** GitHub can omit or truncate patches. Matching the patch's added and deleted line counts to the file record prevents a partial patch from producing a comment-only verdict.
+
+**Fetch and parse every complete file revision.** Per-file content requests multiply API traffic for large pull requests and still require the same language-specific parsing. The changed-file response already carries enough evidence for complete ordinary patches.
+
+## Consequences
+
+Documentation and proven comment-only changes request nobody. The workflow logs them separately from tests so maintainers can audit why owner matching ignored a file.
+
+Unsupported or incomplete inputs remain reviewable. Comment directives do not request owners even when another tool interprets them, because this policy classifies their lexical form rather than downstream tool behavior.

+ 41 - 0
.agents/notes/implemented/process/2026-09-08-comment-only-review-routing.zh.md

@@ -0,0 +1,41 @@
+# Agent Note: 从评审路由中排除文档和纯注释变更
+
+Status: implemented
+
+[English](2026-09-08-comment-only-review-routing.md) | 中文
+
+## 问题
+
+只按目录分配 owner 会把文档和注释编辑视为可执行变更。这些编辑不需要用于保护行为变更的自动代码 owner 请求。
+
+GitHub 可能省略或截断文件 patch。如果扫描器假定每个 patch 都完整,就可能漏掉位于已提供 hunk 之外的可执行变更。
+
+## 决策
+
+评审路由按测试、文档、纯注释、可评审代码的顺序对每个新旧路径分类。当测试路径同时具有文档扩展名时,测试分类优先。所有以 `.md` 或 `.yaml` 结尾的文件均视为文档,扩展名匹配不区分大小写;此规则不把 `.yml` 文件视为文档。
+
+纯注释分类只适用于 `status: modified` 且已声明源码注释语法的文件。扫描器重建每个 patch hunk 的变更前后文本,移除引号字符串外的注释和注释留下的空行,并要求其余文本完全相同。
+
+扫描器会统计 patch 的新增行和删除行,并在接受纯注释结果前与 GitHub 文件记录比较。缺失 patch、计数不符、重命名、不受支持的扩展名,或词法分析器仍能看到的注释形式都会使文件保持可评审状态。该保守结果可能产生不必要的评审请求,但不会隐藏已知代码变更。
+
+受支持的词法规则按扫描器中显式的扩展名集合覆盖 C 风格行注释和块注释、井号注释、SQL 注释、CSS 块注释及 HTML 注释。JSDoc 标签、lint 控制、编译器控制和覆盖率控制等注释指令在评审路由中仍属于注释。
+
+## 验证
+
+[扫描器测试](../../../../.github/review-ownership/request-review.test.mjs)覆盖文档扩展名、受支持的注释形式、引号内的注释标记、可执行 token 变更、不完整 patch、重命名、不受支持的扩展名、排除优先级,以及所有文件均被排除时不发出请求的结果。
+
+## 考虑过的替代方案
+
+**让每个非测试文件都保持可评审。** 这会为文档和注释维护请求代码 owner,但该路由策略的目标是识别可执行变更。
+
+**推断任意语义等价。** 证明仓库中多种语言的行为等价需要各语言工具链,而且仍然无法为生成文件、配置或构建指令提供一种稳定含义。扫描器只执行词法注释移除。
+
+**信任 GitHub 返回的每个 patch。** GitHub 可能省略或截断 patch。将 patch 的新增和删除行数与文件记录匹配,可以防止不完整 patch 产生纯注释结论。
+
+**获取并解析每个文件的完整修订版本。** 对于大型 PR,逐文件内容请求会增加多倍 API 流量,而且仍需相同的语言专用解析。普通完整 patch 所需的证据已包含在变更文件响应中。
+
+## 后果
+
+文档和确认的纯注释变更不会请求任何人。Workflow 会将它们与测试分开记录,以便维护者检查 owner 匹配忽略文件的原因。
+
+不受支持或不完整的输入仍需评审。即使其他工具会解释注释指令,这些指令也不会请求 owner,因为该策略按词法形式分类,而不是按下游工具行为分类。

+ 2 - 2
.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md
-2026-09-08-trusted-changed-file-review-routing.md: 246c223d976e312e5712d7baa150bd3e84f1363e
-2026-09-08-trusted-changed-file-review-routing.zh.md: 397d4174340fc9c6002419b21436b5c33542f7e4
+2026-09-08-trusted-changed-file-review-routing.md: dde97002f8a138687ecfc1c8d0bfc9a373ef29a5
+2026-09-08-trusted-changed-file-review-routing.zh.md: 2d618d5ebbcaf46f3a6ac4bbfbb80f51e516323b

+ 7 - 7
.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md

@@ -16,17 +16,17 @@ The repository keeps a CODEOWNERS-compatible map at [`.github/review-ownership/C
 
 The policy test counts non-test tracked lines in directories that match an ownership rule. It rejects a map in which `@turtle1999` owns more than one third of that eligible owned codebase.
 
-The [`request-review` workflow](../../../../.github/workflows/request-review.yml) runs on non-draft `pull_request_target` events for opened, synchronized, reopened, and ready-for-review pull requests. Its write-capable job checks out the default branch and executes only the default branch's scanner and ownership map. It does not check out pull-request code or read repository secrets.
+The [`request-review` workflow](../../../../.github/workflows/request-review.yml) runs on `pull_request_target` events for opened, synchronized, reopened, ready-for-review, and converted-to-draft pull requests. Its write-capable job checks out the default branch and executes only the default branch's scanner and ownership map. It does not check out pull-request code or read repository secrets.
 
 The scanner fetches every changed-file record before deciding. It fails if the pull request reports more than GitHub's 3,000-file API limit or if pagination returns an incomplete list. It normalizes repository paths, evaluates old and new paths of a rename independently, and escapes filenames before logging them.
 
-The scanner excludes test-only paths before owner matching. Excluded paths comprise directories named `test`, `tests`, `__tests__`, `__snapshots__`, `benches`, or `stress-tests`; the top-level `benchmarks` and `snapshots` trees; `packages/test-support`; `scripts/fixtures` and `scripts/snapshots`; filenames ending in `.bench.<ext>`, `.corpus.<ext>`, `.e2e.<ext>`, `.perf.<ext>`, `.snapshot.<ext>`, `.spec.<ext>`, `.stress.<ext>`, or `.test.<ext>`; and Python `test_*.py`, `*_test.py`, or `*_tests.py` files. Test infrastructure such as `vitest*.config.ts` and gate implementations remains reviewable because it changes how repository evidence is produced.
+The scanner excludes test-only paths before owner matching. Excluded paths comprise directories named `test`, `tests`, `__tests__`, `__snapshots__`, `benches`, or `stress-tests`; the top-level `benchmarks` and `snapshots` trees; `packages/test-support`; `scripts/fixtures` and `scripts/snapshots`; filenames ending in `.bench.<ext>`, `.corpus.<ext>`, `.e2e.<ext>`, `.perf.<ext>`, `.snapshot.<ext>`, `.spec.<ext>`, `.stress.<ext>`, or `.test.<ext>`; and Python `test_*.py`, `*_test.py`, or `*_tests.py` files. Test infrastructure such as `vitest*.config.ts` and gate implementations remains reviewable because it changes how repository evidence is produced. The [comment-only routing decision](2026-09-08-comment-only-review-routing.md) owns the additional documentation and comment exclusions.
 
-The workflow prints the changed non-test paths, excluded test paths, per-file owner matches, and final reviewer list before any review-request mutation. It requests the union of matched individual owners after removing the pull-request author and users who are already requested. A test-only or wholly unmatched change requests nobody.
+The workflow prints the changed code paths, each exclusion class, per-file owner matches, and final reviewer action before any review-request mutation. For a non-draft pull request, it requests the union of matched individual owners after removing the pull-request author and users who are already requested. For a draft, it reads the complete review-request timeline and cancels current requests whose latest requester is `github-actions[bot]`; requests made by people remain unchanged.
 
 ## Verification
 
-[Scanner tests](../../../../.github/review-ownership/request-review.test.mjs) cover admitted ownership syntax, rejected syntax, each test convention, production-name negative controls, renames, last-match behavior, unmatched files, complete pagination, the 3,000-file limit, log-before-request ordering, author and existing-reviewer filtering, test-only changes, drafts, and API failures. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the event set, least permissions, trusted default-branch checkout, absence of pull-request-head references and secrets, and executed command. The gate graph includes both suites in static CI and `check-all`.
+[Scanner tests](../../../../.github/review-ownership/request-review.test.mjs) cover admitted ownership syntax, rejected syntax, each exclusion class, production-name negative controls, renames, last-match behavior, unmatched files, complete pagination, the 3,000-file limits, log-before-mutation ordering, author and existing-reviewer filtering, draft cancellation provenance, and API failures. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the event set, least permissions, trusted default-branch checkout, absence of pull-request-head references and secrets, and executed command. The gate graph includes both suites in static CI and `check-all`.
 
 ## Alternatives considered
 
@@ -36,12 +36,12 @@ The workflow prints the changed non-test paths, excluded test paths, per-file ow
 
 **Execute the pull request's scanner or owner map under `pull_request_target`.** This lets an untrusted pull request choose its own write-capable behavior or owners.
 
-**Infer semantic source changes from patches or language parsers.** GitHub can truncate patches, and the repository spans TypeScript, JavaScript, Python, Rust, YAML, Markdown, and generated evidence. A cross-language semantic classifier would add ambiguous rules without providing a complete input. The scanner therefore uses the complete non-test changed-file list and does not claim to distinguish formatting, comments, or documentation-only edits inside an eligible file.
+**Infer arbitrary semantic source changes from patches or language parsers.** GitHub can omit or truncate patches, and the repository spans many languages. The scanner does not try to prove that two programs behave identically. The later [comment-only routing decision](2026-09-08-comment-only-review-routing.md) adds a narrow lexical comparison only when changed-line counts prove that GitHub supplied the complete patch.
 
 ## Consequences
 
-Reviewer requests are reproducible from a trusted policy and the file list printed in the workflow log. Test-only changes do not request owners. Ownership changes become effective only after merge, so the pull request that changes policy cannot apply its untrusted policy to itself.
+Reviewer mutations are reproducible from a trusted policy, the file classifications printed in the workflow log, and review-request provenance in the pull-request timeline. Excluded changes do not request owners, and draft pull requests do not retain workflow-authored requests. Ownership changes become effective only after merge, so the pull request that changes policy cannot apply its untrusted policy to itself.
 
 The workflow requests every matched owner rather than choosing one owner nondeterministically. Shared ownership on large directories therefore produces multiple requests. GitHub-generated review-request events may not start other workflows that depend on recursively triggered events from `GITHUB_TOKEN`; those workflows must not rely on this request as their only trigger.
 
-Any non-test change under an owned directory remains eligible, including comment-only or formatting-only edits and documentation changes. Unmatched paths are logged and request nobody. Pull requests above the API file limit fail without requesting a partial owner set.
+Any change that does not match an explicit exclusion remains eligible under an owned directory. Unmatched paths are logged and request nobody. Pull requests above the file or timeline API limit fail without applying a partial reviewer mutation.

+ 7 - 7
.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.zh.md

@@ -16,17 +16,17 @@ Status: implemented
 
 策略测试会统计匹配所有权规则的目录中的非测试跟踪文件行数。如果 `@turtle1999` 拥有的有效代码库超过三分之一,测试就会拒绝该映射。
 
-[`request-review` workflow](../../../../.github/workflows/request-review.yml) 在非草稿 PR 的 `pull_request_target` 事件上运行,订阅创建、同步、重新打开和标记为可评审操作。具备写权限的 job 检出默认分支,只执行默认分支上的扫描器和所有权映射。它不会检出 PR 代码,也不会读取仓库 secret。
+[`request-review` workflow](../../../../.github/workflows/request-review.yml) 在 PR 创建、同步、重新打开、标记为可评审和转为草稿时运行 `pull_request_target` 事件。具备写权限的 job 检出默认分支,只执行默认分支上的扫描器和所有权映射。它不会检出 PR 代码,也不会读取仓库 secret。
 
 扫描器在决策之前获取所有变更文件记录。如果 PR 报告的文件数超过 GitHub API 的 3,000 个文件上限,或者分页只返回了部分列表,扫描器就会失败。它会规范化仓库路径,分别检查重命名前后的路径,并在记录文件名之前进行转义。
 
-扫描器会在匹配 owner 之前排除纯测试路径。排除范围包括名为 `test`、`tests`、`__tests__`、`__snapshots__`、`benches` 或 `stress-tests` 的目录,顶层 `benchmarks` 和 `snapshots` 目录树,`packages/test-support`、`scripts/fixtures` 和 `scripts/snapshots`,以 `.bench.<ext>`、`.corpus.<ext>`、`.e2e.<ext>`、`.perf.<ext>`、`.snapshot.<ext>`、`.spec.<ext>`、`.stress.<ext>` 或 `.test.<ext>` 结尾的文件名,以及 Python 的 `test_*.py`、`*_test.py` 或 `*_tests.py` 文件。`vitest*.config.ts` 和门禁实现等测试基础设施仍需评审,因为它们会改变仓库证据的生成方式。
+扫描器会在匹配 owner 之前排除纯测试路径。排除范围包括名为 `test`、`tests`、`__tests__`、`__snapshots__`、`benches` 或 `stress-tests` 的目录,顶层 `benchmarks` 和 `snapshots` 目录树,`packages/test-support`、`scripts/fixtures` 和 `scripts/snapshots`,以 `.bench.<ext>`、`.corpus.<ext>`、`.e2e.<ext>`、`.perf.<ext>`、`.snapshot.<ext>`、`.spec.<ext>`、`.stress.<ext>` 或 `.test.<ext>` 结尾的文件名,以及 Python 的 `test_*.py`、`*_test.py` 或 `*_tests.py` 文件。`vitest*.config.ts` 和门禁实现等测试基础设施仍需评审,因为它们会改变仓库证据的生成方式。[纯注释路由决策](2026-09-08-comment-only-review-routing.zh.md)记录额外的文档和注释排除规则。
 
-Workflow 会在发出任何评审请求变更之前,依次打印变更的非测试路径、排除的测试路径、逐文件 owner 匹配结果和最终评审者列表。它合并匹配到的个人 owner,并排除 PR 作者和已经收到评审请求的用户。纯测试变更或全部未匹配的变更不会请求任何人。
+Workflow 会在发出任何评审请求变更之前,依次打印变更代码路径、每类排除项、逐文件 owner 匹配结果和最终评审操作。对于非草稿 PR,它会合并匹配到的个人 owner,并排除 PR 作者和已经收到评审请求的用户。对于草稿,它会读取完整的评审请求时间线,并取消最近一次请求者为 `github-actions[bot]` 的当前请求;由人员发出的请求保持不变。
 
 ## 验证
 
-[扫描器测试](../../../../.github/review-ownership/request-review.test.mjs)覆盖允许的所有权语法、拒绝的语法、每种测试约定、生产文件名负向对照、重命名、最后匹配规则、未匹配文件、完整分页、3,000 个文件上限、先记录后请求的顺序、作者与现有评审者过滤、纯测试变更、草稿和 API 失败。[Workflow 测试](../../../../scripts/ci-workflow.spec.ts)固定事件集合、最小权限、受信任的默认分支检出、不引用 PR head 和 secret,以及执行的命令。门禁图在静态 CI 和 `check-all` 中包含这两组测试。
+[扫描器测试](../../../../.github/review-ownership/request-review.test.mjs)覆盖允许的所有权语法、拒绝的语法、每类排除项、生产文件名负向对照、重命名、最后匹配规则、未匹配文件、完整分页、两个 3,000 项上限、先记录后修改的顺序、作者与现有评审者过滤、草稿取消来源和 API 失败。[Workflow 测试](../../../../scripts/ci-workflow.spec.ts)固定事件集合、最小权限、受信任的默认分支检出、不引用 PR head 和 secret,以及执行的命令。门禁图在静态 CI 和 `check-all` 中包含这两组测试。
 
 ## 考虑过的替代方案
 
@@ -36,12 +36,12 @@ Workflow 会在发出任何评审请求变更之前,依次打印变更的非
 
 **在 `pull_request_target` 下执行 PR 中的扫描器或 owner 映射。** 这会让不受信任的 PR 选择自己的写权限行为或 owner。
 
-**根据补丁或语言解析器推断语义源码变更。** GitHub 可能截断补丁,而且仓库包含 TypeScript、JavaScript、Python、Rust、YAML、Markdown 和生成的证据。跨语言语义分类器会增加含义不明确的规则,却无法提供完整输入。因此,扫描器使用完整的非测试变更文件列表,并且不会声称能够区分合格文件中的纯格式、注释或仅文档编辑。
+**根据 patch 或语言解析器推断任意语义源码变更。** GitHub 可能省略或截断 patch,而且仓库包含多种语言。扫描器不会尝试证明两个程序行为相同。后续的[纯注释路由决策](2026-09-08-comment-only-review-routing.zh.md)只在变更行计数能够证明 GitHub 提供了完整 patch 时执行有限的词法比较。
 
 ## 后果
 
-评审请求可以根据受信任的策略和 workflow 日志中打印的文件列表复现。纯测试变更不会请求 owner。所有权变更只有合并后才会生效,因此修改策略的 PR 无法对自身应用其中不受信任的策略。
+评审请求修改可以根据受信任的策略、workflow 日志中打印的文件分类,以及 PR 时间线中的请求来源复现。被排除的变更不会请求 owner,草稿 PR 不会保留 workflow 发出的请求。所有权变更只有合并后才会生效,因此修改策略的 PR 无法对自身应用其中不受信任的策略。
 
 Workflow 会请求所有匹配的 owner,不会随机选择一人。因此,大目录上的共享所有权会产生多个请求。GitHub 使用 `GITHUB_TOKEN` 生成的评审请求事件可能不会启动依赖递归触发事件的其他 workflow;这些 workflow 不得把此请求作为唯一触发条件。
 
-所有已分配目录下的非测试变更仍符合请求条件,其中包括纯注释、纯格式调整和文档变更。未匹配的路径会被记录,但不会请求任何人。超过 API 文件上限的 PR 会失败,并且不会请求不完整的 owner 集合。
+已分配目录下不符合任何显式排除规则的变更仍符合请求条件。未匹配的路径会被记录,但不会请求任何人。超过文件或时间线 API 上限的 PR 会失败,并且不会执行不完整的评审者修改。

+ 2 - 2
.github/review-ownership/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .github/review-ownership/README.md
-README.md: 95dd52595bacb9b2b00bc475966f328b06098a14
-README.zh.md: a20a5f4ae41b5d0418601afa853ac2fbc141cc96
+README.md: fff5516fb6e67b639ffaa4799a040040184dd28a
+README.zh.md: 5b6cb2b60a9b08dca263c9254722b89cd0b225a0

+ 13 - 7
.github/review-ownership/README.md

@@ -4,12 +4,12 @@ English | [中文](README.zh.md)
 
 ## Summary
 
-The [`request-review` workflow](../workflows/request-review.yml) reads the CODEOWNERS-compatible [ownership map](CODEOWNERS) from the trusted default branch. It prints the complete changed non-test file list, matches those files to owners, and then requests the missing reviewers. The ownership map is outside GitHub's native CODEOWNERS locations, so GitHub does not apply it directly.
+The [`request-review` workflow](../workflows/request-review.yml) reads the CODEOWNERS-compatible [ownership map](CODEOWNERS) from the trusted default branch. It classifies changed files, requests missing owners for reviewable code, and cancels its outstanding requests when a pull request becomes a draft. The ownership map is outside GitHub's native CODEOWNERS locations, so GitHub does not apply it directly.
 
 ## Table of Contents
 
 - [Routing](#routing)
-- [Test exclusion](#test-exclusion)
+- [Review exclusions](#review-exclusions)
 - [Security](#security)
 - [Verification](#verification)
 - [Dev Note](#dev-note)
@@ -18,20 +18,26 @@ The [`request-review` workflow](../workflows/request-review.yml) reads the CODEO
 
 ## Routing
 
-Non-draft pull requests run the workflow when opened, synchronized, reopened, or marked ready for review. The scanner fetches the complete pull-request file list, evaluates both paths of a rename, and fails instead of routing from a partial list. GitHub exposes at most 3,000 files for this API.
+Pull requests run the workflow when opened, synchronized, reopened, marked ready for review, or converted to a draft. The scanner fetches the complete pull-request file list, evaluates both paths of a rename, and fails instead of routing from a partial list. GitHub exposes at most 3,000 files for this API.
 
-The ownership map accepts explicit absolute directory patterns and individual GitHub users. It rejects wildcards, hidden-directory patterns, teams, and duplicate patterns or owners. Matching follows CODEOWNERS last-match semantics. The scanner prints `Changed code files`, `Excluded test files`, `Owners by changed file`, and `Reviewers to request` before it sends the review request. Unmatched files remain visible in the log. The pull-request author and users who are already requested are omitted.
+For a non-draft pull request, the workflow requests missing matched owners. For a draft, it reads the current requested reviewers and review-request timeline, then cancels each current request whose latest requester is `github-actions[bot]`. Current requests made by people remain unchanged. The workflow fails without cancellation when the timeline exceeds 3,000 events or contains invalid request provenance.
+
+The ownership map accepts explicit absolute directory patterns and individual GitHub users. It rejects wildcards, hidden-directory patterns, teams, and duplicate patterns or owners. Matching follows CODEOWNERS last-match semantics. The scanner prints the changed code, excluded test, documentation, and comment-only files; per-file owner matches; and the reviewers it will request or cancel before it mutates review requests. Unmatched files remain visible in the log. The pull-request author and users who are already requested are omitted from new requests.
 
 The policy test measures non-test tracked lines under matched directories and requires `@turtle1999` to own no more than one third of that eligible owned codebase.
 
-<a id="test-exclusion"></a>
+<a id="review-exclusions"></a>
 
-## Test exclusion
+## Review exclusions
 
 Review routing excludes the repository's unit, end-to-end, expected-output, snapshot, benchmark, performance, stress, corpus, native, and Python test conventions. This includes `test`, `tests`, `__tests__`, `__snapshots__`, `benches`, and `stress-tests` directories; the top-level `benchmarks` and `snapshots` trees; `packages/test-support`; `scripts/fixtures` and `scripts/snapshots`; recognized test filename suffixes; and Python `test_*.py` or `*_test.py` files.
 
 Test infrastructure that can alter how evidence is produced remains reviewable, including `vitest*.config.ts` and gate implementations under `scripts`. A production file named `test.ts`, `spec.ts`, or `snapshot.ts` is not excluded solely by that name.
 
+Files ending in `.md` or `.yaml`, with case-insensitive extension matching, are documentation and never contribute owners. A `.yml` file remains reviewable unless another exclusion applies.
+
+For a modified file with a supported source extension, the scanner compares the pre-change and post-change text after removing parsed comments. It excludes the file only when GitHub supplies a patch whose counted additions and deletions prove that the patch is complete and the remaining code is identical. The parser recognizes C-style line and block comments, hash comments, SQL comments, CSS block comments, and HTML comments for their declared extensions. Renames, unsupported languages, missing or partial patches, and uncertain comment forms remain reviewable.
+
 <a id="security"></a>
 
 ## Security
@@ -44,7 +50,7 @@ Ownership changes take effect only after they merge into the default branch. Thi
 
 ## Verification
 
-Run `pnpm run test:request-review` for ownership parsing, test classification, pagination, logging order, reviewer filtering, and API behavior. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, permissions, events, and command. The repository gate graph runs both checks in CI.
+Run `pnpm run test:request-review` for ownership parsing, file classification, complete-patch checks, comment parsing, pagination, logging order, draft cancellation, reviewer filtering, and API behavior. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, permissions, events, and command. The repository gate graph runs both checks in CI.
 
 <a id="dev-note"></a>
 

+ 13 - 7
.github/review-ownership/README.zh.md

@@ -4,12 +4,12 @@
 
 ## 概要
 
-[`request-review` workflow](../workflows/request-review.yml) 从受信任的默认分支读取兼容 CODEOWNERS 格式的[所有权映射](CODEOWNERS)。它先打印完整的非测试变更文件列表,再将这些文件与 owner 匹配,最后请求尚未加入的评审者。所有权映射不在 GitHub 原生 CODEOWNERS 路径中,因此 GitHub 不会直接应用它。
+[`request-review` workflow](../workflows/request-review.yml) 从受信任的默认分支读取兼容 CODEOWNERS 格式的[所有权映射](CODEOWNERS)。它会对变更文件分类,为可评审代码请求尚未加入的 owner,并在 PR 转为草稿时取消自己尚未完成的请求。所有权映射不在 GitHub 原生 CODEOWNERS 路径中,因此 GitHub 不会直接应用它。
 
 ## 目录
 
 - [路由](#routing)
-- [排除测试](#test-exclusion)
+- [评审排除规则](#review-exclusions)
 - [安全性](#security)
 - [验证](#verification)
 - [开发说明](#dev-note)
@@ -18,20 +18,26 @@
 
 ## 路由
 
-非草稿 PR 在创建、同步、重新打开或标记为可评审时运行该 workflow。扫描器获取完整的 PR 文件列表,分别检查重命名前后的路径;如果只能取得部分列表,则停止执行,不发出评审请求。GitHub 对此 API 最多公开 3,000 个文件。
+PR 在创建、同步、重新打开、标记为可评审或转为草稿时运行该 workflow。扫描器获取完整的 PR 文件列表,分别检查重命名前后的路径;如果只能取得部分列表,则停止执行,不发出评审请求。GitHub 对此 API 最多公开 3,000 个文件。
 
-所有权映射只接受显式绝对目录模式和 GitHub 个人用户。通配符、隐藏目录模式、团队,以及重复的模式或 owner 都会被拒绝。匹配遵循 CODEOWNERS 的最后一条匹配规则。扫描器先打印 `Changed code files`、`Excluded test files`、`Owners by changed file` 和 `Reviewers to request`,再发送评审请求。未匹配的文件仍显示在日志中。PR 作者和已经收到评审请求的用户会被排除。
+对于非草稿 PR,workflow 会请求尚未加入的匹配 owner。对于草稿,workflow 会读取当前评审请求和评审请求时间线,然后取消最近一次请求者为 `github-actions[bot]` 的当前请求。由人员发出的当前请求保持不变。如果时间线超过 3,000 个事件或包含无效的请求来源,workflow 会失败且不执行取消操作。
+
+所有权映射只接受显式绝对目录模式和 GitHub 个人用户。通配符、隐藏目录模式、团队,以及重复的模式或 owner 都会被拒绝。匹配遵循 CODEOWNERS 的最后一条匹配规则。扫描器会在修改评审请求前,打印变更代码文件、排除的测试文件、文档文件和纯注释文件,逐文件 owner 匹配结果,以及将要请求或取消的评审者。未匹配的文件仍显示在日志中。PR 作者和已经收到评审请求的用户不会收到新请求。
 
 策略测试会统计已匹配目录下的非测试跟踪文件行数,并要求 `@turtle1999` 拥有的有效代码库不超过三分之一。
 
-<a id="test-exclusion"></a>
+<a id="review-exclusions"></a>
 
-## 排除测试
+## 评审排除规则
 
 评审路由会排除仓库中的单元测试、端到端测试、预期输出、快照、基准测试、性能测试、压力测试、语料测试、原生测试和 Python 测试约定。其中包括 `test`、`tests`、`__tests__`、`__snapshots__`、`benches` 和 `stress-tests` 目录,顶层 `benchmarks` 和 `snapshots` 目录树,`packages/test-support`、`scripts/fixtures` 和 `scripts/snapshots`,可识别的测试文件名后缀,以及 Python 的 `test_*.py` 或 `*_test.py` 文件。
 
 能够改变证据生成方式的测试基础设施仍需评审,包括 `vitest*.config.ts` 和 `scripts` 下的门禁实现。生产文件不会仅因名称为 `test.ts`、`spec.ts` 或 `snapshot.ts` 而被排除。
 
+扩展名以不区分大小写方式匹配。所有以 `.md` 或 `.yaml` 结尾的文件均视为文档,不会贡献 owner;除非符合其他排除规则,否则 `.yml` 文件仍需评审。
+
+对于具有受支持源码扩展名的修改文件,扫描器会移除解析出的注释,再比较变更前后的文本。只有 GitHub 提供的 patch 中增删行数与文件记录一致、能够证明 patch 完整,且其余代码完全相同时,扫描器才会排除该文件。解析器会按声明的扩展名识别 C 风格行注释和块注释、井号注释、SQL 注释、CSS 块注释与 HTML 注释。重命名、不受支持的语言、缺失或不完整的 patch,以及无法确定的注释形式仍需评审。
+
 <a id="security"></a>
 
 ## 安全性
@@ -44,7 +50,7 @@
 
 ## 验证
 
-运行 `pnpm run test:request-review` 可检查所有权解析、测试分类、分页、日志顺序、评审者过滤和 API 行为。[Workflow 测试](../../scripts/ci-workflow.spec.ts)固定受信任检出、权限、事件和命令。仓库门禁图会在 CI 中运行这两类检查。
+运行 `pnpm run test:request-review` 可检查所有权解析、文件分类、完整 patch 检查、注释解析、分页、日志顺序、草稿取消、评审者过滤和 API 行为。[Workflow 测试](../../scripts/ci-workflow.spec.ts)固定受信任检出、权限、事件和命令。仓库门禁图会在 CI 中运行这两类检查。
 
 <a id="dev-note"></a>
 

+ 249 - 21
.github/review-ownership/request-review.mjs

@@ -6,10 +6,20 @@ import { pathToFileURL } from 'node:url'
 
 const API_VERSION = '2026-03-10'
 const MAX_PULL_REQUEST_FILES = 3_000
+const MAX_TIMELINE_EVENTS = 3_000
 const PAGE_SIZE = 100
+const WORKFLOW_REVIEW_REQUESTER = 'github-actions[bot]'
 const TEST_DIRECTORY_NAMES = new Set(['__snapshots__', '__tests__', 'benches', 'stress-tests', 'test', 'tests'])
 const TEST_FILE_MARKER = /\.(?:bench|corpus|e2e|perf|snapshot|spec|stress|test)\.[^./]+$/u
 const PYTHON_TEST_FILE = /^(?:test_.+|.+_tests?)\.py$/u
+const DOCUMENTATION_FILE = /\.(?:md|yaml)$/iu
+const C_STYLE_EXTENSIONS = new Set([
+  'c', 'cc', 'cjs', 'cpp', 'cts', 'cxx', 'go', 'h', 'hpp', 'java', 'js', 'jsx',
+  'kt', 'kts', 'less', 'mjs', 'mts', 'rs', 'scss', 'swift', 'ts', 'tsx',
+])
+const BLOCK_COMMENT_EXTENSIONS = new Set(['css'])
+const HASH_COMMENT_EXTENSIONS = new Set(['bash', 'ps1', 'py', 'pyi', 'r', 'rb', 'sh', 'toml', 'yml', 'zsh'])
+const HTML_COMMENT_EXTENSIONS = new Set(['htm', 'html'])
 
 /**
  * Parse the explicit directory subset accepted from the review ownership file.
@@ -83,27 +93,174 @@ export function isTestPath(value) {
 }
 
 /**
- * Expand changed-file records into reviewable and excluded repository paths.
+ * Decide whether a repository path is documentation excluded from review routing.
+ * @param {string} value Repository-relative path.
+ * @returns {boolean} Whether the path has an excluded documentation extension.
+ */
+export function isDocumentationPath(value) {
+  return DOCUMENTATION_FILE.test(normalizeRepositoryPath(value))
+}
+
+/**
+ * Decide whether a complete modified-file patch changes comments only.
+ * @param {unknown} value GitHub changed-file record.
+ * @returns {boolean} Whether supported comment parsing removes every changed token.
+ */
+export function isCommentOnlyChange(value) {
+  if (!isRecord(value) || value.status !== 'modified' || typeof value.filename !== 'string'
+    || typeof value.patch !== 'string' || !Number.isSafeInteger(value.additions)
+    || value.additions < 0 || !Number.isSafeInteger(value.deletions) || value.deletions < 0) return false
+  const syntax = commentSyntax(value.filename)
+  if (syntax === undefined) return false
+  if (value.filename.toLowerCase().endsWith('.rs') && /\b(?:br|r)#{0,255}"/u.test(value.patch)) return false
+  const hunks = parsePatchHunks(value.patch)
+  if (hunks === undefined || hunks.additions !== value.additions || hunks.deletions !== value.deletions) {
+    return false
+  }
+  return hunks.values.every(({ before, after }) =>
+    normalizedCode(before, syntax) === normalizedCode(after, syntax))
+}
+
+function commentSyntax(filename) {
+  const normalized = normalizeRepositoryPath(filename)
+  const basename = normalized.slice(normalized.lastIndexOf('/') + 1).toLowerCase()
+  const extension = basename.includes('.') ? basename.slice(basename.lastIndexOf('.') + 1) : ''
+  const line = []
+  const block = []
+  if (C_STYLE_EXTENSIONS.has(extension)) {
+    line.push('//')
+    block.push(['/*', '*/'])
+  }
+  if (BLOCK_COMMENT_EXTENSIONS.has(extension)) block.push(['/*', '*/'])
+  if (HASH_COMMENT_EXTENSIONS.has(extension) || basename === 'dockerfile' || basename.startsWith('dockerfile.')
+    || basename === 'makefile' || basename.startsWith('makefile.')) line.push('#')
+  if (extension === 'sql') {
+    line.push('--')
+    block.push(['/*', '*/'])
+  }
+  if (HTML_COMMENT_EXTENSIONS.has(extension)) block.push(['<!--', '-->'])
+  return line.length === 0 && block.length === 0 ? undefined : { line, block }
+}
+
+function parsePatchHunks(patch) {
+  const values = []
+  let current
+  let additions = 0
+  let deletions = 0
+  for (const line of patch.split('\n')) {
+    if (line.startsWith('@@')) {
+      current = { before: [], after: [] }
+      values.push(current)
+      continue
+    }
+    if (current === undefined || line.startsWith('\\ No newline at end of file')) continue
+    const prefix = line[0]
+    const content = line.slice(1)
+    if (prefix === ' ') {
+      current.before.push(content)
+      current.after.push(content)
+    } else if (prefix === '-') {
+      current.before.push(content)
+      deletions++
+    } else if (prefix === '+') {
+      current.after.push(content)
+      additions++
+    }
+  }
+  return values.length === 0 ? undefined : { values, additions, deletions }
+}
+
+function normalizedCode(lines, syntax) {
+  return stripComments(lines.join('\n'), syntax)
+    .split('\n')
+    .map(line => line.trimEnd())
+    .filter(line => line.trim().length > 0)
+    .join('\n')
+}
+
+function stripComments(source, syntax) {
+  let result = ''
+  let quote
+  let blockEnd
+  for (let index = 0; index < source.length;) {
+    if (blockEnd !== undefined) {
+      if (source.startsWith(blockEnd, index)) {
+        index += blockEnd.length
+        blockEnd = undefined
+      } else {
+        index++
+      }
+      continue
+    }
+    const character = source[index]
+    if (quote !== undefined) {
+      result += character
+      index++
+      if (character === '\\' && index < source.length) {
+        result += source[index]
+        index++
+      } else if (character === quote) {
+        quote = undefined
+      }
+      continue
+    }
+    if (character === '\'' || character === '"' || character === '`') {
+      quote = character
+      result += character
+      index++
+      continue
+    }
+    const block = syntax.block.find(([start]) => source.startsWith(start, index))
+    if (block !== undefined) {
+      index += block[0].length
+      blockEnd = block[1]
+      continue
+    }
+    const line = syntax.line.find(marker => source.startsWith(marker, index))
+    const lineStart = index === 0 || source[index - 1] === '\n'
+    const hashStartsComment = line !== '#' || lineStart || /\s/u.test(source[index - 1] ?? '')
+    if (line !== undefined && hashStartsComment && !(line === '#' && lineStart && source[index + 1] === '!')) {
+      const newline = source.indexOf('\n', index + line.length)
+      if (newline === -1) break
+      result += '\n'
+      index = newline + 1
+      continue
+    }
+    result += character
+    index++
+  }
+  return result
+}
+
+/**
+ * Expand changed-file records into reviewable, test, documentation, and comment-only paths.
  * @param {unknown[]} files Pull-request file records from GitHub.
- * @returns {{changedCodeFiles: string[], excludedTestFiles: string[]}} Classified paths.
+ * @returns {{changedCodeFiles: string[], excludedTestFiles: string[], excludedDocumentationFiles: string[], excludedCommentOnlyFiles: string[]}} Classified paths.
  */
 export function classifyChangedFiles(files) {
   const changedCodeFiles = new Set()
   const excludedTestFiles = new Set()
+  const excludedDocumentationFiles = new Set()
+  const excludedCommentOnlyFiles = new Set()
   for (const entry of files) {
     if (!isRecord(entry)) throw new Error('changed-file response contains a non-object entry')
     const paths = [normalizeRepositoryPath(entry.filename)]
+    const commentOnly = isCommentOnlyChange(entry)
     if (entry.previous_filename !== undefined) {
       paths.unshift(normalizeRepositoryPath(entry.previous_filename))
     }
     for (const file of paths) {
       if (isTestPath(file)) excludedTestFiles.add(file)
+      else if (isDocumentationPath(file)) excludedDocumentationFiles.add(file)
+      else if (commentOnly) excludedCommentOnlyFiles.add(file)
       else changedCodeFiles.add(file)
     }
   }
   return {
     changedCodeFiles: [...changedCodeFiles].sort(),
     excludedTestFiles: [...excludedTestFiles].sort(),
+    excludedDocumentationFiles: [...excludedDocumentationFiles].sort(),
+    excludedCommentOnlyFiles: [...excludedCommentOnlyFiles].sort(),
   }
 }
 
@@ -190,23 +347,81 @@ export async function listPullRequestFiles(api, repository, pullNumber, expected
 }
 
 /**
- * Print changed paths, route owners, and request every missing eligible reviewer.
+ * Fetch the pull request timeline used to identify workflow-authored review requests.
+ * @param {(path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>} api GitHub API caller.
+ * @param {string} repository Owner/name repository identifier.
+ * @param {number} pullNumber Pull-request number.
+ * @returns {Promise<unknown[]>} Complete timeline event list within the supported limit.
+ */
+export async function listPullRequestTimeline(api, repository, pullNumber) {
+  const events = []
+  for (let page = 1; ; page++) {
+    const response = await api(`/repos/${repository}/issues/${pullNumber}/timeline?per_page=${PAGE_SIZE}&page=${page}`)
+    if (!Array.isArray(response)) throw new Error('pull-request timeline response is not an array')
+    events.push(...response)
+    if (response.length < PAGE_SIZE) return events
+    if (events.length >= MAX_TIMELINE_EVENTS) {
+      throw new Error(`pull-request timeline exceeds ${MAX_TIMELINE_EVENTS} events`)
+    }
+  }
+}
+
+/** Return current requested reviewers whose latest request came from this workflow identity. */
+function workflowRequestedReviewers(events, requestedReviewers) {
+  const requested = new Map(requestedReviewers.map(login => [login.toLowerCase(), login]))
+  const latestRequester = new Map()
+  for (const event of events) {
+    if (!isRecord(event) || event.event !== 'review_requested') continue
+    if (!isRecord(event.requested_reviewer) || typeof event.requested_reviewer.login !== 'string') continue
+    const key = event.requested_reviewer.login.toLowerCase()
+    if (!requested.has(key)) continue
+    if (!isRecord(event.review_requester) || typeof event.review_requester.login !== 'string') {
+      throw new Error('review-request timeline event has no requester login')
+    }
+    latestRequester.set(key, event.review_requester.login.toLowerCase())
+  }
+  return [...requested]
+    .filter(([key]) => latestRequester.get(key) === WORKFLOW_REVIEW_REQUESTER)
+    .map(([, login]) => login)
+}
+
+/** Extract and validate individual logins from GitHub's requested-reviewer response. */
+function requestedReviewerLogins(response) {
+  if (!isRecord(response) || !Array.isArray(response.users)) {
+    throw new Error('requested-reviewers response has no users array')
+  }
+  return response.users.map((user) => {
+    if (!isRecord(user) || typeof user.login !== 'string') {
+      throw new Error('requested-reviewers response contains an invalid user')
+    }
+    return user.login
+  })
+}
+
+/**
+ * Print changed paths, request missing owners on reviewable pull requests, and
+ * cancel workflow-authored requests on drafts.
  * @param {{event: unknown, ownershipSource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, write?: (line: string) => void}} options Runtime inputs.
- * @returns {Promise<{changedCodeFiles: string[], excludedTestFiles: string[], requestedReviewers: string[]}>} Applied routing result.
+ * @returns {Promise<{changedCodeFiles: string[], excludedTestFiles: string[], excludedDocumentationFiles: string[], excludedCommentOnlyFiles: string[], requestedReviewers: string[], cancelledReviewers: string[]}>} Applied routing result.
  */
 export async function requestReviews({ event, ownershipSource, api, write = line => process.stdout.write(`${line}\n`) }) {
   const pull = pullRequestFromEvent(event)
   write('This is by automated Angry Turtle Cyborg, not a human')
-  if (pull.draft) {
-    write('Draft pull request; reviewer routing is deferred until ready_for_review.')
-    return { changedCodeFiles: [], excludedTestFiles: [], requestedReviewers: [] }
-  }
-
   const files = await listPullRequestFiles(api, pull.repository, pull.number, pull.changedFileCount)
   const classified = classifyChangedFiles(files)
   const plan = planReviewers(parseOwnership(ownershipSource), classified.changedCodeFiles)
   writeList(write, 'Changed code files', classified.changedCodeFiles.map(file => JSON.stringify(file)))
   writeList(write, 'Excluded test files', classified.excludedTestFiles.map(file => JSON.stringify(file)))
+  writeList(
+    write,
+    'Excluded documentation files',
+    classified.excludedDocumentationFiles.map(file => JSON.stringify(file)),
+  )
+  writeList(
+    write,
+    'Excluded comment-only files',
+    classified.excludedCommentOnlyFiles.map(file => JSON.stringify(file)),
+  )
   writeList(
     write,
     'Owners by changed file',
@@ -214,30 +429,43 @@ export async function requestReviews({ event, ownershipSource, api, write = line
   )
 
   const candidates = plan.reviewers.filter(login => login.toLowerCase() !== pull.author.toLowerCase())
+  if (pull.draft) {
+    const existing = await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`)
+    const requestedReviewers = requestedReviewerLogins(existing)
+    const reviewers = requestedReviewers.length === 0
+      ? []
+      : workflowRequestedReviewers(
+          await listPullRequestTimeline(api, pull.repository, pull.number),
+          requestedReviewers,
+        )
+    writeList(write, 'Review requests to cancel', reviewers.map(login => `@${login}`))
+    if (reviewers.length === 0) return { ...classified, requestedReviewers: [], cancelledReviewers: [] }
+
+    await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, {
+      method: 'DELETE',
+      body: { reviewers },
+    })
+    const requestLabel = reviewers.length === 1 ? 'request' : 'requests'
+    write(`Cancelled review ${requestLabel} for ${reviewers.map(login => `@${login}`).join(' ')}.`)
+    return { ...classified, requestedReviewers: [], cancelledReviewers: reviewers }
+  }
+
   if (candidates.length === 0) {
     writeList(write, 'Reviewers to request', [])
-    return { ...classified, requestedReviewers: [] }
+    return { ...classified, requestedReviewers: [], cancelledReviewers: [] }
   }
   const existing = await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`)
-  if (!isRecord(existing) || !Array.isArray(existing.users)) {
-    throw new Error('requested-reviewers response has no users array')
-  }
-  const alreadyRequested = new Set(existing.users.map((user) => {
-    if (!isRecord(user) || typeof user.login !== 'string') {
-      throw new Error('requested-reviewers response contains an invalid user')
-    }
-    return user.login.toLowerCase()
-  }))
+  const alreadyRequested = new Set(requestedReviewerLogins(existing).map(login => login.toLowerCase()))
   const reviewers = candidates.filter(login => !alreadyRequested.has(login.toLowerCase()))
   writeList(write, 'Reviewers to request', reviewers.map(login => `@${login}`))
-  if (reviewers.length === 0) return { ...classified, requestedReviewers: [] }
+  if (reviewers.length === 0) return { ...classified, requestedReviewers: [], cancelledReviewers: [] }
 
   await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, {
     method: 'POST',
     body: { reviewers },
   })
   write(`Requested ${reviewers.map(login => `@${login}`).join(' ')}.`)
-  return { ...classified, requestedReviewers: reviewers }
+  return { ...classified, requestedReviewers: reviewers, cancelledReviewers: [] }
 }
 
 function pullRequestFromEvent(event) {

+ 171 - 16
.github/review-ownership/request-review.test.mjs

@@ -1,13 +1,16 @@
 import assert from 'node:assert/strict'
 import { execFileSync } from 'node:child_process'
-import { readFileSync } from 'node:fs'
+import { existsSync, readFileSync } from 'node:fs'
 import test from 'node:test'
 
 import {
   classifyChangedFiles,
   createGitHubApi,
+  isCommentOnlyChange,
+  isDocumentationPath,
   isTestPath,
   listPullRequestFiles,
+  listPullRequestTimeline,
   normalizeRepositoryPath,
   parseOwnership,
   planReviewers,
@@ -54,11 +57,13 @@ test('loads the repository ownership policy without test-only directory rules',
 
 test('keeps turtle below one third of the eligible owned codebase', () => {
   const rules = parseOwnership(ownershipSource)
-  const trackedFiles = execFileSync('git', ['ls-files', '-z'], { encoding: 'utf8' }).split('\0').filter(Boolean)
+  const trackedFiles = execFileSync('git', ['ls-files', '-z'], { encoding: 'utf8' })
+    .split('\0')
+    .filter(file => file && existsSync(file))
   let ownedLines = 0
   let turtleLines = 0
   for (const file of trackedFiles) {
-    if (isTestPath(file)) continue
+    if (isTestPath(file) || isDocumentationPath(file)) continue
     const owners = planReviewers(rules, [file]).matches[0]?.owners ?? []
     if (owners.length === 0) continue
     const content = readFileSync(file)
@@ -132,6 +137,76 @@ test('does not confuse production names with tests', () => {
   }
 })
 
+test('excludes Markdown and YAML documentation extensions', () => {
+  for (const file of [
+    'README.md',
+    'docs/architecture.MD',
+    'packages/subagent/subagent/guide.yaml',
+    'profiles/example.YAML',
+  ]) {
+    assert.equal(isDocumentationPath(file), true, file)
+  }
+  for (const file of [
+    '.github/workflows/request-review.yml',
+    'packages/subagent/subagent/src/index.ts',
+    'website/docs.ts',
+  ]) {
+    assert.equal(isDocumentationPath(file), false, file)
+  }
+})
+
+test('detects comment-only changes only from complete supported patches', () => {
+  for (const file of [
+    {
+      filename: 'packages/core/agent/src/index.ts',
+      status: 'modified', additions: 1, deletions: 1,
+      patch: '@@ -1,2 +1,2 @@\n-// old note\n+// new note\n const value = "https://example.com"',
+    },
+    {
+      filename: 'python/sdk/src/client.py',
+      status: 'modified', additions: 1, deletions: 1,
+      patch: '@@ -1 +1 @@\n-value = 1  # old note\n+value = 1  # new note',
+    },
+    {
+      filename: 'native/landlock-run/src/main.rs',
+      status: 'modified', additions: 1, deletions: 1,
+      patch: '@@ -1 +1 @@\n-let value = 1; /* old note */\n+let value = 1; /* new note */',
+    },
+  ]) {
+    assert.equal(isCommentOnlyChange(file), true, file.filename)
+  }
+
+  for (const file of [
+    {
+      filename: 'packages/core/agent/src/index.ts',
+      status: 'modified', additions: 1, deletions: 1,
+      patch: '@@ -1 +1 @@\n-const value = 1 // note\n+const value = 2 // note',
+    },
+    {
+      filename: 'packages/core/agent/src/index.ts',
+      status: 'modified', additions: 2, deletions: 1,
+      patch: '@@ -1 +1 @@\n-// old note\n+// new note',
+    },
+    {
+      filename: 'packages/core/agent/src/data.json',
+      status: 'modified', additions: 1, deletions: 1,
+      patch: '@@ -1 +1 @@\n-{"value":1}\n+{"value":2}',
+    },
+    {
+      filename: 'native/landlock-run/src/main.rs',
+      status: 'modified', additions: 1, deletions: 1,
+      patch: '@@ -1 +1 @@\n-let value = r#"https://old.example"#;\n+let value = r#"https://new.example"#;',
+    },
+    {
+      filename: 'packages/core/agent/src/index.ts',
+      status: 'renamed', additions: 1, deletions: 1,
+      patch: '@@ -1 +1 @@\n-// old note\n+// new note',
+    },
+  ]) {
+    assert.equal(isCommentOnlyChange(file), false, file.filename)
+  }
+})
+
 test('normalizes separators and rejects paths that are not repository-relative', () => {
   assert.equal(normalizeRepositoryPath('./packages\\core\\agent\\src\\index.ts'), 'packages/core/agent/src/index.ts')
   for (const file of ['', '/absolute.ts', '../escape.ts', 'packages//empty.ts', 'packages/./same.ts']) {
@@ -150,6 +225,12 @@ test('classifies both sides of a rename independently', () => {
         filename: 'packages/client/store/src/restored.ts',
         previous_filename: 'packages/client/store/tests/restored.spec.ts',
       },
+      { filename: 'packages/core/agent/README.md' },
+      {
+        filename: 'packages/core/agent/src/commented.ts',
+        status: 'modified', additions: 1, deletions: 1,
+        patch: '@@ -1 +1 @@\n-// old note\n+// new note',
+      },
     ]),
     {
       changedCodeFiles: [
@@ -160,6 +241,8 @@ test('classifies both sides of a rename independently', () => {
         'packages/client/store/tests/restored.spec.ts',
         'packages/core/agent/tests/moved.spec.ts',
       ],
+      excludedDocumentationFiles: ['packages/core/agent/README.md'],
+      excludedCommentOnlyFiles: ['packages/core/agent/src/commented.ts'],
     },
   )
 })
@@ -210,6 +293,18 @@ test('fails closed when GitHub cannot provide the complete file list', async ()
   )
 })
 
+test('fails closed when the review-request timeline exceeds its limit', async () => {
+  let calls = 0
+  await assert.rejects(
+    listPullRequestTimeline(async () => {
+      calls++
+      return Array.from({ length: 100 }, () => ({ event: 'commented' }))
+    }, 'owner/repo', 42),
+    /exceeds 3000 events/u,
+  )
+  assert.equal(calls, 30)
+})
+
 test('prints changed code files before requesting missing owners', async () => {
   const trace = []
   const files = [
@@ -239,14 +334,16 @@ test('prints changed code files before requesting missing owners', async () => {
 
   assert.deepEqual(result, {
     changedCodeFiles: [
-      'AGENTS.md',
       'packages/client/store/src/index.ts',
       'packages/core/agent/src/index.ts',
       'packages/preset/agent-presets/src/index.ts',
       'packages/subagent/subagent/src/index.ts',
     ],
     excludedTestFiles: ['packages/core/agent/tests/index.spec.ts'],
+    excludedDocumentationFiles: ['AGENTS.md'],
+    excludedCommentOnlyFiles: [],
     requestedReviewers: ['Dudu-0223', 'LegGasai', 'mektpoy', 'tianyicui'],
+    cancelledReviewers: [],
   })
   assert.equal(trace[0].type, 'log')
   assert.equal(trace[0].line, 'This is by automated Angry Turtle Cyborg, not a human')
@@ -263,12 +360,19 @@ test('prints changed code files before requesting missing owners', async () => {
   })
 })
 
-test('does not request reviewers for a test-only change', async () => {
+test('does not request reviewers for test, documentation, or comment-only changes', async () => {
   const calls = []
   const output = []
   const files = [
     { filename: 'apps/web/tests/chat.e2e.ts' },
     { filename: 'packages/core/agent/tests/agent.spec.ts' },
+    { filename: 'packages/core/agent/README.md' },
+    { filename: 'packages/core/agent/examples.yaml' },
+    {
+      filename: 'packages/core/agent/src/index.ts',
+      status: 'modified', additions: 1, deletions: 1,
+      patch: '@@ -1 +1 @@\n-// old note\n+// new note',
+    },
   ]
   const result = await requestReviews({
     event: pullRequestEvent({ changedFiles: files.length }),
@@ -281,8 +385,11 @@ test('does not request reviewers for a test-only change', async () => {
   })
   assert.deepEqual(result, {
     changedCodeFiles: [],
-    excludedTestFiles: files.map(file => file.filename),
+    excludedTestFiles: files.slice(0, 2).map(file => file.filename),
+    excludedDocumentationFiles: files.slice(2, 4).map(file => file.filename),
+    excludedCommentOnlyFiles: ['packages/core/agent/src/index.ts'],
     requestedReviewers: [],
+    cancelledReviewers: [],
   })
   assert.equal(calls.length, 1)
   assert.deepEqual(output.slice(0, 4), [
@@ -293,19 +400,67 @@ test('does not request reviewers for a test-only change', async () => {
   ])
 })
 
-test('defers draft pull requests without reading changed files', async () => {
-  const output = []
+test('cancels workflow-authored review requests on draft pull requests', async () => {
+  const trace = []
+  const files = [
+    { filename: 'packages/subagent/subagent/src/index.ts' },
+    { filename: 'packages/subagent/subagent/tests/index.spec.ts' },
+    { filename: 'packages/subagent/subagent/README.md' },
+  ]
   const result = await requestReviews({
-    event: pullRequestEvent({ draft: true }),
+    event: pullRequestEvent({ draft: true, changedFiles: files.length }),
     ownershipSource,
-    api: async () => assert.fail('draft routing must not call GitHub'),
-    write: line => output.push(line),
+    api: async (path, options = {}) => {
+      trace.push({ type: 'api', path, options })
+      if (path.endsWith('/files?per_page=100&page=1')) return files
+      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
+        return { users: [{ login: 'Dudu-0223' }, { login: 'manual-reviewer' }], teams: [] }
+      }
+      if (path.endsWith('/timeline?per_page=100&page=1')) {
+        return [
+          {
+            event: 'review_requested',
+            requested_reviewer: { login: 'Dudu-0223' },
+            review_requester: { login: 'maintainer' },
+          },
+          {
+            event: 'review_requested',
+            requested_reviewer: { login: 'Dudu-0223' },
+            review_requester: { login: 'github-actions[bot]' },
+          },
+          {
+            event: 'review_requested',
+            requested_reviewer: { login: 'manual-reviewer' },
+            review_requester: { login: 'github-actions[bot]' },
+          },
+          {
+            event: 'review_requested',
+            requested_reviewer: { login: 'manual-reviewer' },
+            review_requester: { login: 'maintainer' },
+          },
+        ]
+      }
+      if (path.endsWith('/requested_reviewers') && options.method === 'DELETE') return {}
+      throw new Error(`unexpected API path ${path}`)
+    },
+    write: line => trace.push({ type: 'log', line }),
   })
-  assert.deepEqual(result, { changedCodeFiles: [], excludedTestFiles: [], requestedReviewers: [] })
-  assert.deepEqual(output, [
-    'This is by automated Angry Turtle Cyborg, not a human',
-    'Draft pull request; reviewer routing is deferred until ready_for_review.',
-  ])
+  assert.deepEqual(result, {
+    changedCodeFiles: ['packages/subagent/subagent/src/index.ts'],
+    excludedTestFiles: ['packages/subagent/subagent/tests/index.spec.ts'],
+    excludedDocumentationFiles: ['packages/subagent/subagent/README.md'],
+    excludedCommentOnlyFiles: [],
+    requestedReviewers: [],
+    cancelledReviewers: ['Dudu-0223'],
+  })
+  const remove = trace.find(item => item.type === 'api' && item.options.method === 'DELETE')
+  assert.deepEqual(remove, {
+    type: 'api',
+    path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
+    options: { method: 'DELETE', body: { reviewers: ['Dudu-0223'] } },
+  })
+  assert.equal(trace.some(item => item.type === 'log' && item.line === '- @manual-reviewer'), false)
+  assert.equal(trace.at(-1).line, 'Cancelled review request for @Dudu-0223.')
 })
 
 test('sends authenticated JSON and escapes an API error body', async () => {

+ 1 - 2
.github/workflows/request-review.yml

@@ -2,7 +2,7 @@ name: request-review
 
 on:
   pull_request_target:
-    types: [opened, synchronize, reopened, ready_for_review]
+    types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
 
 permissions:
   contents: read
@@ -15,7 +15,6 @@ concurrency:
 jobs:
   request-review:
     name: request-review
-    if: ${{ !github.event.pull_request.draft }}
     runs-on: ubuntu-latest
     timeout-minutes: 5
     steps:

+ 0 - 14
packages/subagent/subagent/src/request-review-smoke.ts

@@ -1,14 +0,0 @@
-/**
- * Temporary production-code probe for the request-review workflow.
- *
- * @module @deepseek-ai/dsh-subagent
- */
-
-/**
- * Decide whether a smoke-probe value enables review routing.
- * @param value - Candidate smoke-probe value.
- * @returns Whether the normalized value is `enabled`.
- */
-export function requestReviewSmokeEnabled(value: string | undefined): boolean {
-  return value?.trim().toLowerCase() === 'enabled'
-}

+ 0 - 13
packages/subagent/subagent/tests/request-review-smoke.spec.ts

@@ -1,13 +0,0 @@
-import { describe, expect, it } from 'vitest'
-import { requestReviewSmokeEnabled } from '../src/request-review-smoke.ts'
-
-describe('requestReviewSmokeEnabled', () => {
-  it.each([
-    ['enabled', true],
-    [' ENABLED ', true],
-    ['disabled', false],
-    [undefined, false],
-  ] as const)('normalizes %s to %s', (value, expected) => {
-    expect(requestReviewSmokeEnabled(value)).toBe(expected)
-  })
-})

+ 3 - 3
scripts/ci-workflow.spec.ts

@@ -775,7 +775,7 @@ describe('Python release workflows', () => {
 })
 
 describe('Request review workflow', () => {
-  it('runs trusted routing on reviewable pull request updates', () => {
+  it('runs trusted routing on pull request review-state updates', () => {
     const workflow = loadWorkflow('.github/workflows/request-review.yml')
     const event = workflowEvent(workflow, 'pull_request_target')
     const job = workflowJob(workflow, 'request-review')
@@ -787,7 +787,7 @@ describe('Request review workflow', () => {
 
     expect(workflow.name).toBe('request-review')
     expect(Object.keys(workflow.on)).toEqual(['pull_request_target'])
-    expect(event.types).toEqual(['opened', 'synchronize', 'reopened', 'ready_for_review'])
+    expect(event.types).toEqual(['opened', 'synchronize', 'reopened', 'ready_for_review', 'converted_to_draft'])
     expect(workflow.permissions).toEqual({ contents: 'read', 'pull-requests': 'write' })
     expect(workflow.concurrency).toEqual({
       group: 'request-review-${{ github.event.pull_request.number }}',
@@ -795,10 +795,10 @@ describe('Request review workflow', () => {
     })
     expect(job).toMatchObject({
       name: 'request-review',
-      if: '${{ !github.event.pull_request.draft }}',
       'runs-on': 'ubuntu-latest',
       'timeout-minutes': 5,
     })
+    expect(job).not.toHaveProperty('if')
     expect(checkout).toMatchObject({
       uses: 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1',
       with: {