فهرست منبع

feat(ci): add author approval credit from merged PRs

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

+ 6 - 4
.github/review-ownership/README.md

@@ -17,9 +17,11 @@ The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes
 
 The weighted approval workflow exposes two pull-request checks. The `weighted approval publisher` Actions job reports whether evaluation and status publication completed, while the `weighted approval` commit status carries the approval decision on the pull request head. Branch rules must require only the commit status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. The publisher marks the head pending before Python setup or lexer installation, so failed setup or an interrupted history fetch cannot leave a previous success in place. Setup and evaluation failures publish an error status. A completed evaluation returns `pending` below two approval points, while the pull request is a draft, or while a write-capable reviewer has an effective `CHANGES_REQUESTED` review; the blocker keeps the status pending even when counted approvals reach the threshold. It returns `success` only when the threshold is met, the pull request is ready, and no such blocker exists. If evaluation fails, the publisher writes an `error` status.
 
-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 and reviewers without write permission do not count.
+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.
 
-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 base approval points already meet the threshold or a blocking review exists. Displayed scores use at most two decimal places; the decision uses the unrounded score. The curve endpoints come from the policy’s default and required points.
+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.
+
+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.
 
 Production source means supported code files under `src/` in `packages/`, `apps/`, `python/`, and `native/`, plus the Desktop renderer, Python interpreter scripts, and committed runtime/packer launchers. The [classifier](blame-production.py) excludes documentation, tests, fixtures, snapshots, test support (including `src/testing/` and `src/testing.ts`), examples, generated source, dependencies, vendored code, declarations, comments, and blank lines. Pygments lexers distinguish comments from strings; mixed code/comment lines count, as do C preprocessor directives. Classification uses the old path and content, so changes to the PR’s file locations or generated headers cannot remove old lines from the denominator. Pure renames have no changed lines; renames with edits use the old path for blame.
 
@@ -31,7 +33,7 @@ The publisher runs when a pull request opens, synchronizes, reopens, becomes rea
 
 ## 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, base points 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 are treated as API data and escaped in logs.
 
 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.
 
@@ -39,7 +41,7 @@ Approval policy changes take effect only after they merge into the default branc
 
 ## Verification
 
-Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, pagination, permission filtering, weighted scoring, blockers, drafts, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs the approval policy and workflow tests in CI. The Python SDK job runs `uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'` for real Git histories, lexers, renames, shallow-history rejection, and the publisher’s fetch/analysis integration.
+Run `pnpm run test:approval-policy` for policy parsing, effective review decisions, review-event validation, merged-history pagination and account matching, lazy author credit, permission filtering, weighted scoring, blockers, drafts, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, no-permission review handoff, permissions, events, and commands. The repository gate graph runs the approval policy and workflow tests in CI. The Python SDK job runs `uv run --python 3.10 --with-requirements .github/review-ownership/requirements.txt python -m unittest discover -s .github/review-ownership -p 'test_*.py'` for real Git histories, lexers, renames, shallow-history rejection, and the publisher’s fetch/analysis integration.
 
 <a id="dev-note"></a>
 

+ 62 - 0
.github/review-ownership/author-weight.mjs

@@ -0,0 +1,62 @@
+/** Author credit from merged pull requests in the same repository. */
+const CAP_COUNT = 150
+
+/**
+ * Convert merged PR count to author points, capped at 0.6.
+ * @param {number} mergedCount Merged PR count.
+ * @returns {number} Author approval points.
+ */
+export function authorCreditPoints(mergedCount) {
+  return Math.min(CAP_COUNT, mergedCount) / 250
+}
+
+/**
+ * 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.
+ */
+export async function countMergedAuthorPulls(pull, api) {
+  const [owner, name] = pull.repository.split('/')
+  const seen = new Set()
+  const cursors = new Set()
+  let after = null
+  let count = 0
+  do {
+    const response = await api('/graphql', {
+      method: 'POST',
+      body: {
+        query: `query($owner: String!, $name: String!, $after: String) {
+          repository(owner: $owner, name: $name) {
+            pullRequests(states: MERGED, first: 100, after: $after, orderBy: {field: CREATED_AT, direction: DESC}) {
+              nodes { number author { ... on Node { id } } }
+              pageInfo { hasNextPage endCursor }
+            }
+          }
+        }`,
+        variables: { owner, name, after },
+      },
+    })
+    const connection = response.data?.repository?.pullRequests
+    if (response.errors?.length || !Array.isArray(connection?.nodes)
+      || typeof connection.pageInfo?.hasNextPage !== 'boolean') {
+      throw new Error('GitHub merged PR history is incomplete')
+    }
+    for (const entry of connection.nodes) {
+      if (!Number.isSafeInteger(entry?.number) || entry.number <= 0
+        || (entry.author !== null && typeof entry.author?.id !== 'string')) {
+        throw new Error('GitHub returned an invalid merged PR')
+      }
+      if (seen.has(entry.number)) continue
+      seen.add(entry.number)
+      if (entry.number !== pull.number && entry.author?.id === pull.authorId) count++
+      if (count === CAP_COUNT) return count
+    }
+    if (!connection.pageInfo.hasNextPage) return count
+    after = connection.pageInfo.endCursor
+    if (typeof after !== 'string' || !after || cursors.has(after)) {
+      throw new Error('GitHub merged PR history cursor did not advance')
+    }
+    cursors.add(after)
+  } while (true)
+}

+ 47 - 0
.github/review-ownership/author-weight.test.mjs

@@ -0,0 +1,47 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+import { countMergedAuthorPulls } from './author-weight.mjs'
+
+const pull = { repository: 'owner/repo', number: 999, authorId: 'account-id' }
+const entry = (number, id = pull.authorId) => ({ number, author: id === null ? null : { id } })
+const page = (nodes, hasNextPage = false, endCursor = null) => ({
+  data: { repository: { pullRequests: { nodes, pageInfo: { hasNextPage, endCursor } } } },
+})
+
+test('counts only the same account and excludes the current PR and deleted accounts', async () => {
+  const count = await countMergedAuthorPulls(pull, async (path, { method, body }) => {
+    assert.equal(path, '/graphql')
+    assert.equal(method, 'POST')
+    assert.deepEqual(body.variables, { owner: 'owner', name: 'repo', after: null })
+    assert.match(body.query, /pullRequests\(states: MERGED, first: 100, after: \$after/u)
+    assert.match(body.query, /author \{ \.\.\. on Node \{ id \} \}/u)
+    return page([entry(1), entry(2, 'another-id'), entry(3, null), entry(999)])
+  })
+  assert.equal(count, 1)
+})
+
+test('follows cursors, deduplicates overlapping pages, and stops at 150', 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')
+    assert.equal(calls, 2)
+    assert.equal(body.variables.after, 'next')
+    return page(Array.from({ length: 100 }, (_, i) => entry(i + 100)), true, 'unused')
+  })
+  assert.equal(count, 150)
+  assert.equal(calls, 2)
+})
+
+test('returns zero only for a complete empty history', async () => {
+  assert.equal(await countMergedAuthorPulls(pull, async () => page([])), 0)
+  for (const response of [{}, { errors: [{ message: 'rate limited' }], ...page([]) },
+    page([null]), page([{ number: 1 }]), page([], true, null)]) {
+    await assert.rejects(countMergedAuthorPulls(pull, async () => response), /GitHub/u)
+  }
+  await assert.rejects(countMergedAuthorPulls(pull, async () => { throw new Error('offline') }), /offline/u)
+})
+
+test('rejects a repeated cursor instead of looping over a partial history', async () => {
+  await assert.rejects(countMergedAuthorPulls(pull, async () => page([], true, 'same')), /did not advance/u)
+})

+ 30 - 14
.github/review-ownership/check-approval.mjs

@@ -5,6 +5,7 @@ import process from 'node:process'
 import { pathToFileURL } from 'node:url'
 
 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
@@ -129,10 +130,10 @@ export async function listPullRequestReviews(api, repository, pullNumber) {
 
 /**
  * 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}} options Runtime inputs.
- * @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'pending' | 'success', description: string, points: number, requiredPoints: number, approvals: Array<{login: string, points: number, ownership?: {ownedLines: number, totalLines: number}}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields.
+ * @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.
  */
-export async function evaluateApproval({ event, policySource, api, getOwnership = productionOwnership }) {
+export async function evaluateApproval({ event, policySource, api, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls }) {
   const pull = pullRequestFromEvent(event)
   const policy = parseApprovalPolicy(policySource)
   if (pull.draft) {
@@ -161,8 +162,14 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
       })
     }
   }
-  const unboostedPoints = approvals.reduce((sum, approval) => sum + approval.points, 0)
-  if (blockers.length === 0 && unboostedPoints < policy.requiredPoints
+  let authorCredit = null
+  const reviewerPoints = approvals.reduce((sum, approval) => sum + approval.points, 0)
+  if (blockers.length === 0 && reviewerPoints < policy.requiredPoints) {
+    const mergedCount = await getMergedCount(pull, api)
+    authorCredit = { mergedCount, points: authorCreditPoints(mergedCount) }
+  }
+  const pointsBeforeOwnership = reviewerPoints + (authorCredit?.points ?? 0)
+  if (blockers.length === 0 && pointsBeforeOwnership < policy.requiredPoints
     && approvals.some(approval => approval.points === policy.defaultPoints)) {
     const ownership = await getOwnership(pull, api)
     for (const approval of approvals) {
@@ -180,12 +187,13 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
     const next = total + approval.points
     if (!Number.isFinite(next) || next > Number.MAX_SAFE_INTEGER) throw new Error('approval points must be finite and at most Number.MAX_SAFE_INTEGER')
     return next
-  }, 0)
+  }, 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'}`)
+      `${blockers.length} blocking change request${blockers.length === 1 ? '' : 's'}`, authorCredit)
   }
-  const state = points >= policy.requiredPoints ? 'success' : 'pending'
+  // Tolerate floating-point addition error without rounding approval scores.
+  const state = points + 1e-12 >= policy.requiredPoints ? 'success' : 'pending'
   return approvalResult(
     pull,
     policy.requiredPoints,
@@ -193,25 +201,29 @@ export async function evaluateApproval({ event, policySource, api, getOwnership
     blockers,
     ignoredReviewers,
     state,
-    `${Number(points.toFixed(2))}/${policy.requiredPoints} approval points`,
+    `${Number(points.toFixed(3))}/${policy.requiredPoints} approval points${authorCredit ? ` (author ${authorCredit.points})` : ''}`,
+    authorCredit,
   )
 }
 
 /**
  * Evaluate and publish the required commit status, publishing an error status when evaluation fails.
- * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, runUrl: string, write?: (line: string) => void, getOwnership?: typeof productionOwnership}} options Runtime inputs.
+ * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>, runUrl: string, write?: (line: string) => void, getOwnership?: typeof productionOwnership, getMergedCount?: typeof countMergedAuthorPulls}} options Runtime inputs.
  * @returns {Promise<Awaited<ReturnType<typeof evaluateApproval>>>} Published approval decision.
  */
-export async function runApprovalCheck({ event, policySource, api, runUrl, getOwnership = productionOwnership, write = line => process.stdout.write(`${line}\n`) }) {
+export async function runApprovalCheck({ event, policySource, api, runUrl, getOwnership = productionOwnership, getMergedCount = countMergedAuthorPulls, write = line => process.stdout.write(`${line}\n`) }) {
   const pull = pullRequestFromEvent(event)
   await publishStatus(api, pull, 'pending', 'Evaluating approval points.', runUrl)
   let result
   try {
-    result = await evaluateApproval({ event, policySource, api, getOwnership })
+    result = await evaluateApproval({ event, policySource, api, getOwnership, getMergedCount })
   } catch (error) {
     await publishStatus(api, pull, 'error', 'Approval evaluation failed.', runUrl)
     throw error
   }
+  write(result.authorCredit
+    ? `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)` : ''}`))
@@ -261,12 +273,13 @@ export async function approvalEventFromWorkflowRun({ event, api }) {
   return { ...event, pull_request: pull }
 }
 
-function approvalResult(pull, requiredPoints, approvals, blockers, ignoredReviewers, state, detail) {
+function approvalResult(pull, requiredPoints, approvals, blockers, ignoredReviewers, state, detail, authorCredit = null) {
   return {
     pull: { repository: pull.repository, number: pull.number, headSha: pull.headSha },
     state,
     description: `${detail}.`,
-    points: approvals.reduce((total, approval) => total + approval.points, 0),
+    points: approvals.reduce((total, approval) => total + approval.points, authorCredit?.points ?? 0),
+    authorCredit,
     requiredPoints,
     approvals,
     blockers,
@@ -312,12 +325,15 @@ function pullRequestFromEvent(event) {
   if (!Number.isSafeInteger(pull.number) || pull.number <= 0) throw new Error('pull request has no valid number')
   if (typeof pull.draft !== 'boolean') throw new Error('pull request has no draft flag')
   const author = validateLogin(pull.user.login, 'pull-request author')
+  // REST node_id and GraphQL id identify the same global account node.
+  if (typeof pull.user.node_id !== 'string' || !pull.user.node_id) throw new Error('pull request has no author account ID')
   const headSha = validateHeadSha(pull.head.sha, 'pull request')
   return {
     repository,
     number: pull.number,
     draft: pull.draft,
     author,
+    authorId: pull.user.node_id,
     headSha,
   }
 }

+ 147 - 6
.github/review-ownership/check-approval.test.mjs

@@ -6,14 +6,16 @@ import {
   approvalEventFromWorkflowRun,
   createGitHubApi,
   effectiveReviewDecisions,
-  evaluateApproval,
+  evaluateApproval as evaluateWithHistory,
   listPullRequestReviews,
   parseApprovalPolicy,
-  runApprovalCheck,
+  runApprovalCheck as runWithHistory,
   publishApprovalPhase,
 } 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 HEAD_SHA = '1234567890abcdef1234567890abcdef12345678'
 
 const pullRequestEvent = ({ author = 'author', draft = false } = {}) => ({
@@ -21,7 +23,7 @@ const pullRequestEvent = ({ author = 'author', draft = false } = {}) => ({
   pull_request: {
     number: 42,
     draft,
-    user: { login: author },
+    user: { login: author, node_id: 'author-id' },
     head: { sha: HEAD_SHA },
   },
 })
@@ -313,7 +315,8 @@ test('publishes the required status and replaces stale success with error on eva
       },
     },
   })
-  assert.equal(output[0], 'Approval score: 2/2.')
+  assert.equal(output[0], 'Author credit: not evaluated (reviewer points suffice).')
+  assert.equal(output[1], 'Approval score: 2/2.')
 
   const failures = []
   await assert.rejects(runApprovalCheck({
@@ -375,7 +378,7 @@ for (const [ownedLines, totalLines, expectedPoints] of [[9, 100, 1.3599999999999
         : { permission: 'write' },
     })
     assert.equal(result.points, expectedPoints)
-    assert.equal(result.description, `${Number(expectedPoints.toFixed(2))}/2 approval points.`)
+    assert.equal(result.description, `${Number(expectedPoints.toFixed(3))}/2 approval points (author 0).`)
     assert.equal(result.state, expectedPoints === 2 ? 'success' : 'pending')
     assert.equal(measurements, 1)
     assert.deepEqual(result.approvals[0].ownership, { ownedLines, totalLines })
@@ -462,7 +465,7 @@ test('uses policy endpoints and formats only the displayed score', async () => {
     api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' },
   })
   assert.equal(result.points, 3.08)
-  assert.equal(result.description, '3.08/5 approval points.')
+  assert.equal(result.description, '3.08/5 approval points (author 0).')
 })
 
 test('publishes setup phases without evaluating or installing dependencies', async () => {
@@ -476,3 +479,141 @@ test('publishes setup phases without evaluating or installing dependencies', asy
   assert.deepEqual(states, ['pending', 'error'])
   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]]) {
+  test(`author with ${mergedCount} merged PRs contributes ${credit} points but cannot approve alone`, async () => {
+    const result = await evaluateApproval({
+      event: pullRequestEvent(), policySource, getMergedCount: async () => mergedCount,
+      api: async path => path.includes('/reviews?') ? [review('author', 'APPROVED')] : { permission: 'write' },
+    })
+    assert.equal(result.points, credit)
+    assert.equal(result.authorCredit.points, credit)
+    assert.equal(result.state, 'pending')
+    assert.deepEqual(result.approvals, [])
+  })
+}
+
+test('combines author credit and reviewer ownership at the passing threshold', async () => {
+  for (let mergedCount = 0; mergedCount <= 150; mergedCount++) {
+    const result = await evaluateApproval({
+      event: pullRequestEvent(), policySource, getMergedCount: async () => mergedCount,
+      getOwnership: async () => ({ totalLines: 1000, reviewerLines: { writer: 250 - mergedCount } }),
+      api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' },
+    })
+    assert.equal(result.state, 'success', `author merged ${mergedCount}`)
+  }
+})
+
+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') },
+    getOwnership: async () => { throw new Error('blockers must skip blame') },
+    api: async path => path.includes('/reviews?')
+      ? [review('writer', 'APPROVED'), review('blocker', 'CHANGES_REQUESTED'), review('reader', 'APPROVED')]
+      : { permission: path.includes('/reader/') ? 'read' : 'write' },
+  })
+  assert.equal(result.state, 'pending')
+  assert.equal(result.points, 1)
+  assert.equal(result.authorCredit, null)
+  assert.deepEqual(result.blockers, ['blocker'])
+  assert.deepEqual(result.ignoredReviewers, ['reader'])
+})
+
+test('drafts skip history and history failures revoke success with error', async () => {
+  await evaluateApproval({
+    event: pullRequestEvent({ draft: true }), policySource,
+    getMergedCount: async () => { throw new Error('draft must skip history') },
+  })
+  const states = []
+  await assert.rejects(runApprovalCheck({
+    event: pullRequestEvent(), policySource, runUrl: 'https://github.example/run/1',
+    getMergedCount: async () => { throw new Error('history unavailable') },
+    api: async (path, options) => {
+      if (path.includes('/reviews?')) return []
+      states.push(options.body.state)
+    },
+  }), /history unavailable/u)
+  assert.deepEqual(states, ['pending', 'error'])
+})
+
+
+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 } }),
+    api: async path => path.includes('/reviews?') ? [review('writer', 'APPROVED')] : { permission: 'write' },
+  })
+  assert.equal(result.state, 'pending')
+  assert.ok(result.points < 2)
+})
+
+test('the publisher counts merged history through the production API path', async () => {
+  const event = pullRequestEvent()
+  const states = []
+  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 } }),
+    api: async (path, options) => {
+      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) => ({
+            number: i + (options.body.variables.after ? 200 : 100), author: { id: event.pull_request.user.node_id },
+          })),
+          pageInfo: { hasNextPage: !options.body.variables.after, endCursor: 'next' },
+        } } } }
+      }
+      if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
+      if (path.includes('/permission')) return { permission: 'write' }
+      states.push(options.body.state)
+      return {}
+    },
+  })
+  assert.equal(result.points, 2)
+  assert.equal(result.authorCredit.points, 0.5)
+  assert.equal(result.approvals[0].points, 1.5)
+  assert.deepEqual(states, ['pending', 'success'])
+  assert.equal(output[0], 'Author credit: 0.5 (125 merged PRs).')
+})
+
+test('sufficient reviewer points and drafts publish without querying author history', async () => {
+  for (const draft of [false, true]) {
+    const output = []
+    const result = await runWithHistory({
+      event: pullRequestEvent({ draft }), policySource, runUrl: 'https://github.example/run/1',
+      write: line => output.push(line),
+      api: async path => {
+        assert.notEqual(path, '/graphql')
+        if (path.includes('/reviews?')) return [review('turtle2099', 'APPROVED')]
+        if (path.includes('/permission')) return { permission: 'write' }
+        return {}
+      },
+    })
+    assert.equal(result.state, draft ? 'pending' : 'success')
+    assert.equal(result.authorCredit, null)
+    assert.equal(output[0], `Author credit: not evaluated (${draft ? 'draft' : 'reviewer points suffice'}).`)
+  }
+})
+
+test('rejects missing author node IDs before calling APIs even for drafts', async () => {
+  for (const nodeId of [undefined, '', 123]) {
+    const event = pullRequestEvent({ draft: true })
+    event.pull_request.user.node_id = nodeId
+    await assert.rejects(evaluateWithHistory({
+      event, policySource, api: async () => assert.fail('invalid events must not call GitHub'),
+    }), /account ID/u)
+  }
+})
+
+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,
+    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.state, 'success')
+})

+ 1 - 1
package.json

@@ -58,7 +58,7 @@
     "test:bench:built": "vitest run --config vitest.bench.config.ts",
     "test:expected": "vitest run --config vitest.expected.config.ts",
     "test:expected:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.expected.config.ts",
-    "test:approval-policy": "node --test .github/review-ownership/check-approval.test.mjs .github/review-ownership/blame-ownership.test.mjs",
+    "test:approval-policy": "node --test .github/review-ownership/check-approval.test.mjs .github/review-ownership/blame-ownership.test.mjs .github/review-ownership/author-weight.test.mjs",
     "test:issue-management": "node .github/issue-management/policy.test.mjs",
     "test:snapshot": "vitest run --config vitest.snapshot.config.ts",
     "test:snapshot:record": "DSH_SNAPSHOT=record vitest run --config vitest.snapshot.config.ts --update",