Răsfoiți Sursa

Merge pull request #3764 from deepseek-harness/turtle/request-review-live-smoke

feat: rank review owners by changed LOC
Turtle 5 zile în urmă
părinte
comite
1d6b898ef6

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

@@ -1,6 +0,0 @@
-# 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-trusted-changed-file-review-routing.md
-2026-09-08-trusted-changed-file-review-routing.md: 9f59f336fb1f8d2fdd1e13f6751258a47518b4dc
-2026-09-08-trusted-changed-file-review-routing.zh.md: e845c79051858c11053597516f486cf29fa2805a

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

@@ -2,8 +2,6 @@
 
 Status: implemented
 
-English | [中文](2026-09-08-trusted-changed-file-review-routing.zh.md)
-
 ## Problem
 
 GitHub's native CODEOWNERS behavior requests reviewers whenever a matching path changes. It cannot apply this repository's distinction between reviewable implementation or documentation files and test-only evidence. A native CODEOWNERS file also makes GitHub, rather than an inspected repository program, responsible for the request decision.
@@ -22,11 +20,13 @@ The scanner fetches every changed-file record before deciding. It fails if the p
 
 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 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 removes the pull-request author and users who are already requested from the login-sorted union of matched individual owners, then fills at most two current individual review-request slots. Existing individual requests consume slots even when they do not match the ownership map. Login order deterministically selects candidates when the available slots cannot cover the remaining union. The workflow does not remove requests from a non-draft pull request. 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.
+The workflow prints the changed code paths, each exclusion class, per-file owner matches and changed LOC, aggregate owner relevance, current individual requests, the available counted slot after planned cancellations, and final reviewer actions before any review-request mutation. For a non-draft pull request, it removes the pull-request author and users who remain requested from the matched individual owners, then keeps at most one current individual review request other than `@turtle1999`. An existing request for `@turtle1999` does not consume that slot, but each workflow run adds at most one reviewer. An existing non-turtle request leaves no slot, so the workflow does not add anyone, including `@turtle1999`. Existing individual requests consume the slot even when they do not match the ownership map. An owner's relevance is the sum of GitHub-reported additions and deletions for each reviewable changed-file record whose current or previous path matches that owner. Each record contributes once per owner, including when both paths of a rename match the same owner. Higher changed LOC selects candidates first when the available slot cannot cover the remaining owners; login order resolves equal scores.
+
+When current review requests exist, the workflow reads the complete review-request timeline before mutation. A current reviewer is workflow-authored only when its latest matching `review_requested` event identifies `github-actions[bot]` as `review_requester`; a request without an attributable event is preserved. A non-draft run cancels workflow-authored reviewers that no longer match the current candidates and excess workflow-authored non-turtle reviewers above the counted limit; current relevance order selects which matching workflow reviewer remains. Planned cancellations release capacity before the workflow selects a new reviewer. A draft run cancels every current workflow-authored request. Requests made by people remain unchanged. An attributable event with invalid provenance and timelines above 3,000 events fail before mutation.
 
 ## Verification
 
-[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`.
+[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, changed-LOC aggregation and ranking, complete pagination, the 3,000-file limits, log-before-mutation ordering, author and existing-reviewer filtering, non-draft reconciliation, 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,16 @@ The workflow prints the changed code paths, each exclusion class, per-file owner
 
 **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.
 
+**Select capped candidates by login order.** Login order is stable but ignores how much reviewable code changed under each owner's directories. Changed LOC makes the limited requests follow the pull request's strongest ownership relevance while retaining login order for ties.
+
+**Cancel every reviewer that no longer matches.** A person may request a reviewer for reasons outside the ownership map. Only requests attributed to the workflow identity are safe for automated reconciliation.
+
 **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 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.
+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, rule and changed-file updates remove obsolete workflow-authored requests on the next run, 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 therefore produces at most two requests for each changed module. 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.
+The workflow requests at most one reviewer per run, keeps no more than one current individual reviewer other than `@turtle1999`, and prefers owners whose matched reviewable files carry more changed LOC. An existing `@turtle1999` request leaves the counted slot available; an existing non-turtle request prevents every additional request. Shared ownership gives each owner the same file-level relevance without counting one renamed file twice for the same owner. 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 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.

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

@@ -1,47 +0,0 @@
-# Agent Note: 基于受信任的变更文件策略路由评审
-
-Status: implemented
-
-[English](2026-09-08-trusted-changed-file-review-routing.md) | 中文
-
-## 问题
-
-只要匹配路径发生变更,GitHub 原生 CODEOWNERS 就会请求评审者。它无法应用本仓库对需评审的实现或文档文件与纯测试证据的区分。使用原生 CODEOWNERS 文件还会让 GitHub 负责请求决策,而不是由可检查的仓库程序负责。
-
-评审路由需要可观测的变更文件输入、显式 owner 规则、完整的测试排除规则,以及对 fork PR 仍然安全且具备写权限的 workflow。
-
-## 决策
-
-仓库在 GitHub 原生 CODEOWNERS 路径之外的 [`.github/review-ownership/CODEOWNERS`](../../../../.github/review-ownership/CODEOWNERS) 中保存兼容 CODEOWNERS 格式的映射。该映射只接受显式绝对目录模式,每条模式配置一至两名 GitHub 个人用户。通配符、隐藏目录模式、团队、超过两名 owner、重复模式和重复 owner 都会被拒绝。靠后的匹配模式会替换靠前的匹配结果。
-
-策略测试会统计匹配所有权规则的目录中的非测试跟踪文件行数。如果 `@turtle1999` 拥有的有效代码库超过三分之一,测试就会拒绝该映射。
-
-[`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` 和门禁实现等测试基础设施仍需评审,因为它们会改变仓库证据的生成方式。[纯注释路由决策](2026-09-08-comment-only-review-routing.zh.md)记录额外的文档和注释排除规则。
-
-Workflow 会在发出任何评审请求变更之前,依次打印变更代码路径、每类排除项、逐文件 owner 匹配结果和最终评审操作。对于非草稿 PR,它会从按登录名排序并合并的个人 owner 中排除 PR 作者和已经收到评审请求的用户,然后填充最多两个当前个人评审请求名额。现有个人请求即使不匹配所有权映射,也会占用名额。当可用名额无法覆盖剩余候选集合时,登录名顺序会确定性地选择候选人。Workflow 不会从非草稿 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` 中包含这两组测试。
-
-## 考虑过的替代方案
-
-**使用原生 CODEOWNERS。** 原生路由无法忽略纯测试变更,也无法在请求评审者之前提供由仓库控制的决策日志。
-
-**在 `pull_request` 下运行并检出 PR head。** Fork workflow 无法获得具备写权限的 token,而向不受信任 head 中的代码授予写权限 token 并不安全。
-
-**在 `pull_request_target` 下执行 PR 中的扫描器或 owner 映射。** 这会让不受信任的 PR 选择自己的写权限行为或 owner。
-
-**根据 patch 或语言解析器推断任意语义源码变更。** GitHub 可能省略或截断 patch,而且仓库包含多种语言。扫描器不会尝试证明两个程序行为相同。后续的[纯注释路由决策](2026-09-08-comment-only-review-routing.zh.md)只在变更行计数能够证明 GitHub 提供了完整 patch 时执行有限的词法比较。
-
-## 后果
-
-评审请求修改可以根据受信任的策略、workflow 日志中打印的文件分类,以及 PR 时间线中的请求来源复现。被排除的变更不会请求 owner,草稿 PR 不会保留 workflow 发出的请求。所有权变更只有合并后才会生效,因此修改策略的 PR 无法对自身应用其中不受信任的策略。
-
-Workflow 会请求所有匹配的 owner,不会随机选择一人。因此,共享所有权对每个变更模块最多产生两个请求。GitHub 使用 `GITHUB_TOKEN` 生成的评审请求事件可能不会启动依赖递归触发事件的其他 workflow;这些 workflow 不得把此请求作为唯一触发条件。
-
-已分配目录下不符合任何显式排除规则的变更仍符合请求条件。未匹配的路径会被记录,但不会请求任何人。超过文件或时间线 API 上限的 PR 会失败,并且不会执行不完整的评审者修改。

+ 0 - 1
.github/review-ownership/CODEOWNERS

@@ -6,7 +6,6 @@
 /native/ @mektpoy
 /patches/ @mektpoy
 /python/ @LegGasai
-/scripts/ @turtle1999
 /vendor/ @turtle1999
 /website/ @LegGasai
 /packages/acp/ @mektpoy

+ 0 - 6
.github/review-ownership/README.i18n.yaml

@@ -1,6 +0,0 @@
-# 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 .github/review-ownership/README.md
-README.md: 4c412a7c47f114e0aa6aa9a3048fd7f833f0b0e3
-README.zh.md: 1db7d1b56d4927a83c502557f393cb6518dc09f1

+ 5 - 5
.github/review-ownership/README.md

@@ -1,7 +1,5 @@
 # Automated review requests
 
-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 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.
@@ -20,9 +18,11 @@ The [`request-review` workflow](../workflows/request-review.yml) reads the CODEO
 
 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.
 
-For a non-draft pull request, the workflow requests missing matched owners while keeping the total number of current individual review requests at two or fewer. Existing individual requests consume those slots, including requests made by people outside the ownership map. When more candidates remain than available slots, login order selects the reviewers deterministically. The workflow does not remove requests from a non-draft pull request. 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.
+For a non-draft pull request, the workflow keeps at most one current individual review request other than `@turtle1999`; an existing request for `@turtle1999` does not consume that slot. Each run adds at most one reviewer. An existing non-turtle request leaves no slot, so the workflow does not add anyone, including `@turtle1999`. Existing individual requests consume the slot even when made by people outside the ownership map. When more candidates remain than the available counted slot can cover, the workflow ranks them by the total GitHub-reported additions plus deletions in reviewable changed-file records that match each owner. A rename contributes its changed LOC once to an owner even when both paths match that owner. Higher changed LOC ranks first, and login order resolves ties.
+
+On every run with current review requests, the workflow reads the pull-request timeline. A current reviewer is workflow-authored only when the latest matching `review_requested` event names `github-actions[bot]` as `review_requester`; a request without an attributable event is preserved. On a non-draft pull request, the workflow cancels workflow-authored reviewers that no longer match the current candidates and excess workflow-authored non-turtle reviewers above the counted limit. Current relevance order decides which matching workflow reviewer remains when the limit shrinks. It then fills any slot left by the planned cancellations. On a draft, it cancels every current workflow-authored request. Requests made by people remain unchanged in both states. An attributable event with invalid provenance fails before mutation, and the workflow also fails without cancellation when the timeline exceeds 3,000 events.
 
-The ownership map accepts explicit absolute directory patterns and one or two individual GitHub users per pattern. It rejects wildcards, hidden-directory patterns, teams, more than two owners, 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 ownership map accepts explicit absolute directory patterns and one or two individual GitHub users per pattern. It rejects wildcards, hidden-directory patterns, teams, more than two owners, 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 LOC; the aggregate owner relevance ranking; current individual requests and the available counted slot after planned cancellations; 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 remain 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.
 
@@ -50,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, 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.
+Run `pnpm run test:request-review` for ownership parsing, file classification, complete-patch checks, comment parsing, changed-LOC ranking, pagination, logging order, non-draft reconciliation, draft cancellation, reviewer provenance, 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>
 

+ 0 - 59
.github/review-ownership/README.zh.md

@@ -1,59 +0,0 @@
-# 自动请求代码评审
-
-[English](README.md) | 中文
-
-## 概要
-
-[`request-review` workflow](../workflows/request-review.yml) 从受信任的默认分支读取兼容 CODEOWNERS 格式的[所有权映射](CODEOWNERS)。它会对变更文件分类,为可评审代码请求尚未加入的 owner,并在 PR 转为草稿时取消自己尚未完成的请求。所有权映射不在 GitHub 原生 CODEOWNERS 路径中,因此 GitHub 不会直接应用它。
-
-## 目录
-
-- [路由](#routing)
-- [评审排除规则](#review-exclusions)
-- [安全性](#security)
-- [验证](#verification)
-- [开发说明](#dev-note)
-
-<a id="routing"></a>
-
-## 路由
-
-PR 在创建、同步、重新打开、标记为可评审或转为草稿时运行该 workflow。扫描器获取完整的 PR 文件列表,分别检查重命名前后的路径;如果只能取得部分列表,则停止执行,不发出评审请求。GitHub 对此 API 最多公开 3,000 个文件。
-
-对于非草稿 PR,workflow 会请求尚未加入的匹配 owner,同时确保当前个人评审请求总数不超过两个。现有个人请求会占用名额,包括由人员向所有权映射之外用户发出的请求。当剩余候选人数超过可用名额时,workflow 会按登录名顺序确定评审者。Workflow 不会从非草稿 PR 移除请求。对于草稿,workflow 会读取当前评审请求和评审请求时间线,然后取消最近一次请求者为 `github-actions[bot]` 的当前请求。由人员发出的当前请求保持不变。如果时间线超过 3,000 个事件或包含无效的请求来源,workflow 会失败且不执行取消操作。
-
-所有权映射只接受显式绝对目录模式,并允许每条模式配置一至两名 GitHub 个人用户。通配符、隐藏目录模式、团队、超过两名 owner,以及重复的模式或 owner 都会被拒绝。匹配遵循 CODEOWNERS 的最后一条匹配规则。扫描器会在修改评审请求前,打印变更代码文件、排除的测试文件、文档文件和纯注释文件,逐文件 owner 匹配结果,以及将要请求或取消的评审者。未匹配的文件仍显示在日志中。PR 作者和已经收到评审请求的用户不会收到新请求。
-
-策略测试会统计已匹配目录下的非测试跟踪文件行数,并要求 `@turtle1999` 拥有的有效代码库不超过三分之一。
-
-<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>
-
-## 安全性
-
-具备写权限的 `pull_request_target` job 只检出仓库默认分支。它不会检出或执行 PR 代码,也不使用仓库 secret。PR 文件名仅作为 API 数据处理,并在日志中转义。
-
-所有权变更只有合并到默认分支后才会生效。这可以防止不受信任的 PR 为自身的 workflow 运行修改路由程序或 owner 分配。
-
-<a id="verification"></a>
-
-## 验证
-
-运行 `pnpm run test:request-review` 可检查所有权解析、文件分类、完整 patch 检查、注释解析、分页、日志顺序、草稿取消、评审者过滤和 API 行为。[Workflow 测试](../../scripts/ci-workflow.spec.ts)固定受信任检出、权限、事件和命令。仓库门禁图会在 CI 中运行这两类检查。
-
-<a id="dev-note"></a>
-
-## 开发说明
-
-[评审路由决策](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.zh.md)记录了安全模型、测试排除规则和备选方案。

+ 117 - 36
.github/review-ownership/request-review.mjs

@@ -7,9 +7,10 @@ import { pathToFileURL } from 'node:url'
 const API_VERSION = '2026-03-10'
 const MAX_OWNERS_PER_RULE = 2
 const MAX_PULL_REQUEST_FILES = 3_000
-const MAX_REQUESTED_REVIEWERS = 2
+const MAX_COUNTED_REQUESTED_REVIEWERS = 1
 const MAX_TIMELINE_EVENTS = 3_000
 const PAGE_SIZE = 100
+const UNCOUNTED_REVIEWER = 'turtle1999'
 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
@@ -240,55 +241,87 @@ function stripComments(source, syntax) {
 /**
  * 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[], excludedDocumentationFiles: string[], excludedCommentOnlyFiles: string[]}} Classified paths.
+ * @returns {{changedCodeFiles: string[], reviewableChanges: Array<{paths: string[], changedLines: number}>, excludedTestFiles: string[], excludedDocumentationFiles: string[], excludedCommentOnlyFiles: string[]}} Classified paths and their GitHub-reported changed-line counts.
  */
 export function classifyChangedFiles(files) {
   const changedCodeFiles = new Set()
+  const reviewableChanges = []
   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 changedLines = changedLineCount(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) {
+    const reviewablePaths = []
+    for (const file of new Set(paths)) {
       if (isTestPath(file)) excludedTestFiles.add(file)
       else if (isDocumentationPath(file)) excludedDocumentationFiles.add(file)
       else if (commentOnly) excludedCommentOnlyFiles.add(file)
-      else changedCodeFiles.add(file)
+      else {
+        changedCodeFiles.add(file)
+        reviewablePaths.push(file)
+      }
+    }
+    if (reviewablePaths.length > 0) {
+      reviewableChanges.push({ paths: reviewablePaths.sort(), changedLines })
     }
   }
   return {
     changedCodeFiles: [...changedCodeFiles].sort(),
+    reviewableChanges,
     excludedTestFiles: [...excludedTestFiles].sort(),
     excludedDocumentationFiles: [...excludedDocumentationFiles].sort(),
     excludedCommentOnlyFiles: [...excludedCommentOnlyFiles].sort(),
   }
 }
 
+function changedLineCount(entry) {
+  for (const field of ['additions', 'deletions']) {
+    if (!Number.isSafeInteger(entry[field]) || entry[field] < 0) {
+      throw new Error(`changed-file ${field} must be a non-negative integer`)
+    }
+  }
+  const changedLines = entry.additions + entry.deletions
+  if (!Number.isSafeInteger(changedLines)) throw new Error('changed-file LOC exceeds the safe integer range')
+  return changedLines
+}
+
 /**
- * Match changed paths to owners with CODEOWNERS last-match semantics.
+ * Match changed paths and rank owners by their reviewable changed LOC.
  * @param {Array<{prefix: string, owners: string[]}>} rules Ordered ownership rules.
- * @param {string[]} changedCodeFiles Reviewable repository paths.
- * @returns {{matches: Array<{file: string, owners: string[]}>, reviewers: string[]}} Routing plan.
+ * @param {Array<{paths: string[], changedLines: number}>} reviewableChanges Reviewable GitHub file records.
+ * @returns {{matches: Array<{file: string, changedLines: number, owners: string[]}>, reviewers: Array<{login: string, changedLines: number}>}} Routing plan.
  */
-export function planReviewers(rules, changedCodeFiles) {
+export function planReviewers(rules, reviewableChanges) {
   const matches = []
   const reviewers = new Map()
-  for (const file of changedCodeFiles) {
-    let owners = []
-    for (const rule of rules) {
-      if (file.startsWith(rule.prefix)) owners = rule.owners
+  for (const change of reviewableChanges) {
+    const changeOwners = new Map()
+    for (const file of change.paths) {
+      let owners = []
+      for (const rule of rules) {
+        if (file.startsWith(rule.prefix)) owners = rule.owners
+      }
+      matches.push({ file, changedLines: change.changedLines, owners })
+      for (const owner of owners) changeOwners.set(owner.toLowerCase(), owner.slice(1))
+    }
+    for (const [key, login] of changeOwners) {
+      const changedLines = (reviewers.get(key)?.changedLines ?? 0) + change.changedLines
+      if (!Number.isSafeInteger(changedLines)) throw new Error(`changed LOC for @${login} exceeds the safe integer range`)
+      reviewers.set(key, { login, changedLines })
     }
-    matches.push({ file, owners })
-    for (const owner of owners) reviewers.set(owner.toLowerCase(), owner.slice(1))
   }
   return {
-    matches,
-    reviewers: [...reviewers.values()].sort((left, right) => left.localeCompare(right, 'en')),
+    matches: matches.sort((left, right) => left.file.localeCompare(right.file, 'en')),
+    reviewers: [...reviewers.values()].sort((left, right) => {
+      if (left.changedLines !== right.changedLines) return left.changedLines < right.changedLines ? 1 : -1
+      return left.login.localeCompare(right.login, 'en')
+    }),
   }
 }
 
@@ -404,8 +437,8 @@ function requestedReviewerLogins(response) {
 }
 
 /**
- * Print changed paths, request missing owners on reviewable pull requests, and
- * cancel workflow-authored requests on drafts.
+ * Print changed paths, reconcile workflow-authored requests with current
+ * ownership, 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[], excludedDocumentationFiles: string[], excludedCommentOnlyFiles: string[], requestedReviewers: string[], cancelledReviewers: string[]}>} Applied routing result.
  */
@@ -413,8 +446,8 @@ export async function requestReviews({ event, ownershipSource, api, write = line
   const pull = pullRequestFromEvent(event)
   write('This is by automated Angry Turtle Cyborg, not a human')
   const files = await listPullRequestFiles(api, pull.repository, pull.number, pull.changedFileCount)
-  const classified = classifyChangedFiles(files)
-  const plan = planReviewers(parseOwnership(ownershipSource), classified.changedCodeFiles)
+  const { reviewableChanges, ...classified } = classifyChangedFiles(files)
+  const plan = planReviewers(parseOwnership(ownershipSource), reviewableChanges)
   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(
@@ -430,10 +463,16 @@ export async function requestReviews({ event, ownershipSource, api, write = line
   writeList(
     write,
     'Owners by changed file',
-    plan.matches.map(({ file, owners }) => `${JSON.stringify(file)}: ${owners.length ? owners.join(' ') : '(none)'}`),
+    plan.matches.map(({ file, changedLines, owners }) =>
+      `${JSON.stringify(file)} (${changedLines} LOC): ${owners.length ? owners.join(' ') : '(none)'}`),
+  )
+  writeList(
+    write,
+    'Owner relevance by changed LOC',
+    plan.reviewers.map(({ login, changedLines }) => `@${login}: ${changedLines}`),
   )
 
-  const candidates = plan.reviewers.filter(login => login.toLowerCase() !== pull.author.toLowerCase())
+  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)
@@ -455,25 +494,67 @@ export async function requestReviews({ event, ownershipSource, api, write = line
     return { ...classified, requestedReviewers: [], cancelledReviewers: reviewers }
   }
 
-  if (candidates.length === 0) {
-    writeList(write, 'Reviewers to request', [])
-    return { ...classified, requestedReviewers: [], cancelledReviewers: [] }
-  }
   const existing = await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`)
-  const alreadyRequested = new Set(requestedReviewerLogins(existing).map(login => login.toLowerCase()))
-  const availableSlots = Math.max(0, MAX_REQUESTED_REVIEWERS - alreadyRequested.size)
+  const currentReviewers = requestedReviewerLogins(existing).sort((left, right) => left.localeCompare(right, 'en'))
+  const workflowReviewers = currentReviewers.length === 0
+    ? []
+    : workflowRequestedReviewers(
+        await listPullRequestTimeline(api, pull.repository, pull.number),
+        currentReviewers,
+      )
+  const workflowReviewerKeys = new Set(workflowReviewers.map(login => login.toLowerCase()))
+  const manualReviewers = currentReviewers.filter(login => !workflowReviewerKeys.has(login.toLowerCase()))
+  let retainedCountedSlots = Math.max(
+    0,
+    MAX_COUNTED_REQUESTED_REVIEWERS
+      - manualReviewers.filter(login => login.toLowerCase() !== UNCOUNTED_REVIEWER).length,
+  )
+  const retainedWorkflowReviewerKeys = new Set()
+  for (const { login } of candidates) {
+    const key = login.toLowerCase()
+    if (!workflowReviewerKeys.has(key)) continue
+    if (key === UNCOUNTED_REVIEWER) retainedWorkflowReviewerKeys.add(key)
+    else if (retainedCountedSlots > 0) {
+      retainedWorkflowReviewerKeys.add(key)
+      retainedCountedSlots--
+    }
+  }
+  const reviewersToCancel = workflowReviewers.filter(
+    login => !retainedWorkflowReviewerKeys.has(login.toLowerCase()),
+  )
+  const cancelledReviewerKeys = new Set(reviewersToCancel.map(login => login.toLowerCase()))
+  const remainingReviewers = currentReviewers.filter(login => !cancelledReviewerKeys.has(login.toLowerCase()))
+  const alreadyRequested = new Set(remainingReviewers.map(login => login.toLowerCase()))
+  const availableSlots = Math.max(
+    0,
+    MAX_COUNTED_REQUESTED_REVIEWERS
+      - remainingReviewers.filter(login => login.toLowerCase() !== UNCOUNTED_REVIEWER).length,
+  )
+  writeList(write, 'Current individual review requests', currentReviewers.map(login => `@${login}`))
+  write(`Available counted review request slots: ${availableSlots}.`)
   const reviewers = candidates
-    .filter(login => !alreadyRequested.has(login.toLowerCase()))
+    .filter(({ login }) => !alreadyRequested.has(login.toLowerCase()))
     .slice(0, availableSlots)
+    .map(({ login }) => login)
+  writeList(write, 'Review requests to cancel', reviewersToCancel.map(login => `@${login}`))
   writeList(write, 'Reviewers to request', reviewers.map(login => `@${login}`))
-  if (reviewers.length === 0) return { ...classified, requestedReviewers: [], cancelledReviewers: [] }
+  if (reviewersToCancel.length > 0) {
+    await api(`/repos/${pull.repository}/pulls/${pull.number}/requested_reviewers`, {
+      method: 'DELETE',
+      body: { reviewers: reviewersToCancel },
+    })
+    const requestLabel = reviewersToCancel.length === 1 ? 'request' : 'requests'
+    write(`Cancelled review ${requestLabel} for ${reviewersToCancel.map(login => `@${login}`).join(' ')}.`)
+  }
 
-  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, cancelledReviewers: [] }
+  if (reviewers.length > 0) {
+    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, cancelledReviewers: reviewersToCancel }
 }
 
 function pullRequestFromEvent(event) {

+ 270 - 32
.github/review-ownership/request-review.test.mjs

@@ -32,8 +32,9 @@ const pullRequestEvent = ({ author = 'author', changedFiles = 1, draft = false }
 test('loads the repository ownership policy without test-only directory rules', () => {
   const rules = parseOwnership(ownershipSource)
   const ownersByPattern = new Map(rules.map(rule => [rule.pattern, rule.owners]))
-  assert.equal(rules.length, 58)
+  assert.equal(rules.length, 57)
   assert.equal(rules.some(rule => rule.pattern === '/benchmarks/'), false)
+  assert.equal(rules.some(rule => rule.pattern === '/scripts/'), false)
   assert.equal(rules.some(rule => rule.pattern === '/snapshots/'), false)
   assert.equal(rules.some(rule => rule.pattern === '/packages/test-support/'), false)
   assert.deepEqual(ownersByPattern.get('/apps/cli/'), ['@turtle1999'])
@@ -61,7 +62,7 @@ test('keeps turtle below one third of the eligible owned codebase', () => {
   let turtleLines = 0
   for (const file of trackedFiles) {
     if (isTestPath(file) || isDocumentationPath(file)) continue
-    const owners = planReviewers(rules, [file]).matches[0]?.owners ?? []
+    const owners = planReviewers(rules, [{ paths: [file], changedLines: 0 }]).matches[0]?.owners ?? []
     if (owners.length === 0) continue
     const content = readFileSync(file)
     const lines = content.length === 0
@@ -218,12 +219,16 @@ test('classifies both sides of a rename independently', () => {
       {
         filename: 'packages/core/agent/tests/moved.spec.ts',
         previous_filename: 'packages/core/agent/src/moved.ts',
+        additions: 3,
+        deletions: 2,
       },
       {
         filename: 'packages/client/store/src/restored.ts',
         previous_filename: 'packages/client/store/tests/restored.spec.ts',
+        additions: 2,
+        deletions: 1,
       },
-      { filename: 'packages/core/agent/README.md' },
+      { filename: 'packages/core/agent/README.md', additions: 1, deletions: 0 },
       {
         filename: 'packages/core/agent/src/commented.ts',
         status: 'modified', additions: 1, deletions: 1,
@@ -235,6 +240,10 @@ test('classifies both sides of a rename independently', () => {
         'packages/client/store/src/restored.ts',
         'packages/core/agent/src/moved.ts',
       ],
+      reviewableChanges: [
+        { paths: ['packages/core/agent/src/moved.ts'], changedLines: 5 },
+        { paths: ['packages/client/store/src/restored.ts'], changedLines: 3 },
+      ],
       excludedTestFiles: [
         'packages/client/store/tests/restored.spec.ts',
         'packages/core/agent/tests/moved.spec.ts',
@@ -245,21 +254,54 @@ test('classifies both sides of a rename independently', () => {
   )
 })
 
-test('uses the last matching ownership rule and keeps unmatched files visible', () => {
+test('uses the last matching ownership rule and ranks owners by changed LOC', () => {
   const rules = parseOwnership('/packages/ @broad\n/packages/core/ @core @second\n')
   assert.deepEqual(
-    planReviewers(rules, ['AGENTS.md', 'packages/core/agent/src/index.ts', 'packages/fs/fs/src/index.ts']),
+    planReviewers(rules, [
+      { paths: ['AGENTS.md'], changedLines: 1 },
+      { paths: ['packages/core/agent/src/index.ts'], changedLines: 8 },
+      { paths: ['packages/fs/fs/src/index.ts'], changedLines: 3 },
+    ]),
     {
       matches: [
-        { file: 'AGENTS.md', owners: [] },
-        { file: 'packages/core/agent/src/index.ts', owners: ['@core', '@second'] },
-        { file: 'packages/fs/fs/src/index.ts', owners: ['@broad'] },
+        { file: 'AGENTS.md', changedLines: 1, owners: [] },
+        { file: 'packages/core/agent/src/index.ts', changedLines: 8, owners: ['@core', '@second'] },
+        { file: 'packages/fs/fs/src/index.ts', changedLines: 3, owners: ['@broad'] },
+      ],
+      reviewers: [
+        { login: 'core', changedLines: 8 },
+        { login: 'second', changedLines: 8 },
+        { login: 'broad', changedLines: 3 },
       ],
-      reviewers: ['broad', 'core', 'second'],
     },
   )
 })
 
+test('counts each changed-file record once per owner across rename paths', () => {
+  const rules = parseOwnership('/packages/a/ @same @a\n/packages/b/ @same @b\n/packages/c/ @c\n')
+  const plan = planReviewers(rules, [
+    { paths: ['packages/a/old.ts', 'packages/b/new.ts'], changedLines: 10 },
+    { paths: ['packages/a/other.ts'], changedLines: 5 },
+    { paths: ['packages/c/tiny.ts'], changedLines: 1 },
+  ])
+  assert.deepEqual(plan.reviewers, [
+    { login: 'a', changedLines: 15 },
+    { login: 'same', changedLines: 15 },
+    { login: 'b', changedLines: 10 },
+    { login: 'c', changedLines: 1 },
+  ])
+})
+
+test('rejects invalid changed-file LOC', () => {
+  for (const file of [
+    { filename: 'packages/core/index.ts', deletions: 0 },
+    { filename: 'packages/core/index.ts', additions: -1, deletions: 0 },
+    { filename: 'packages/core/index.ts', additions: Number.MAX_SAFE_INTEGER, deletions: 1 },
+  ]) {
+    assert.throws(() => classifyChangedFiles([file]), /changed-file|LOC/u)
+  }
+})
+
 test('fetches every declared changed file across pages', async () => {
   const calls = []
   const pageOne = Array.from({ length: 100 }, (_, index) => ({ filename: `packages/core/file-${index}.ts` }))
@@ -303,21 +345,21 @@ test('fails closed when the review-request timeline exceeds its limit', async ()
   assert.equal(calls, 30)
 })
 
-test('prints changed code files and limits current review requests to two people', async () => {
+test('prints changed code files and requests the highest-ranked counted owner', async () => {
   const trace = []
   const files = [
-    { filename: 'packages/core/agent/src/index.ts' },
-    { filename: 'packages/preset/agent-presets/src/index.ts' },
-    { filename: 'packages/client/store/src/index.ts' },
-    { filename: 'packages/subagent/subagent/src/index.ts' },
-    { filename: 'packages/core/agent/tests/index.spec.ts' },
-    { filename: 'AGENTS.md' },
+    { filename: 'packages/core/agent/src/index.ts', additions: 70, deletions: 10 },
+    { filename: 'packages/preset/agent-presets/src/index.ts', additions: 5, deletions: 5 },
+    { filename: 'packages/client/store/src/index.ts', additions: 2, deletions: 0 },
+    { filename: 'packages/subagent/subagent/src/index.ts', additions: 40, deletions: 0 },
+    { filename: 'packages/core/agent/tests/index.spec.ts', additions: 100, deletions: 0 },
+    { filename: 'AGENTS.md', additions: 200, deletions: 0 },
   ]
   const 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 !== 'POST') {
-      return { users: [{ login: 'imccyu' }], teams: [] }
+      return { users: [], teams: [] }
     }
     if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {}
     throw new Error(`unexpected API path ${path}`)
@@ -340,37 +382,109 @@ test('prints changed code files and limits current review requests to two people
     excludedTestFiles: ['packages/core/agent/tests/index.spec.ts'],
     excludedDocumentationFiles: ['AGENTS.md'],
     excludedCommentOnlyFiles: [],
-    requestedReviewers: ['Dudu-0223'],
+    requestedReviewers: ['mektpoy'],
     cancelledReviewers: [],
   })
   assert.equal(trace[0].type, 'log')
   assert.equal(trace[0].line, 'This is by automated Angry Turtle Cyborg, not a human')
   const changedHeading = trace.findIndex(item => item.type === 'log' && item.line === 'Changed code files:')
+  const relevanceHeading = trace.findIndex(item => item.type === 'log' && item.line === 'Owner relevance by changed LOC:')
   const post = trace.findIndex(item => item.type === 'api' && item.options.method === 'POST')
-  assert.ok(changedHeading >= 0 && changedHeading < post)
+  assert.ok(changedHeading >= 0 && changedHeading < relevanceHeading && relevanceHeading < post)
+  assert.deepEqual(trace.slice(relevanceHeading, relevanceHeading + 6).map(item => item.line), [
+    'Owner relevance by changed LOC:',
+    '- @turtle1999: 90',
+    '- @mektpoy: 80',
+    '- @Dudu-0223: 40',
+    '- @LegGasai: 10',
+    '- @imccyu: 2',
+  ])
   assert.deepEqual(trace[post], {
     type: 'api',
     path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
     options: {
       method: 'POST',
-      body: { reviewers: ['Dudu-0223'] },
+      body: { reviewers: ['mektpoy'] },
     },
   })
 })
 
-test('does not add an owner when two people are already requested', async () => {
+test('does not add another counted owner when one is already requested', async () => {
   const calls = []
+  const output = []
   const result = await requestReviews({
     event: pullRequestEvent(),
+    ownershipSource: '/packages/core/ @mektpoy\n',
+    api: async (path, options = {}) => {
+      calls.push({ path, options })
+      if (path.endsWith('/files?per_page=100&page=1')) {
+        return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
+      }
+      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
+        return { users: [{ login: 'first' }], teams: [] }
+      }
+      if (path.endsWith('/timeline?per_page=100&page=1')) return []
+      throw new Error(`unexpected API path ${path}`)
+    },
+    write: line => output.push(line),
+  })
+
+  assert.deepEqual(result.requestedReviewers, [])
+  assert.equal(calls.some(call => call.options.method === 'POST'), false)
+  assert.deepEqual(output.slice(-7), [
+    'Current individual review requests:',
+    '- @first',
+    'Available counted review request slots: 0.',
+    'Review requests to cancel:',
+    '- (none)',
+    'Reviewers to request:',
+    '- (none)',
+  ])
+})
+
+test('requests at most one owner per run when turtle ranks first', async () => {
+  const calls = []
+  const result = await requestReviews({
+    event: pullRequestEvent({ author: 'contributor', changedFiles: 2 }),
+    ownershipSource: '/packages/core/ @turtle1999\n/packages/client/ @mektpoy\n',
+    api: async (path, options = {}) => {
+      calls.push({ path, options })
+      if (path.endsWith('/files?per_page=100&page=1')) {
+        return [
+          { filename: 'packages/core/agent/src/index.ts', additions: 25, deletions: 5 },
+          { filename: 'packages/client/store/src/index.ts', additions: 8, deletions: 2 },
+        ]
+      }
+      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
+        return { users: [], teams: [] }
+      }
+      if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {}
+      throw new Error(`unexpected API path ${path}`)
+    },
+    write: () => {},
+  })
+
+  assert.deepEqual(result.requestedReviewers, ['turtle1999'])
+  assert.deepEqual(calls.find(call => call.options.method === 'POST'), {
+    path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
+    options: { method: 'POST', body: { reviewers: ['turtle1999'] } },
+  })
+})
+
+test('does not add turtle when one counted reviewer is already requested', async () => {
+  const calls = []
+  const result = await requestReviews({
+    event: pullRequestEvent({ author: 'contributor' }),
     ownershipSource: '/packages/core/ @turtle1999 @mektpoy\n',
     api: async (path, options = {}) => {
       calls.push({ path, options })
       if (path.endsWith('/files?per_page=100&page=1')) {
-        return [{ filename: 'packages/core/agent/src/index.ts' }]
+        return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
       }
       if (path.endsWith('/requested_reviewers') && options.method === undefined) {
-        return { users: [{ login: 'first' }, { login: 'second' }], teams: [] }
+        return { users: [{ login: 'first' }], teams: [] }
       }
+      if (path.endsWith('/timeline?per_page=100&page=1')) return []
       throw new Error(`unexpected API path ${path}`)
     },
     write: () => {},
@@ -380,14 +494,136 @@ test('does not add an owner when two people are already requested', async () =>
   assert.equal(calls.some(call => call.options.method === 'POST'), false)
 })
 
+test('keeps the counted slot available when turtle is already requested', async () => {
+  const calls = []
+  const result = await requestReviews({
+    event: pullRequestEvent({ author: 'contributor' }),
+    ownershipSource: '/packages/core/ @turtle1999 @mektpoy\n',
+    api: async (path, options = {}) => {
+      calls.push({ path, options })
+      if (path.endsWith('/files?per_page=100&page=1')) {
+        return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
+      }
+      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
+        return { users: [{ login: 'turtle1999' }], teams: [] }
+      }
+      if (path.endsWith('/timeline?per_page=100&page=1')) return []
+      if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {}
+      throw new Error(`unexpected API path ${path}`)
+    },
+    write: () => {},
+  })
+
+  assert.deepEqual(result.requestedReviewers, ['mektpoy'])
+  assert.deepEqual(calls.find(call => call.options.method === 'POST'), {
+    path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
+    options: { method: 'POST', body: { reviewers: ['mektpoy'] } },
+  })
+})
+
+test('replaces a workflow reviewer that no longer matches current ownership', async () => {
+  const trace = []
+  const result = await requestReviews({
+    event: pullRequestEvent({ author: 'contributor' }),
+    ownershipSource: '/packages/core/ @mektpoy\n',
+    api: async (path, options = {}) => {
+      trace.push({ type: 'api', path, options })
+      if (path.endsWith('/files?per_page=100&page=1')) {
+        return [{ filename: 'packages/core/agent/src/index.ts', additions: 20, deletions: 10 }]
+      }
+      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
+        return { users: [{ login: 'Dudu-0223' }], teams: [] }
+      }
+      if (path.endsWith('/timeline?per_page=100&page=1')) {
+        return [{
+          event: 'review_requested',
+          requested_reviewer: { login: 'Dudu-0223' },
+          review_requester: { login: 'github-actions[bot]' },
+        }]
+      }
+      if (path.endsWith('/requested_reviewers') && options.method === 'DELETE') return {}
+      if (path.endsWith('/requested_reviewers') && options.method === 'POST') return {}
+      throw new Error(`unexpected API path ${path}`)
+    },
+    write: line => trace.push({ type: 'log', line }),
+  })
+
+  assert.deepEqual(result.requestedReviewers, ['mektpoy'])
+  assert.deepEqual(result.cancelledReviewers, ['Dudu-0223'])
+  const cancelLog = trace.findIndex(item => item.type === 'log' && item.line === 'Review requests to cancel:')
+  const requestLog = trace.findIndex(item => item.type === 'log' && item.line === 'Reviewers to request:')
+  const firstMutation = trace.findIndex(item => item.type === 'api' && item.options.method !== undefined)
+  assert.ok(cancelLog >= 0 && requestLog >= 0 && cancelLog < firstMutation && requestLog < firstMutation)
+  assert.equal(trace[cancelLog + 1].line, '- @Dudu-0223')
+  assert.equal(trace[requestLog + 1].line, '- @mektpoy')
+  assert.deepEqual(trace.filter(item => item.type === 'api' && item.options.method !== undefined), [
+    {
+      type: 'api',
+      path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
+      options: { method: 'DELETE', body: { reviewers: ['Dudu-0223'] } },
+    },
+    {
+      type: 'api',
+      path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
+      options: { method: 'POST', body: { reviewers: ['mektpoy'] } },
+    },
+  ])
+})
+
+test('removes excess workflow reviewers using current relevance order', async () => {
+  const calls = []
+  const result = await requestReviews({
+    event: pullRequestEvent({ author: 'contributor', changedFiles: 2 }),
+    ownershipSource: '/packages/core/ @mektpoy\n/packages/subagent/ @Dudu-0223\n',
+    api: async (path, options = {}) => {
+      calls.push({ path, options })
+      if (path.endsWith('/files?per_page=100&page=1')) {
+        return [
+          { filename: 'packages/core/agent/src/index.ts', additions: 25, deletions: 5 },
+          { filename: 'packages/subagent/subagent/src/index.ts', additions: 8, deletions: 2 },
+        ]
+      }
+      if (path.endsWith('/requested_reviewers') && options.method === undefined) {
+        return { users: [{ login: 'Dudu-0223' }, { login: 'mektpoy' }], teams: [] }
+      }
+      if (path.endsWith('/timeline?per_page=100&page=1')) {
+        return ['Dudu-0223', 'mektpoy'].map(login => ({
+          event: 'review_requested',
+          requested_reviewer: { login },
+          review_requester: { login: 'github-actions[bot]' },
+        }))
+      }
+      if (path.endsWith('/requested_reviewers') && options.method === 'DELETE') return {}
+      throw new Error(`unexpected API path ${path}`)
+    },
+    write: () => {},
+  })
+
+  assert.deepEqual(result, {
+    changedCodeFiles: [
+      'packages/core/agent/src/index.ts',
+      'packages/subagent/subagent/src/index.ts',
+    ],
+    excludedTestFiles: [],
+    excludedDocumentationFiles: [],
+    excludedCommentOnlyFiles: [],
+    requestedReviewers: [],
+    cancelledReviewers: ['Dudu-0223'],
+  })
+  assert.deepEqual(calls.find(call => call.options.method === 'DELETE'), {
+    path: '/repos/deepseek-harness/deepseek-harness/pulls/42/requested_reviewers',
+    options: { method: 'DELETE', body: { reviewers: ['Dudu-0223'] } },
+  })
+})
+
 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: 'apps/web/tests/chat.e2e.ts', additions: 10, deletions: 0 },
+    { filename: 'packages/core/agent/tests/agent.spec.ts', additions: 10, deletions: 0 },
+    { filename: 'packages/core/agent/README.md', additions: 10, deletions: 0 },
+    { filename: 'packages/core/agent/examples.yaml', additions: 10, deletions: 0 },
     {
       filename: 'packages/core/agent/src/index.ts',
       status: 'modified', additions: 1, deletions: 1,
@@ -399,7 +635,9 @@ test('does not request reviewers for test, documentation, or comment-only change
     ownershipSource,
     api: async (path) => {
       calls.push(path)
-      return files
+      if (path.endsWith('/files?per_page=100&page=1')) return files
+      if (path.endsWith('/requested_reviewers')) return { users: [], teams: [] }
+      throw new Error(`unexpected API path ${path}`)
     },
     write: line => output.push(line),
   })
@@ -411,7 +649,7 @@ test('does not request reviewers for test, documentation, or comment-only change
     requestedReviewers: [],
     cancelledReviewers: [],
   })
-  assert.equal(calls.length, 1)
+  assert.equal(calls.length, 2)
   assert.deepEqual(output.slice(0, 4), [
     'This is by automated Angry Turtle Cyborg, not a human',
     'Changed code files:',
@@ -423,9 +661,9 @@ test('does not request reviewers for test, documentation, or comment-only change
 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' },
+    { filename: 'packages/subagent/subagent/src/index.ts', additions: 10, deletions: 2 },
+    { filename: 'packages/subagent/subagent/tests/index.spec.ts', additions: 10, deletions: 0 },
+    { filename: 'packages/subagent/subagent/README.md', additions: 10, deletions: 0 },
   ]
   const result = await requestReviews({
     event: pullRequestEvent({ draft: true, changedFiles: files.length }),

+ 2 - 2
docs/i18n/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 docs/i18n/README.md
-README.md: 1b6ed4a3f4bff05cdb52b28fc81c5d7b47a9a260
-README.zh.md: e28db9bb230cf5fb344fdc3ddc415883f5ebe6b3
+README.md: 55ae07c18e09fde141ecf5344f715dfa25658325
+README.zh.md: 674edeb9da4bf0083c607216a3c992a98f61897a

+ 1 - 0
docs/i18n/README.md

@@ -51,6 +51,7 @@ Generated English references and graphs participate in pairing when a reviewed C
 - `docs/AGENTS.md`, `.agents/notes/**/AGENTS.md`, and their `CLAUDE.md` instruction symlinks — agent instructions, maintained in English only like the root `AGENTS.md`.
 - `docs/i18n/terminology.md` and [style-samples.md](style-samples.md) — both are bilingual by construction.
 - [translation-prompt.md](translation-prompt.md) — the automated pipeline's prompt template; its body is machine-consumed verbatim, so a paired translation would change pipeline behavior.
+- [review-ownership/README.md](../../.github/review-ownership/README.md) and its [Agent Note](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md) — repository-internal automation policy maintained in English only.
 - `.agents/notes/archived/` — frozen historical triplets. [`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) validates their completeness and content seals; translation maintenance must never rewrite them.
 
 **Universal requirement**: every current or future document in scope must merge as a complete bilingual pair. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) contains only explicit exclusions; there is no per-file rollout list, date cutoff, or README-specific policy class.

+ 1 - 0
docs/i18n/README.zh.md

@@ -53,6 +53,7 @@
 - `docs/AGENTS.md`、`.agents/notes/**/AGENTS.md` 以及指向它们的 `CLAUDE.md` 指令符号链接:agent 指令,与根 `AGENTS.md` 一样只以英文维护。
 - `docs/i18n/terminology.md` 与 [style-samples.md](style-samples.md):二者本身即为中英对照文档。
 - [translation-prompt.md](translation-prompt.md):自动翻译流水线的提示词模板;正文逐字进入模型请求,配对翻译会改变流水线行为。
+- [review-ownership/README.md](../../.github/review-ownership/README.md) 及其 [Agent Note](../../.agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md):仓库内部自动化政策,只以英文维护。
 - `.agents/notes/archived/`:冻结的历史三文件配对。[`verify-archived-agent-notes`](../../scripts/verify-archived-agent-notes.ts) 校验其完整性和内容封存记录;翻译维护绝不能重写这些文件。
 
 **统一要求**:当前及今后纳入范围的每篇文档,合并时都必须构成完整的双语配对。[scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 只包含显式排除项;不存在逐文件推进清单、日期分界或 README 专用政策类别。

+ 2 - 0
scripts/translation-pairing.manifest.json

@@ -3,6 +3,8 @@
     ".agents/notes/AGENTS.md",
     ".agents/notes/implemented/AGENTS.md",
     ".agents/notes/implemented/CLAUDE.md",
+    ".agents/notes/implemented/process/2026-09-08-trusted-changed-file-review-routing.md",
+    ".github/review-ownership/README.md",
     "docs/AGENTS.md",
     "docs/cordis-api/inherited.md",
     "docs/i18n/style-samples.md",