فهرست منبع

Raise author approval credit to 1.1 at 100 merged PRs

Turtle 1 هفته پیش
والد
کامیت
c7e7e8bc18

+ 6 - 0
.agents/notes/implemented/process/2026-09-16-author-approval-credit.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-16-author-approval-credit.md
+2026-09-16-author-approval-credit.md: ab7aba88801271c22a0f054d5b3888726b94e97a
+2026-09-16-author-approval-credit.zh.md: 316a09ea5d82f78915a9550fde7f29bd55761e44

+ 25 - 0
.agents/notes/implemented/process/2026-09-16-author-approval-credit.md

@@ -0,0 +1,25 @@
+# Agent Note: Author approval credit
+
+Status: implemented
+
+English | [中文](2026-09-16-author-approval-credit.zh.md)
+
+## Problem
+
+The approval policy needs author experience to contribute enough points for an established author and one ordinary reviewer to meet the two-point requirement.
+
+## Decision
+
+The [approval policy](../../../../.github/review-ownership/README.md) awards 0.011 points per merged PR in this repository, capped at 1.1 points after 100 PRs. History lookup stops at 100 matching PRs. Author credit alone remains insufficient, and blocking reviews still prevent approval.
+
+## Alternatives considered
+
+**Keep the 0.6-point cap at 150 PRs.** That cap cannot combine with an ordinary one-point review to meet the requirement without an ownership boost.
+
+## Consequences
+
+One ordinary approval with no ownership boost meets the requirement at 91 merged PRs; 90 yields only 1.99 points. The lower counting cap limits required history traversal for prolific authors. Merged PR count measures contribution history rather than review quality.
+
+## Verification
+
+Policy tests cover credit below, at, and above the cap; the 90/91-PR approval threshold; author-only rejection; pagination; and below-threshold scores whose display rounds to two.

+ 25 - 0
.agents/notes/implemented/process/2026-09-16-author-approval-credit.zh.md

@@ -0,0 +1,25 @@
+# Agent Note: 作者审批积分
+
+Status: implemented
+
+[English](2026-09-16-author-approval-credit.md) | 中文
+
+## Problem
+
+审批策略需要让作者经验贡献足够的积分,使有经验的作者与一位普通审阅者能够共同满足两分要求。
+
+## Decision
+
+[审批策略](../../../../.github/review-ownership/README.md)为作者在本仓库中每个已合并 PR 计入 0.011 分,达到 100 个 PR 时以 1.1 分封顶。历史查询在找到 100 个匹配 PR 后停止。仅靠作者积分仍不足以通过审批,阻塞性审阅仍会阻止通过。
+
+## Alternatives considered
+
+**保留 150 个 PR 时封顶 0.6 分的规则。** 在没有代码所有权加分的情况下,该上限与普通审阅的一分相加仍无法满足要求。
+
+## Consequences
+
+没有代码所有权加分时,一次普通批准与 91 个已合并 PR 的作者积分相加即可满足要求;90 个则只有 1.99 分。较低的计数上限减少了高贡献作者所需的历史遍历。已合并 PR 数量衡量贡献历史,而非审阅质量。
+
+## Verification
+
+策略测试覆盖积分低于、达到及超过上限的情况、90/91 个 PR 的审批阈值、仅靠作者积分不能通过、分页,以及显示值舍入为两分但实际低于阈值的情况。

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

@@ -20,7 +20,7 @@ The weighted approval workflow exposes two pull-request checks. The `weighted ap
 
 Reviewers whose calculated base repository permission is `write` or `admin` count. The [approval policy](approval-policy.json) gives `@07akioni`, `@imccyu`, `@tianyicui`, `@tianyicui-bot`, `@turtle1999`, and `@turtle2099` two points each; every other write-capable reviewer gets one point. The pull-request author’s own review and reviewers without write permission do not count.
 
-The PR author contributes `min(0.6, mergedPRCount / 250)` points: 0 merged PRs → 0 points, 100 → 0.4, and 150 or more → 0.6. The publisher counts only merged PRs in this repository using the author’s immutable account ID, excluding the current PR. It stops at 150 matches and rejects incomplete history responses. Counts refresh at the next subscribed evaluation event. Author credit is separate from reviewer approvals and cannot satisfy the two-point requirement alone; the author’s own review remains excluded. Human and bot authors, including Dependabot, use the same rule. Drafts, blocking reviews, and sufficient reviewer points skip history lookup; logs mark credit as not evaluated. Otherwise, logs show author credit and the capped count separately. Below the cap, counting may scan the repository’s entire merged history; a failed required lookup publishes an error.
+The PR author contributes `min(100, mergedPRCount) × 11 / 1000` points: 0 merged PRs → 0 points, 50 → 0.55, and 100 or more → 1.1. The publisher counts only merged PRs in this repository using the author’s immutable account ID, excluding the current PR. It stops at 100 matches and rejects incomplete history responses. Counts refresh at the next subscribed evaluation event. Author credit is separate from reviewer approvals and cannot satisfy the two-point requirement alone; the author’s own review remains excluded. Human and bot authors, including Dependabot, use the same rule. Drafts, blocking reviews, and sufficient reviewer points skip history lookup; logs mark credit as not evaluated. Otherwise, logs show author credit and the capped count separately. Below the cap, counting may scan the repository’s entire merged history; a failed required lookup publishes an error.
 
 A one-point approval receives weight `min(2, 1 + 4 × ownedLines / totalLines)` from modified or deleted old production-code lines, attributed by `git blame` at the merge base of the live base branch and exact reviewed head. Ownership of 0%, 12.5%, and 25% gives 1, 1.5, and 2 points; higher ownership remains capped at 2. The success threshold remains 2 total points, without rounding the score. New lines do not enter the denominator, and an empty denominator gives no boost. Existing two-point weights remain unchanged. GitHub commit-author accounts identify reviewers across author emails; unlinked authors remain in the denominator without contributing to a reviewer. The publisher logs measured ownership. It skips attribution when reviewer points plus author credit already meet the threshold or a blocking review exists. Displayed scores use at most three decimal places; the decision uses unrounded scores with a tolerance of `1e-12` points for floating-point error. The curve endpoints come from the policy’s default and required points.
 

+ 4 - 4
.github/review-ownership/author-weight.mjs

@@ -1,20 +1,20 @@
 /** Author credit from merged pull requests in the same repository. */
-const CAP_COUNT = 150
+const CAP_COUNT = 100
 
 /**
- * Convert merged PR count to author points, capped at 0.6.
+ * Convert merged PR count to author points, capped at 1.1.
  * @param {number} mergedCount Merged PR count.
  * @returns {number} Author approval points.
  */
 export function authorCreditPoints(mergedCount) {
-  return Math.min(CAP_COUNT, mergedCount) / 250
+  return Math.min(CAP_COUNT, mergedCount) * 11 / 1000
 }
 
 /**
  * Count merged PRs by immutable author account, stopping at the credit cap.
  * @param {{repository: string, number: number, authorId: string}} pull Current pull request.
  * @param {(path: string, options: object) => Promise<unknown>} api GitHub API caller.
- * @returns {Promise<number>} Merged count, capped at 150; incomplete responses reject.
+ * @returns {Promise<number>} Merged count, capped at 100; incomplete responses reject.
  */
 export async function countMergedAuthorPulls(pull, api) {
   const [owner, name] = pull.repository.split('/')

+ 4 - 4
.github/review-ownership/author-weight.test.mjs

@@ -20,16 +20,16 @@ test('counts only the same account and excludes the current PR and deleted accou
   assert.equal(count, 1)
 })
 
-test('follows cursors, deduplicates overlapping pages, and stops at 150', async () => {
+test('follows cursors, deduplicates overlapping pages, and stops at 100', async () => {
   let calls = 0
   const count = await countMergedAuthorPulls(pull, async (path, { body }) => {
     calls++
-    if (calls === 1) return page(Array.from({ length: 100 }, (_, i) => entry(i + 1)), true, 'next')
+    if (calls === 1) return page(Array.from({ length: 100 }, (_, i) => entry(i + 1, i < 50 ? pull.authorId : 'another-id')), true, 'next')
     assert.equal(calls, 2)
     assert.equal(body.variables.after, 'next')
-    return page(Array.from({ length: 100 }, (_, i) => entry(i + 100)), true, 'unused')
+    return page(Array.from({ length: 100 }, (_, i) => entry(i + 51)), true, 'unused')
   })
-  assert.equal(count, 150)
+  assert.equal(count, 100)
   assert.equal(calls, 2)
 })
 

+ 27 - 12
.github/review-ownership/check-approval.test.mjs

@@ -485,7 +485,7 @@ test('publishes setup phases without evaluating or installing dependencies', asy
   await assert.rejects(publishApprovalPhase({ ...options, phase: 'success' }), /invalid approval setup phase/u)
 })
 
-for (const [mergedCount, credit] of [[0, 0], [1, 0.004], [50, 0.2], [100, 0.4], [125, 0.5], [150, 0.6], [200, 0.6]]) {
+for (const [mergedCount, credit] of [[0, 0], [1, 0.011], [50, 0.55], [99, 1.089], [100, 1.1], [101, 1.1], [200, 1.1]]) {
   test(`author with ${mergedCount} merged PRs contributes ${credit} points but cannot approve alone`, async () => {
     const result = await evaluateApproval({
       event: pullRequestEvent(), policySource, getMergedCount: async () => mergedCount,
@@ -499,16 +499,31 @@ for (const [mergedCount, credit] of [[0, 0], [1, 0.004], [50, 0.2], [100, 0.4],
 }
 
 test('combines author credit and reviewer ownership at the passing threshold', async () => {
-  for (let mergedCount = 0; mergedCount <= 150; mergedCount++) {
+  for (let mergedCount = 0; mergedCount <= 90; mergedCount++) {
     const result = await evaluateApproval({
       event: pullRequestEvent(), policySource, getMergedCount: async () => mergedCount,
-      getOwnership: async () => ({ totalLines: 1000, reviewerLines: { writer: 250 - mergedCount } }),
+      getOwnership: async () => ({ totalLines: 1000000, reviewerLines: { writer: 250000 - 2750 * mergedCount } }),
       api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' },
     })
     assert.equal(result.state, 'success', `author merged ${mergedCount}`)
   }
 })
 
+for (const [mergedCount, state] of [[90, 'pending'], [91, 'success'], [100, 'success']]) {
+  test(`one ordinary approval with ${mergedCount} merged PRs is ${state}`, async () => {
+    const result = await evaluateApproval({
+      event: pullRequestEvent(), policySource, getMergedCount: async () => mergedCount,
+      getOwnership: async () => {
+        assert.equal(mergedCount, 90, 'sufficient author credit must skip ownership lookup')
+        return { totalLines: 0, reviewerLines: {} }
+      },
+      api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' },
+    })
+    assert.equal(result.state, state)
+    assert.equal(result.approvals[0].points, 1)
+  })
+}
+
 test('blockers skip history and ignore non-write reviewers', async () => {
   const result = await evaluateApproval({
     event: pullRequestEvent(), policySource, getMergedCount: async () => { throw new Error('blockers must skip history') },
@@ -544,8 +559,8 @@ test('drafts skip history and history failures revoke success with error', async
 
 test('a score just below the threshold remains pending even if its display rounds to two', async () => {
   const result = await evaluateApproval({
-    event: pullRequestEvent(), policySource, getMergedCount: async () => 150,
-    getOwnership: async () => ({ totalLines: 1000000, reviewerLines: { writer: 99999 } }),
+    event: pullRequestEvent(), policySource, getMergedCount: async () => 50,
+    getOwnership: async () => ({ totalLines: 1000000, reviewerLines: { writer: 112499 } }),
     api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' },
   })
   assert.equal(result.state, 'pending')
@@ -558,13 +573,13 @@ test('the publisher counts merged history through the production API path', asyn
   const output = []
   const result = await runWithHistory({
     event, policySource, runUrl: 'https://github.example/run/1', write: line => output.push(line),
-    getOwnership: async () => ({ totalLines: 8, reviewerLines: { writer: 1 } }),
+    getOwnership: async () => ({ totalLines: 80, reviewerLines: { writer: 9 } }),
     api: async (path, options) => {
       if (path.includes('/comments?')) return []
       if (path === '/graphql') {
         assert.equal(options.body.variables.owner, 'deepseek-harness')
         return { data: { repository: { pullRequests: {
-          nodes: Array.from({ length: options.body.variables.after ? 25 : 100 }, (_, i) => ({
+          nodes: Array.from({ length: 25 }, (_, i) => ({
             number: i + (options.body.variables.after ? 200 : 100), author: { id: event.pull_request.user.node_id },
           })),
           pageInfo: { hasNextPage: !options.body.variables.after, endCursor: 'next' },
@@ -577,10 +592,10 @@ test('the publisher counts merged history through the production API path', asyn
     },
   })
   assert.equal(result.points, 2)
-  assert.equal(result.authorCredit.points, 0.5)
-  assert.equal(result.approvals[0].points, 1.5)
+  assert.equal(result.authorCredit.points, 0.55)
+  assert.equal(result.approvals[0].points, 1.45)
   assert.deepEqual(states, ['pending', 'success'])
-  assert.equal(output[0], 'Author credit: 0.5 (125 merged PRs).')
+  assert.equal(output[0], 'Author credit: 0.55 (50 merged PRs).')
 })
 
 test('sufficient reviewer points and drafts publish without querying author history', async () => {
@@ -617,11 +632,11 @@ test('bot authors receive the same history credit', async () => {
   const event = pullRequestEvent({ author: 'dependabot[bot]' })
   event.pull_request.user.type = 'Bot'
   const result = await evaluateApproval({
-    event, policySource, getMergedCount: async () => 150,
+    event, policySource, getMergedCount: async () => 100,
     getOwnership: async () => ({ totalLines: 10, reviewerLines: { writer: 1 } }),
     api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' },
   })
-  assert.equal(result.authorCredit.points, 0.6)
+  assert.equal(result.authorCredit.points, 1.1)
   assert.equal(result.state, 'success')
 })