Selaa lähdekoodia

feat: support weighted approval delegation

Turtle 2 viikkoa sitten
vanhempi
sitoutus
e2ae9411cb

+ 6 - 0
.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.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-15-pr-approval-delegation.md
+2026-09-15-pr-approval-delegation.md: 9a1ba54b59301492eba492e82395a5af23d22328
+2026-09-15-pr-approval-delegation.zh.md: 49128102413037713545f5ab7afe096abe8c8fc1

+ 31 - 0
.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.md

@@ -0,0 +1,31 @@
+# Agent Note: PR-scoped approval delegation
+
+Status: implemented
+
+English | [中文](2026-09-15-pr-approval-delegation.zh.md)
+
+## Problem
+
+A reviewer may trust another reviewer to decide a particular PR while retaining the score associated with their own repository role and code ownership. Counting both the original approval and transferred points would inflate that reviewer's contribution.
+
+## Decision
+
+The [approval policy](../../../../.github/review-ownership/README.md#delegating-points) accepts `/delegate @username` in PR conversation comments. Each eligible sender's points follow the named recipient's effective approval, once, using the sender's weight. Delegation applies only to that PR and only between write-capable accounts other than the PR author. Received points cannot be forwarded. Delegation does not clear either account's blocking review; [blocking-review policy](2026-09-09-blocked-weighted-approvals-remain-pending.md) remains authoritative.
+
+The latest surviving command in comment creation order determines the recipient. A self-delegation or a subsequently submitted review restores the sender's own decision. Comment-only reviews also reclaim the points; pending reviews do not. Equal timestamps favor the review, and subsequent dismissal cannot revive the delegation. A fresh command after the review can delegate again. Edits and deletions recompute from current comments, including restoration of older commands. The trusted publisher subscribes to all PR conversation comment changes, resolves the live PR head, and reads comments as API data. Creating or editing a still-active eligible command requests review from the recipient unless already requested; other requested reviewers remain unchanged. Drafts do not request reviews. Request failures fail evaluation. It preserves the separate [review-workflow validation](2026-09-10-approval-review-workflow-identity.md).
+
+## Alternatives considered
+
+**Count delegation as immediate approval.** This would approve a PR before the chosen reviewer makes a decision.
+
+**Add the sender's score without removing their direct contribution.** This would count one account twice and weaken the approval threshold.
+
+**Forward received points through delegation chains.** This would let a recipient transfer another person's points to an account that person did not name.
+
+**Keep delegation after the sender reviews.** A submitted review expresses the sender's own decision, so continuing to use someone else's approval would disregard it.
+
+**Persist commands separately from comments.** This would require additional storage and reconciliation for edits and deletions; current comments already provide an inspectable record.
+
+## Consequences
+
+Delegation preserves the sender's [production ownership weight](2026-09-11-production-blame-approval-weight.md) and the independent author-credit rule. Each evaluation needs complete comment history and current participant permissions; missing history fails evaluation. Editing an older command does not change its priority, and deleting a newer command may reactivate an older one. Policy tests cover score conservation, review-driven revocation, permission filtering, review requests, blockers, pagination, and failure publication; workflow tests pin trusted execution and shared per-PR concurrency. Native stale-review and latest-push requirements remain GitHub's responsibility.

+ 31 - 0
.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.zh.md

@@ -0,0 +1,31 @@
+# Agent Note: PR 范围内的批准委托
+
+Status: implemented
+
+[English](2026-09-15-pr-approval-delegation.md) | 中文
+
+## Problem
+
+评审人可能信任另一位评审人来决定某个 PR(Pull Request),同时保留自己的仓库角色和代码归属对应的分数。如果同时计入原始批准和转交的分数,就会放大该评审人的贡献。
+
+## Decision
+
+[批准策略](../../../../.github/review-ownership/README.md#delegating-points) 接受 PR 普通评论中的 `/delegate @username`。每位符合资格的委托人,其分数跟随指定受托人的有效批准,按委托人的权重计入一次。委托仅适用于当前 PR,且双方都必须具有写权限,不能是 PR 作者。收到的分数不能继续转交。委托不会清除任一方的阻塞评审;[阻塞评审策略](2026-09-09-blocked-weighted-approvals-remain-pending.zh.md) 仍是权威规则。
+
+按评论创建顺序,最新且仍存在的命令决定受托人。委托给自己或随后提交评审会恢复使用委托人本人的评审决定。仅评论的评审也会收回分数;尚未提交的评审不会。时间戳相同时以评审为准,随后撤销评审也不会重新启用委托。评审之后的新命令可以再次委托。编辑和删除会根据当前评论重算,包括恢复较早的命令。可信发布器订阅所有 PR 普通评论变更,解析实时 PR 头提交,并将评论作为 API 数据读取。创建或编辑仍然有效且符合资格的命令时,会请求受托人进行评审;如果已经请求,则不重复请求,其他 requested reviewers 保持不变。草稿 PR 不发起评审请求。请求失败会使评估失败。独立的[评审工作流验证](2026-09-10-approval-review-workflow-identity.zh.md) 保持不变。
+
+## Alternatives considered
+
+**把委托直接视为批准。** 这会在选定的评审人作出决定前批准 PR。
+
+**增加委托人的分数,同时保留其直接贡献。** 这会将同一账户计算两次,削弱批准阈值。
+
+**沿委托链继续转交收到的分数。** 这会允许受托人将他人的分数转给未经原委托人指定的账户。
+
+**委托人提交评审后仍保留委托。** 已提交的评审表达了委托人本人的决定,继续使用他人的批准会忽略该决定。
+
+**在评论之外单独持久化命令。** 这需要额外存储,并协调编辑与删除;当前评论已经提供可检查的记录。
+
+## Consequences
+
+委托保留委托人的[生产代码归属权重](2026-09-11-production-blame-approval-weight.zh.md)和独立的作者信用规则。每次评估都需要完整评论历史和参与者的当前权限;历史缺失会使评估失败。编辑较早命令不会改变其优先级,删除较新命令可能重新启用较早命令。策略测试覆盖分数守恒、评审触发的收回、权限过滤、评审请求、阻塞评审、分页和失败状态发布;工作流测试约束可信执行及每个 PR 共享的并发分组。过期评审和最新推送要求仍由 GitHub 原生规则负责。

+ 16 - 3
.github/review-ownership/README.md

@@ -2,11 +2,12 @@
 
 ## Summary
 
-The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes an approval score for branch rules. Reviewer selection and review requests remain manual.
+The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes an approval score for branch rules. Reviewers are chosen manually; an eligible delegation command requests review from its recipient.
 
 ## Table of Contents
 
 - [Approval scoring](#approval-scoring)
+- [Delegating points](#delegating-points)
 - [Security](#security)
 - [Verification](#verification)
 - [Dev Note](#dev-note)
@@ -27,13 +28,23 @@ Production source means supported code files under `src/` in `packages/`, `apps/
 
 Each reviewer contributes only the current `APPROVED` or `CHANGES_REQUESTED` decision that GitHub returns. A `DISMISSED` record clears that reviewer's standing decision, including earlier approvals. Comment-only and pending records do not replace a decision. Reviews from deleted accounts and reviewers without current repository access do not count. The workflow does not invalidate an approval by its review commit; the repository's native pull-request rules own stale-review and latest-push requirements.
 
-The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, becomes a draft, or is edited, including a base-branch change. Pull-request and review events share one concurrency group per PR. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review, and resolves current repository permission before publishing the status. Permission changes take effect on the next subscribed pull-request or review event.
+The publisher runs when a pull request opens, synchronizes, reopens, becomes ready, becomes a draft, or is edited, including a base-branch change. It also runs when a PR conversation comment is created, edited, or deleted. Pull-request, review, and comment events share one concurrency group per PR. Review submissions, edits, and dismissals run the no-permission [`weighted-approval-review-event` workflow](../workflows/weighted-approval-review-event.yml); its validated run title supplies the pull-request number to the default-branch publisher. The publisher validates the current head, fetches every review and conversation comment, and resolves current repository permission before publishing the status. Permission changes take effect on the next subscribed event.
+
+<a id="delegating-points"></a>
+
+## Delegating points
+
+Post `/delegate @username` as the entire text of a PR conversation comment to transfer your reviewer points to that user's effective `APPROVED` decision on this PR. For example, `/delegate @turtle1999` lets @turtle1999's approval count your points alongside their own. Until they approve, your points do not count, even if your own approval remains active. Creating or editing an active command also requests review from its recipient unless that user is already requested. Other requested reviewers remain unchanged. The command does not submit or dismiss a GitHub review; drafts do not request reviews.
+
+Both accounts must currently have write or admin permission, and neither may be the PR author. Commands involving ineligible accounts are ignored. A sender's newest surviving syntactically valid command wins, in comment creation order; editing an older comment does not move it after newer comments. Submitting any review after delegation automatically takes the points back, including an approval, change request, or comment-only review. Pending reviews do not count; a later dismissal does not restore the delegation. When timestamps are equal, the review takes precedence. A new command after the review can delegate again; editing an older command cannot reactivate it. Post `/delegate @your-own-username` to restore your own review decision. Editing or deleting a command recomputes delegation from remaining comments, so deleting the latest command can restore an older one. Quoted commands, code blocks, review bodies, inline review comments, and commands mixed with other text do not count. Surrounding whitespace is allowed; account matching is case-insensitive.
+
+Each sender contributes at most once, using their own fixed weight or production-line ownership. A delegate can receive points from multiple senders, but can transfer only their own points: delegated points are not forwarded through another delegation. Author credit cannot be delegated. Every write-capable reviewer's effective `CHANGES_REQUESTED` still blocks the PR, including the sender's. Dismissing or replacing the delegate's approval removes the delegated points. Logs identify each counted score owner and their delegate. Comment history failures or the 3,000-record limit fail evaluation instead of accepting a partial history.
 
 <a id="security"></a>
 
 ## Security
 
-All actions in the status-writing job are pinned to commit SHAs. The job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Only when there is no blocker, reviewer points plus author credit are insufficient, and an approval has the policy’s default weight, it fetches complete history using the job token, passes Git objects to the trusted classifier as data, and resolves commit authors in batches of 50. Fetch credentials exist only in the Git child environment. Missing history, parsing failures, or incomplete author queries fail evaluation rather than producing a partial score. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews are treated as API data and escaped in logs.
+All actions in the status-writing job are pinned to commit SHAs. The job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Only when there is no blocker, reviewer points plus author credit are insufficient, and an approval has the policy’s default weight, it fetches complete history using the job token, passes Git objects to the trusted classifier as data, and resolves commit authors in batches of 50. Fetch credentials exist only in the Git child environment. Missing history, parsing failures, or incomplete author queries fail evaluation rather than producing a partial score. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its run title. The publisher accepts only successful `pull_request_review` runs from the review-event workflow file, identified by `workflow_run.path`; GitHub can populate `workflow_run.name` with the expanded run title. The publisher rejects an invalid run title and a number that does not resolve to the workflow run's current pull-request head. Pull-request reviews and comments are treated as API data and escaped in logs. Pull-request write permission allows requesting the validated delegate as a reviewer. Only a created or edited comment that is still the active eligible command can request review; deletion, review events, and superseded or revoked commands cannot. A failed request fails the publisher and writes an error status.
 
 Approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the program or policy for its own run.
 
@@ -48,3 +59,5 @@ Run `pnpm run test:approval-policy` for policy parsing, effective review decisio
 ## Dev Note
 
 [Production blame weighting](../../.agents/notes/implemented/process/2026-09-11-production-blame-approval-weight.md) records the scoring rationale and measured costs.
+
+[PR-scoped delegation](../../.agents/notes/implemented/process/2026-09-15-pr-approval-delegation.md) records score ownership and revocation choices.

+ 123 - 29
.github/review-ownership/check-approval.mjs

@@ -8,7 +8,7 @@ import { productionOwnership, LOGIN } from './blame-ownership.mjs'
 import { authorCreditPoints, countMergedAuthorPulls } from './author-weight.mjs'
 
 const API_VERSION = '2026-03-10'
-const MAX_PULL_REQUEST_REVIEWS = 3_000
+const MAX_PULL_REQUEST_RECORDS = 3_000
 const PAGE_SIZE = 100
 const STATUS_CONTEXT = 'weighted approval'
 const WRITABLE_PERMISSIONS = new Set(['admin', 'write'])
@@ -116,22 +116,60 @@ export function createGitHubApi({ token, apiUrl = 'https://api.github.com', fetc
  * @returns {Promise<unknown[]>} Complete review list within the supported limit.
  */
 export async function listPullRequestReviews(api, repository, pullNumber) {
-  const reviews = []
+  return listRecords(api, `/repos/${repository}/pulls/${pullNumber}/reviews`, 'pull-request reviews')
+}
+
+async function listRecords(api, path, subject) {
+  const records = []
   for (let page = 1; ; page++) {
-    const response = await api(`/repos/${repository}/pulls/${pullNumber}/reviews?per_page=${PAGE_SIZE}&page=${page}`)
-    if (!Array.isArray(response)) throw new Error('pull-request reviews response is not an array')
-    reviews.push(...response)
-    if (response.length < PAGE_SIZE) return reviews
-    if (reviews.length >= MAX_PULL_REQUEST_REVIEWS) {
-      throw new Error(`pull-request reviews exceed ${MAX_PULL_REQUEST_REVIEWS} records`)
+    const response = await api(`${path}?per_page=${PAGE_SIZE}&page=${page}`)
+    if (!Array.isArray(response)) throw new Error(`${subject} response is not an array`)
+    records.push(...response)
+    if (response.length < PAGE_SIZE) return records
+    if (records.length >= MAX_PULL_REQUEST_RECORDS) {
+      throw new Error(`${subject} exceed ${MAX_PULL_REQUEST_RECORDS} records`)
     }
   }
 }
 
+// The API orders comments by creation ID; edits do not reorder commands.
+function effectiveDelegations(comments, reviews) {
+  const delegations = new Map()
+  for (const comment of comments) {
+    if (!isRecord(comment)) throw new Error('pull-request comment is not an object')
+    if (comment.user === null) continue
+    if (typeof comment.body !== 'string') throw new Error('pull-request comment has no body')
+    const match = /^\/delegate @([^\s]+)$/u.exec(comment.body.trim())
+    if (!match || !LOGIN.test(match[1])) continue
+    const login = validateLogin(comment.user?.login, 'delegation author').toLowerCase()
+    const delegate = match[1].toLowerCase()
+    if (login === delegate) delegations.delete(login)
+    else {
+      if (!Number.isSafeInteger(comment.id) || comment.id <= 0) throw new Error('delegation comment has no valid ID')
+      delegations.set(login, { delegate, commentId: comment.id, createdAt: timestamp(comment.created_at, 'delegation comment') })
+    }
+  }
+  for (const review of reviews) {
+    if (review.user === null || review.state.toUpperCase() === 'PENDING') continue
+    const login = review.user.login.toLowerCase()
+    const delegation = delegations.get(login)
+    if (delegation && timestamp(review.submitted_at, 'submitted review') >= delegation.createdAt) {
+      delegations.delete(login)
+    }
+  }
+  return delegations
+}
+
+function timestamp(value, subject) {
+  const time = typeof value === 'string' ? Date.parse(value) : NaN
+  if (!Number.isFinite(time)) throw new Error(`${subject} has no valid timestamp`)
+  return time
+}
+
 /**
  * Evaluate approval points from current reviews and repository permissions.
  * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, getOwnership?: typeof productionOwnership, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs.
- * @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'pending' | 'success', description: string, points: number, authorCredit: {mergedCount: number, points: number} | null, requiredPoints: number, approvals: Array<{login: string, points: number, ownership?: {ownedLines: number, totalLines: number}}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields; null author credit means history was not evaluated.
+ * @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'pending' | 'success', description: string, points: number, authorCredit: {mergedCount: number, points: number} | null, requiredPoints: number, approvals: Array<{login: string, points: number, delegatedTo?: string, ownership?: {ownedLines: number, totalLines: number}}>, delegations: Array<{login: string, delegatedTo: string, commentId: number}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields; approval login owns the points, delegatedTo supplies its decision, delegations contains eligible active commands, and null author credit means history was not evaluated.
  */
 export async function evaluateApproval({ event, policySource, api, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls }) {
   const pull = pullRequestFromEvent(event)
@@ -143,24 +181,39 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
   const reviews = await listPullRequestReviews(api, pull.repository, pull.number)
   const decisions = effectiveReviewDecisions(reviews)
     .filter(({ login }) => login.toLowerCase() !== pull.author.toLowerCase())
-  const permissions = []
-  for (const { login, state } of decisions) {
-    permissions.push({ login, state, permission: await reviewerPermission(api, pull.repository, login) })
+  const comments = await listRecords(api, `/repos/${pull.repository}/issues/${pull.number}/comments`, 'pull-request comments')
+  const delegations = effectiveDelegations(comments, reviews)
+  const participants = new Map(decisions.map(({ login }) => [login.toLowerCase(), login]))
+  for (const [login, { delegate }] of delegations) {
+    participants.set(login, participants.get(login) ?? login)
+    participants.set(delegate, participants.get(delegate) ?? delegate)
   }
-  const approvals = []
-  const blockers = []
+  participants.delete(pull.author.toLowerCase())
+  const writers = new Set()
   const ignoredReviewers = []
-  for (const { login, state, permission } of permissions) {
-    if (!WRITABLE_PERMISSIONS.has(permission)) {
-      ignoredReviewers.push(login)
-    } else if (state === 'CHANGES_REQUESTED') {
-      blockers.push(login)
-    } else {
-      approvals.push({
-        login,
-        points: policy.reviewerPoints.get(login.toLowerCase()) ?? policy.defaultPoints,
-      })
-    }
+  for (const [key, login] of participants) {
+    const permission = await reviewerPermission(api, pull.repository, login)
+    if (WRITABLE_PERMISSIONS.has(permission)) writers.add(key)
+    else ignoredReviewers.push(login)
+  }
+  const approvals = []
+  const activeDelegations = []
+  const blockers = decisions.filter(({ login, state }) => writers.has(login.toLowerCase()) && state === 'CHANGES_REQUESTED')
+    .map(({ login }) => login)
+  const approved = new Set(decisions.filter(({ state }) => state === 'APPROVED').map(({ login }) => login.toLowerCase()))
+  for (const key of writers) {
+    const delegation = delegations.get(key)
+    const target = delegation?.delegate
+    const delegatedTo = writers.has(target) ? target : undefined
+    if (delegatedTo) activeDelegations.push({
+      login: participants.get(key), delegatedTo: participants.get(delegatedTo), commentId: delegation.commentId,
+    })
+    if (!approved.has(delegatedTo ?? key)) continue
+    approvals.push({
+      login: participants.get(key),
+      points: policy.reviewerPoints.get(key) ?? policy.defaultPoints,
+      ...(delegatedTo ? { delegatedTo: participants.get(delegatedTo) } : {}),
+    })
   }
   let authorCredit = null
   const reviewerPoints = approvals.reduce((sum, approval) => sum + approval.points, 0)
@@ -190,7 +243,7 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
   }, authorCredit?.points ?? 0)
   if (blockers.length > 0) {
     return approvalResult(pull, policy.requiredPoints, approvals, blockers, ignoredReviewers, 'pending',
-      `${blockers.length} blocking change request${blockers.length === 1 ? '' : 's'}`, authorCredit)
+      `${blockers.length} blocking change request${blockers.length === 1 ? '' : 's'}`, authorCredit, activeDelegations)
   }
   // Tolerate floating-point addition error without rounding approval scores.
   const state = points + 1e-12 >= policy.requiredPoints ? 'success' : 'pending'
@@ -203,6 +256,7 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
     state,
     `${Number(points.toFixed(3))}/${policy.requiredPoints} approval points${authorCredit ? ` (author ${authorCredit.points})` : ''}`,
     authorCredit,
+    activeDelegations,
   )
 }
 
@@ -217,6 +271,7 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
   let result
   try {
     result = await evaluateApproval({ event, policySource, api, getOwnership, getMergedCount })
+    await requestDelegatedReview(event, result, api, write)
   } catch (error) {
     await publishStatus(api, pull, 'error', 'Approval evaluation failed.', runUrl)
     throw error
@@ -225,8 +280,8 @@ export async function runApprovalCheck({ event, policySource, api, runUrl, getOw
     ? `Author credit: ${result.authorCredit.points} (${result.authorCredit.mergedCount} merged PRs).`
     : `Author credit: not evaluated (${pull.draft ? 'draft' : result.blockers.length ? 'blocking review' : 'reviewer points suffice'}).`)
   write(`Approval score: ${result.points}/${result.requiredPoints}.`)
-  writeList(write, 'Counted approvals', result.approvals.map(({ login, points, ownership }) =>
-    `@${login}: ${points}${ownership ? ` (${ownership.ownedLines}/${ownership.totalLines} old production lines)` : ''}`))
+  writeList(write, 'Counted approvals', result.approvals.map(({ login, points, ownership, delegatedTo }) =>
+    `@${login}: ${points}${delegatedTo ? ` (delegated to @${delegatedTo})` : ''}${ownership ? ` (${ownership.ownedLines}/${ownership.totalLines} old production lines)` : ''}`))
   writeList(write, 'Blocking change requests', result.blockers.map(login => `@${login}`))
   writeList(write, 'Ignored reviewers without write access', result.ignoredReviewers.map(login => `@${login}`))
   await publishStatus(api, pull, result.state, result.description, runUrl)
@@ -273,7 +328,41 @@ export async function approvalEventFromWorkflowRun({ event, api }) {
   return { ...event, pull_request: pull }
 }
 
-function approvalResult(pull, requiredPoints, approvals, blockers, ignoredReviewers, state, detail, authorCredit = null) {
+/**
+ * Resolve a PR conversation comment to the current pull request; ordinary issues are skipped.
+ * @param {{event: unknown, api: (path: string) => Promise<unknown>}} options Comment event and API caller.
+ * @returns {Promise<Record<string, unknown> | null>} Current PR event, or null for an issue or closed PR.
+ */
+export async function approvalEventFromComment({ event, api }) {
+  const repository = repositoryFromEvent(event)
+  if (!isRecord(event.issue)) throw new Error('comment event has no issue')
+  if (!isRecord(event.issue.pull_request)) return null
+  const number = event.issue.number
+  if (!Number.isSafeInteger(number) || number <= 0) throw new Error('comment event has no valid pull-request number')
+  const pull = await api(`/repos/${repository}/pulls/${number}`)
+  if (!isRecord(pull) || pull.number !== number || !['open', 'closed'].includes(pull.state)) {
+    throw new Error('comment pull-request response is invalid')
+  }
+  if (pull.state === 'closed') return null
+  const resolved = { ...event, pull_request: pull }
+  pullRequestFromEvent(resolved)
+  return resolved
+}
+
+async function requestDelegatedReview(event, result, api, write) {
+  if (!isRecord(event.issue) || !['created', 'edited'].includes(event.action) || !isRecord(event.comment)) return
+  const delegation = result.delegations.find(({ commentId }) => commentId === event.comment.id)
+  if (!delegation) return
+  const path = `/repos/${result.pull.repository}/pulls/${result.pull.number}/requested_reviewers`
+  const requested = await api(path)
+  if (!isRecord(requested) || !Array.isArray(requested.users)) throw new Error('requested reviewers response has no users array')
+  const logins = requested.users.map(user => validateLogin(user?.login, 'requested reviewer').toLowerCase())
+  if (logins.includes(delegation.delegatedTo.toLowerCase())) return
+  await api(path, { method: 'POST', body: { reviewers: [delegation.delegatedTo] } })
+  write(`Requested review from @${delegation.delegatedTo} for @${delegation.login}'s delegation.`)
+}
+
+function approvalResult(pull, requiredPoints, approvals, blockers, ignoredReviewers, state, detail, authorCredit = null, delegations = []) {
   return {
     pull: { repository: pull.repository, number: pull.number, headSha: pull.headSha },
     state,
@@ -282,6 +371,7 @@ function approvalResult(pull, requiredPoints, approvals, blockers, ignoredReview
     authorCredit,
     requiredPoints,
     approvals,
+    delegations,
     blockers,
     ignoredReviewers,
   }
@@ -391,6 +481,10 @@ async function main() {
     token: process.env.GITHUB_TOKEN ?? '',
     apiUrl: process.env.GITHUB_API_URL,
   })
+  if (isRecord(event) && isRecord(event.issue)) {
+    event = await approvalEventFromComment({ event, api })
+    if (event === null) return
+  }
   if (isRecord(event) && isRecord(event.workflow_run)) {
     const resolved = await approvalEventFromWorkflowRun({
       event,

+ 305 - 3
.github/review-ownership/check-approval.test.mjs

@@ -4,6 +4,7 @@ import test from 'node:test'
 
 import {
   approvalEventFromWorkflowRun,
+  approvalEventFromComment,
   createGitHubApi,
   effectiveReviewDecisions,
   evaluateApproval as evaluateWithHistory,
@@ -14,8 +15,9 @@ import {
 } from './check-approval.mjs'
 
 const policySource = readFileSync(new URL('approval-policy.json', import.meta.url), 'utf8')
-const evaluateApproval = options => evaluateWithHistory({ getMergedCount: async () => 0, ...options })
-const runApprovalCheck = options => runWithHistory({ getMergedCount: async () => 0, ...options })
+const withoutComments = api => (path, options) => path.includes('/comments?') ? [] : api(path, options)
+const evaluateApproval = options => evaluateWithHistory({ getMergedCount: async () => 0, ...options, api: withoutComments(options.api) })
+const runApprovalCheck = options => runWithHistory({ getMergedCount: async () => 0, ...options, api: withoutComments(options.api) })
 const HEAD_SHA = '1234567890abcdef1234567890abcdef12345678'
 
 const pullRequestEvent = ({ author = 'author', draft = false } = {}) => ({
@@ -28,7 +30,7 @@ const pullRequestEvent = ({ author = 'author', draft = false } = {}) => ({
   },
 })
 
-const review = (login, state) => ({ user: { login }, state })
+const review = (login, state, submitted_at = '2026-09-14T00:00:00Z') => ({ user: { login }, state, submitted_at })
 
 test('loads the repository approval score policy', () => {
   const policy = parseApprovalPolicy(policySource)
@@ -419,6 +421,7 @@ test('publishes error when production attribution fails', async () => {
     api: async (path, options) => {
       if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
       if (path.includes('/permission')) return { permission: 'write' }
+      if (path.includes('/comments?')) return []
       states.push(options.body.state)
       return {}
     },
@@ -555,6 +558,7 @@ test('the publisher counts merged history through the production API path', asyn
     event, policySource, runUrl: 'https://github.example/run/1', write: line => output.push(line),
     getOwnership: async () => ({ totalLines: 8, reviewerLines: { writer: 1 } }),
     api: async (path, options) => {
+      if (path.includes('/comments?')) return []
       if (path === '/graphql') {
         assert.equal(options.body.variables.owner, 'deepseek-harness')
         return { data: { repository: { pullRequests: {
@@ -587,6 +591,7 @@ test('sufficient reviewer points and drafts publish without querying author hist
         assert.notEqual(path, '/graphql')
         if (path.includes('/reviews?')) return [review('turtle2099', 'APPROVED')]
         if (path.includes('/permission')) return { permission: 'write' }
+        if (path.includes('/comments?')) return []
         return {}
       },
     })
@@ -617,3 +622,300 @@ test('bot authors receive the same history credit', async () => {
   assert.equal(result.authorCredit.points, 0.6)
   assert.equal(result.state, 'success')
 })
+
+const comment = (login, body, created_at = '2026-09-15T00:00:00Z', id = 1) => ({ user: { login }, body, created_at, id })
+
+function delegationApi({ comments = [], reviews = [], permissions = {} } = {}) {
+  return async path => {
+    if (path.includes('/comments?')) return comments
+    if (path.includes('/reviews?')) return reviews
+    const match = /\/collaborators\/([^/]+)\/permission$/u.exec(path)
+    if (match) return { permission: permissions[decodeURIComponent(match[1]).toLowerCase()] ?? 'write' }
+    throw new Error(`unexpected API path ${path}`)
+  }
+}
+
+const evaluateDelegation = options => evaluateWithHistory({
+  event: pullRequestEvent(), policySource,
+  getMergedCount: async () => 0,
+  getOwnership: async () => ({ totalLines: 0, reviewerLines: {} }),
+  ...options,
+})
+
+test('delegates the sender fixed points once after the recipient approves, retaining both blockers', async () => {
+  for (const senderState of [undefined, 'APPROVED', 'CHANGES_REQUESTED']) {
+    for (const recipientState of [undefined, 'APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED']) {
+      const result = await evaluateDelegation({ api: delegationApi({
+        comments: [comment('Turtle1999', '/delegate @Writer')],
+        reviews: [
+          ...(senderState ? [review('turtle1999', senderState)] : []),
+          ...(recipientState ? [review('writer', recipientState)] : []),
+        ],
+      }) })
+      const points = recipientState === 'APPROVED' ? 3 : 0
+      assert.equal(result.points, points)
+      assert.equal(result.state, points === 3 && senderState !== 'CHANGES_REQUESTED' ? 'success' : 'pending')
+      assert.deepEqual(result.blockers, [
+        ...(senderState === 'CHANGES_REQUESTED' ? ['turtle1999'] : []),
+        ...(recipientState === 'CHANGES_REQUESTED' ? ['writer'] : []),
+      ])
+      if (points) assert.deepEqual(result.approvals[0], { login: 'turtle1999', points: 2, delegatedTo: 'writer' })
+    }
+  }
+})
+
+test('uses the newest surviving command and restores previous commands after edits or deletion', async () => {
+  const first = comment('turtle1999', '/delegate @writer')
+  for (const [comments, points] of [
+    [[first], 3],
+    [[first, comment('turtle1999', '/delegate @other')], 1],
+    [[first, comment('turtle1999', '/delegate @TURTLE1999')], 1],
+    [[comment('turtle1999', '/delegate @other')], 1],
+    [[first, comment('turtle1999', 'edited to ordinary text')], 3],
+    [[], 1],
+  ]) {
+    const result = await evaluateDelegation({ api: delegationApi({ comments, reviews: [review('writer', 'APPROVED')] }) })
+    assert.equal(result.points, points)
+  }
+  const restored = await evaluateDelegation({ api: delegationApi({
+    comments: [first, comment('turtle1999', '/delegate @turtle1999')],
+    reviews: [review('turtle1999', 'APPROVED')],
+  }) })
+  assert.deepEqual(restored.approvals, [{ login: 'turtle1999', points: 2 }])
+})
+
+test('commands must occupy the entire conversation comment', async () => {
+  for (const body of ['> /delegate @writer', '```\n/delegate @writer\n```', '/delegate @writer extra',
+    'Please /delegate @writer', '/delegate writer', '/delegate @bad_user', '/delegate @writer\n/delegate @other']) {
+    const result = await evaluateDelegation({ api: delegationApi({
+      comments: [comment('turtle1999', body)], reviews: [review('writer', 'APPROVED')],
+    }) })
+    assert.equal(result.points, 1, body)
+  }
+  const result = await evaluateDelegation({ api: delegationApi({
+    comments: [comment('turtle1999', '\n/delegate @writer\r\n'), { user: null, body: '/delegate @writer' }],
+    reviews: [review('writer', 'APPROVED')],
+  }) })
+  assert.equal(result.points, 3)
+})
+
+test('both delegation participants need current write access and neither may be the PR author', async () => {
+  for (const [sender, target, permissions, expectedPoints] of [
+    ['reader', 'writer', { reader: 'read' }, 1],
+    ['turtle1999', 'writer', { turtle1999: 'none' }, 1],
+    ['turtle1999', 'writer', { writer: 'read' }, 0],
+    ['author', 'writer', {}, 1],
+    ['turtle1999', 'author', {}, 1],
+  ]) {
+    const result = await evaluateDelegation({ api: delegationApi({
+      comments: [comment(sender, `/delegate @${target}`)],
+      reviews: [review('writer', 'APPROVED'), review('author', 'APPROVED')], permissions,
+    }) })
+    assert.equal(result.points, expectedPoints)
+  }
+  const ignoredTarget = await evaluateDelegation({ api: delegationApi({
+    comments: [comment('turtle1999', '/delegate @reader')], reviews: [review('turtle1999', 'APPROVED')],
+    permissions: { reader: 'read' },
+  }) })
+  assert.equal(ignoredTarget.points, 2)
+})
+
+test('delegated points retain the sender production ownership and transfer only one hop', async () => {
+  const api = delegationApi({
+    comments: [comment('owner', '/delegate @writer'), comment('writer', '/delegate @waiting')],
+    reviews: [review('writer', 'APPROVED')],
+  })
+  const result = await evaluateDelegation({ api,
+    getOwnership: async () => ({ totalLines: 8, reviewerLines: { owner: 1, writer: 7 } }),
+  })
+  assert.equal(result.points, 1.5)
+  assert.deepEqual(result.approvals, [{ login: 'owner', points: 1.5, delegatedTo: 'writer', ownership: { ownedLines: 1, totalLines: 8 } }])
+  const chain = await evaluateDelegation({ api: delegationApi({
+    comments: [comment('owner', '/delegate @writer'), comment('writer', '/delegate @waiting')],
+    reviews: [review('waiting', 'APPROVED')],
+  }) })
+  assert.deepEqual(chain.approvals.map(({ login }) => login), ['waiting', 'writer'])
+})
+
+test('cycles do not create approvals and repeated commands do not multiply points', async () => {
+  const comments = [comment('one', '/delegate @two'), comment('one', '/delegate @two'), comment('two', '/delegate @one')]
+  for (const reviews of [[], [review('one', 'APPROVED'), review('two', 'APPROVED')]]) {
+    const result = await evaluateDelegation({ api: delegationApi({ comments, reviews }) })
+    assert.equal(result.points, reviews.length)
+  }
+})
+
+test('recipient dismissal revokes delegated approvals and comments do not reinstate them', async () => {
+  const result = await evaluateDelegation({ api: delegationApi({
+    comments: [comment('turtle1999', '/delegate @writer')],
+    reviews: [review('writer', 'APPROVED'), review('writer', 'DISMISSED'), review('writer', 'COMMENTED')],
+  }) })
+  assert.equal(result.points, 0)
+})
+
+test('a sender submitted review takes back delegation, including comment-only and dismissed reviews', async () => {
+  for (const state of ['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING']) {
+    for (const submittedAt of ['2026-09-15T00:00:00Z', '2026-09-16T00:00:00Z']) {
+      const result = await evaluateDelegation({ api: delegationApi({
+        comments: [comment('turtle1999', '/delegate @writer')],
+        reviews: [review('writer', 'APPROVED'), review('Turtle1999', state, state === 'PENDING' ? null : submittedAt)],
+      }) })
+      assert.equal(result.delegations.length, state === 'PENDING' ? 1 : 0)
+      assert.equal(result.points, state === 'APPROVED' || state === 'PENDING' ? 3 : 1)
+      assert.equal(result.state, state === 'APPROVED' || state === 'PENDING' ? 'success' : 'pending')
+      if (state === 'APPROVED') assert.equal(result.approvals.find(({ login }) => login === 'Turtle1999').delegatedTo, undefined)
+    }
+  }
+})
+
+test('a fresh delegation after a review works, while editing an older command does not reactivate it', async () => {
+  const reviews = [review('writer', 'APPROVED'), review('turtle1999', 'COMMENTED', '2026-09-16T00:00:00Z')]
+  const old = { ...comment('turtle1999', '/delegate @writer'), updated_at: '2026-09-18T00:00:00Z' }
+  for (const [comments, expected] of [
+    [[old], 1],
+    [[old, comment('turtle1999', '/delegate @writer', '2026-09-17T00:00:00Z', 2)], 3],
+  ]) {
+    const result = await evaluateDelegation({ api: delegationApi({ comments, reviews }) })
+    assert.equal(result.points, expected)
+  }
+})
+
+test('missing command identity or review timing fails evaluation instead of preserving delegation', async () => {
+  for (const [comments, reviews, message] of [
+    [[{ ...comment('turtle1999', '/delegate @writer'), id: undefined }], [], /valid ID/u],
+    [[{ ...comment('turtle1999', '/delegate @writer'), created_at: 'invalid' }], [], /valid timestamp/u],
+    [[comment('turtle1999', '/delegate @writer')], [review('turtle1999', 'COMMENTED', null)], /valid timestamp/u],
+  ]) {
+    await assert.rejects(evaluateDelegation({ api: delegationApi({ comments, reviews }) }), message)
+  }
+})
+
+test('an active delegate command requests review once and preserves other requested reviewers', async () => {
+  for (const action of ['created', 'edited']) {
+    for (const alreadyRequested of [false, true]) {
+      const requests = []
+      const output = []
+      const api = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')] })
+      const result = await runWithHistory({
+        event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action, comment: { id: 1 } },
+        policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0,
+        write: line => output.push(line),
+        api: async (path, options) => {
+          if (path.endsWith('/requested_reviewers')) {
+            if (options?.method === 'POST') { requests.push(options.body); return {} }
+            return { users: [{ login: 'another-reviewer' }, ...(alreadyRequested ? [{ login: 'WRITER' }] : [])], teams: [] }
+          }
+          if (path.includes('/statuses/')) return {}
+          return api(path)
+        },
+      })
+      assert.equal(result.points, 0)
+      assert.deepEqual(requests, alreadyRequested ? [] : [{ reviewers: ['writer'] }])
+      assert.equal(output.includes("Requested review from @writer for @turtle1999's delegation."), !alreadyRequested)
+    }
+  }
+})
+
+test('inactive commands, drafts, and non-comment events do not request review', async () => {
+  const command = comment('turtle1999', '/delegate @writer')
+  for (const scenario of [
+    { event: {} },
+    { event: { issue: { number: 42, pull_request: {} }, action: 'deleted', comment: { id: 1 } } },
+    { comments: [comment('turtle1999', '/delegate @turtle1999')] },
+    { comments: [command, comment('turtle1999', '/delegate @other', '2026-09-16T00:00:00Z', 2)] },
+    { reviews: [review('turtle1999', 'COMMENTED', '2026-09-16T00:00:00Z')] },
+    { permissions: { turtle1999: 'read' } },
+    { permissions: { writer: 'read' } },
+    { draft: true },
+  ]) {
+    const api = delegationApi({ comments: [command], ...scenario })
+    await runWithHistory({
+      event: { ...pullRequestEvent({ draft: scenario.draft }),
+        ...(scenario.event ?? { issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } }),
+      },
+      policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, write: () => {},
+      api: async (path, options) => {
+        assert.ok(!path.endsWith('/requested_reviewers'))
+        if (path.includes('/statuses/')) return {}
+        return api(path, options)
+      },
+    })
+  }
+})
+
+test('failed review requests publish an error status', async () => {
+  for (const failedRead of [false, true]) {
+    const statuses = []
+    const api = delegationApi({ comments: [comment('turtle1999', '/delegate @writer')] })
+    await assert.rejects(runWithHistory({
+      event: { ...pullRequestEvent(), issue: { number: 42, pull_request: {} }, action: 'created', comment: { id: 1 } },
+      policySource, runUrl: 'https://github.example/run/1', getMergedCount: async () => 0, write: () => {},
+      api: async (path, options) => {
+        if (path.includes('/statuses/')) { statuses.push(options.body.state); return {} }
+        if (path.endsWith('/requested_reviewers')) {
+          if (options?.method === 'POST') throw new Error('request rejected')
+          return failedRead ? {} : { users: [] }
+        }
+        return api(path)
+      },
+    }), failedRead ? /no users array/u : /request rejected/u)
+    assert.deepEqual(statuses, ['pending', 'error'])
+  }
+})
+
+test('reads all comment pages, logs the score owner, and fails closed on missing comment history', async () => {
+  const statuses = []
+  const output = []
+  const api = delegationApi({ reviews: [review('writer', 'APPROVED')] })
+  const run = comments => runWithHistory({
+    event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1',
+    getMergedCount: async () => 0, write: line => output.push(line),
+    api: async (path, options) => {
+      if (path.includes('/statuses/')) { statuses.push(options.body.state); return {} }
+      if (path.includes('/comments?')) return comments(path)
+      return api(path)
+    },
+  })
+  const pages = []
+  await run(path => {
+    pages.push(path)
+    return path.endsWith('page=1') ? Array.from({ length: 100 }, () => comment('writer', 'text'))
+      : [comment('turtle1999', '/delegate @writer')]
+  })
+  assert.equal(pages.length, 2)
+  assert.ok(output.includes('- @turtle1999: 2 (delegated to @writer)'))
+  assert.deepEqual(statuses.splice(0), ['pending', 'success'])
+  for (const [comments, message] of [
+    [() => { throw new Error('comment API unavailable') }, /comment API unavailable/u],
+    [() => ({}), /comments response is not an array/u],
+    [() => [null], /comment is not an object/u],
+    [() => [{ user: { login: 'writer' } }], /comment has no body/u],
+    [() => [{ user: {}, body: '/delegate @writer' }], /delegation author has an invalid login/u],
+    [() => Array.from({ length: 100 }, () => comment('writer', 'text')), /comments exceed 3000/u],
+  ]) {
+    await assert.rejects(run(comments), message)
+    assert.deepEqual(statuses.splice(0), ['pending', 'error'])
+  }
+})
+
+test('comment events resolve the live PR head and skip ordinary issues and closed PRs', async () => {
+  const event = { repository: pullRequestEvent().repository, issue: { number: 42, pull_request: {} } }
+  const pull = { ...pullRequestEvent().pull_request, state: 'open' }
+  const resolved = await approvalEventFromComment({ event, api: async path => {
+    assert.equal(path, '/repos/deepseek-harness/deepseek-harness/pulls/42')
+    return pull
+  } })
+  assert.deepEqual(resolved.pull_request, pull)
+  assert.equal(await approvalEventFromComment({ event: { ...event, issue: { number: 42 } },
+    api: async () => assert.fail('ordinary issues must not fetch a PR'),
+  }), null)
+  assert.equal(await approvalEventFromComment({ event, api: async () => ({ ...pull, state: 'closed' }) }), null)
+  for (const number of [0, -1, '42', Number.MAX_SAFE_INTEGER + 1]) {
+    await assert.rejects(approvalEventFromComment({ event: { ...event, issue: { ...event.issue, number } },
+      api: async () => assert.fail('invalid number must not call GitHub'),
+    }), /valid pull-request number/u)
+  }
+  for (const response of [null, { ...pull, number: 1 }, { ...pull, state: 'unknown' }, { ...pull, head: {} }]) {
+    await assert.rejects(approvalEventFromComment({ event, api: async () => response }))
+  }
+})

+ 6 - 4
.github/workflows/weighted-approval.yml

@@ -3,28 +3,30 @@ name: weighted-approval
 on:
   pull_request_target:
     types: [opened, synchronize, reopened, ready_for_review, converted_to_draft, edited]
+  issue_comment:
+    types: [created, edited, deleted]
   workflow_run:
     workflows: [weighted-approval-review-event]
     types: [completed]
 
 permissions:
   contents: read
-  pull-requests: read
+  pull-requests: write
   statuses: write
 
 concurrency:
-  group: weighted-approval-${{ github.event.pull_request.number && format('weighted-approval-review-event:{0}', github.event.pull_request.number) || github.event.workflow_run.display_title }}
+  group: weighted-approval-${{ (github.event.pull_request.number || github.event.issue.number) && format('weighted-approval-review-event:{0}', github.event.pull_request.number || github.event.issue.number) || github.event.workflow_run.display_title }}
   cancel-in-progress: false
 
 jobs:
   publish-status:
-    if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
+    if: (github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') && (github.event_name != 'issue_comment' || github.event.issue.pull_request)
     name: weighted approval publisher
     runs-on: ubuntu-latest
     timeout-minutes: 5
     steps:
       # SECURITY: the status-writing job executes policy from the trusted default
-      # branch and reads pull-request reviews only as API data.
+      # branch and reads pull-request reviews and comments only as API data.
       - name: Check out trusted approval policy
         uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
         with:

+ 5 - 4
scripts/ci-workflow.spec.ts

@@ -916,7 +916,8 @@ describe('Weighted approval workflow', () => {
     const record = recordSteps.find(step => step.name === 'Record review event')
 
     expect(publisher.name).toBe('weighted-approval')
-    expect(Object.keys(publisher.on)).toEqual(['pull_request_target', 'workflow_run'])
+    expect(Object.keys(publisher.on)).toEqual(['pull_request_target', 'issue_comment', 'workflow_run'])
+    expect(workflowEvent(publisher, 'issue_comment').types).toEqual(['created', 'edited', 'deleted'])
     expect(pullRequest.types).toEqual(['opened', 'synchronize', 'reopened', 'ready_for_review', 'converted_to_draft', 'edited'])
     expect(workflowRun).toEqual({ workflows: ['weighted-approval-review-event'], types: ['completed'] })
     expect(reviewEvent.name).toBe('weighted-approval-review-event')
@@ -926,15 +927,15 @@ describe('Weighted approval workflow', () => {
     expect(reviewEvent.permissions).toEqual({})
     expect(publisher.permissions).toEqual({
       contents: 'read',
-      'pull-requests': 'read',
+      'pull-requests': 'write',
       statuses: 'write',
     })
     expect(publisher.concurrency).toEqual({
-      group: "weighted-approval-${{ github.event.pull_request.number && format('weighted-approval-review-event:{0}', github.event.pull_request.number) || github.event.workflow_run.display_title }}",
+      group: "weighted-approval-${{ (github.event.pull_request.number || github.event.issue.number) && format('weighted-approval-review-event:{0}', github.event.pull_request.number || github.event.issue.number) || github.event.workflow_run.display_title }}",
       'cancel-in-progress': false,
     })
     expect(job).toMatchObject({
-      if: "github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'",
+      if: "(github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success') && (github.event_name != 'issue_comment' || github.event.issue.pull_request)",
       name: 'weighted approval publisher',
       'runs-on': 'ubuntu-latest',
       'timeout-minutes': 5,