Bläddra i källkod

feat: enforce weighted pull request approvals

Turtle 1 månad sedan
förälder
incheckning
4550bb6e69

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

@@ -1,12 +1,13 @@
-# Automated review requests
+# Automated pull-request reviews
 
 ## Summary
 
-The [`request-review` workflow](../workflows/request-review.yml) reads the CODEOWNERS-compatible [ownership map](CODEOWNERS) from the trusted default branch. It classifies changed files, requests missing owners for reviewable code, and cancels its outstanding requests when a pull request becomes a draft. The ownership map is outside GitHub's native CODEOWNERS locations, so GitHub does not apply it directly.
+The [`request-review` workflow](../workflows/request-review.yml) requests owners for reviewable code. The [`weighted-approval` workflow](../workflows/weighted-approval.yml) publishes an approval score for branch rules. Both workflows execute policy from the trusted default branch.
 
 ## Table of Contents
 
 - [Routing](#routing)
+- [Approval scoring](#approval-scoring)
 - [Review exclusions](#review-exclusions)
 - [Security](#security)
 - [Verification](#verification)
@@ -26,6 +27,18 @@ The ownership map accepts explicit absolute directory patterns and one or two in
 
 The policy test measures non-test tracked lines under matched directories and requires `@turtle1999` to own no more than one third of that eligible owned codebase.
 
+<a id="approval-scoring"></a>
+
+## Approval scoring
+
+The weighted approval workflow publishes the `weighted approval` commit status on the pull request head. Branch rules must require this status context. The status succeeds at two approval points, remains pending below two points or while the pull request is a draft, fails while a write-capable reviewer has an effective `CHANGES_REQUESTED` review, and reports an error when policy evaluation fails.
+
+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.
+
+Each reviewer contributes only the latest non-comment `APPROVED` or `CHANGES_REQUESTED` decision that GitHub returns. Comment-only and dismissed review records do not replace an effective decision. 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 workflow runs when a pull request opens, synchronizes, reopens, becomes ready, or becomes a draft, and when a review is submitted, edited, or dismissed. It 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.
+
 <a id="review-exclusions"></a>
 
 ## Review exclusions
@@ -42,15 +55,15 @@ For a modified file with a supported source extension, the scanner compares the
 
 ## Security
 
-The write-capable `pull_request_target` job checks out only the repository default branch. It does not check out or execute pull-request code and does not use repository secrets. Pull-request filenames are treated as API data and escaped in logs.
+The write-capable jobs check out only the repository default branch. They do not check out or execute pull-request code and do not use repository secrets. Pull-request filenames and reviews are treated as API data and escaped in logs.
 
-Ownership changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing the routing program or its owner assignments for its own run.
+Ownership and approval policy changes take effect only after they merge into the default branch. This prevents an untrusted pull request from changing either program or policy for its own run.
 
 <a id="verification"></a>
 
 ## Verification
 
-Run `pnpm run test:request-review` for ownership parsing, file classification, complete-patch checks, comment parsing, changed-LOC ranking, pagination, logging order, non-draft reconciliation, draft cancellation, reviewer provenance, reviewer filtering, and API behavior. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin the trusted checkout, permissions, events, and command. The repository gate graph runs both checks in CI.
+Run `pnpm run test:request-review` for routing behavior and `pnpm run test:approval-policy` for policy parsing, effective review decisions, pagination, permission filtering, weighted scoring, blockers, drafts, status publication, and API failures. [Workflow tests](../../scripts/ci-workflow.spec.ts) pin both workflows' trusted checkout, permissions, events, and commands. The repository gate graph runs all three checks in CI.
 
 <a id="dev-note"></a>
 

+ 12 - 0
.github/review-ownership/approval-policy.json

@@ -0,0 +1,12 @@
+{
+  "requiredPoints": 2,
+  "defaultPoints": 1,
+  "reviewerPoints": {
+    "07akioni": 2,
+    "imccyu": 2,
+    "tianyicui": 2,
+    "tianyicui-bot": 2,
+    "turtle1999": 2,
+    "turtle2099": 2
+  }
+}

+ 298 - 0
.github/review-ownership/check-approval.mjs

@@ -0,0 +1,298 @@
+#!/usr/bin/env node
+
+import { readFileSync } from 'node:fs'
+import process from 'node:process'
+import { pathToFileURL } from 'node:url'
+
+const API_VERSION = '2026-03-10'
+const MAX_PULL_REQUEST_REVIEWS = 3_000
+const PAGE_SIZE = 100
+const STATUS_CONTEXT = 'weighted approval'
+const STATUS_PREFIX = 'This is by automated Angry Turtle Cyborg, not a human'
+const WRITABLE_PERMISSIONS = new Set(['admin', 'write'])
+const REVIEW_STATES = new Set(['APPROVED', 'CHANGES_REQUESTED', 'COMMENTED', 'DISMISSED', 'PENDING'])
+const LOGIN = /^[A-Za-z0-9-]+(?:\[bot\])?$/u
+
+/**
+ * Parse the approval score policy.
+ * @param {string} source Approval policy JSON.
+ * @returns {{requiredPoints: number, defaultPoints: number, reviewerPoints: Map<string, number>}} Validated policy.
+ */
+export function parseApprovalPolicy(source) {
+  const value = JSON.parse(source)
+  if (!isRecord(value)) throw new Error('approval policy must be an object')
+  const fields = Object.keys(value).sort()
+  if (fields.join(',') !== 'defaultPoints,requiredPoints,reviewerPoints') {
+    throw new Error('approval policy must contain only defaultPoints, requiredPoints, and reviewerPoints')
+  }
+  const requiredPoints = positiveInteger(value.requiredPoints, 'requiredPoints')
+  const defaultPoints = positiveInteger(value.defaultPoints, 'defaultPoints')
+  if (!isRecord(value.reviewerPoints)) throw new Error('reviewerPoints must be an object')
+  const reviewerPoints = new Map()
+  for (const [login, pointsValue] of Object.entries(value.reviewerPoints)) {
+    validateLogin(login, 'approval policy reviewer')
+    const key = login.toLowerCase()
+    if (reviewerPoints.has(key)) throw new Error(`duplicate approval policy reviewer @${login}`)
+    reviewerPoints.set(key, positiveInteger(pointsValue, `reviewerPoints.${login}`))
+  }
+  return { requiredPoints, defaultPoints, reviewerPoints }
+}
+
+/**
+ * Select each reviewer's latest non-comment review decision.
+ * @param {unknown[]} reviews Pull-request review records in GitHub's chronological order.
+ * @returns {Array<{login: string, state: 'APPROVED' | 'CHANGES_REQUESTED'}>} Effective review decisions.
+ */
+export function effectiveReviewDecisions(reviews) {
+  const decisions = new Map()
+  for (const review of reviews) {
+    if (!isRecord(review) || !isRecord(review.user) || typeof review.user.login !== 'string') {
+      throw new Error('pull-request review has no reviewer login')
+    }
+    const login = validateLogin(review.user.login, 'pull-request reviewer')
+    if (typeof review.state !== 'string' || !REVIEW_STATES.has(review.state.toUpperCase())) {
+      throw new Error(`pull-request review by @${login} has an invalid state`)
+    }
+    const state = review.state.toUpperCase()
+    if (state === 'APPROVED' || state === 'CHANGES_REQUESTED') {
+      decisions.set(login.toLowerCase(), { login, state })
+    }
+  }
+  return [...decisions.values()]
+}
+
+/**
+ * Create a repository-scoped GitHub JSON API caller.
+ * @param {{token: string, apiUrl?: string, fetchImpl?: typeof fetch}} options API dependencies.
+ * @returns {(path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>} API caller.
+ */
+export function createGitHubApi({ token, apiUrl = 'https://api.github.com', fetchImpl = globalThis.fetch }) {
+  if (!token) throw new Error('GITHUB_TOKEN is not set')
+  if (typeof fetchImpl !== 'function') throw new Error('fetch is unavailable')
+  const root = apiUrl.replace(/\/+$/u, '')
+  return async (path, { method = 'GET', body } = {}) => {
+    const response = await fetchImpl(`${root}${path}`, {
+      method,
+      headers: {
+        Accept: 'application/vnd.github+json',
+        Authorization: `Bearer ${token}`,
+        'Content-Type': 'application/json',
+        'User-Agent': 'deepseek-harness-weighted-approval',
+        'X-GitHub-Api-Version': API_VERSION,
+      },
+      ...(body === undefined ? {} : { body: JSON.stringify(body) }),
+    })
+    if (!response.ok) {
+      const responseBody = await response.text()
+      throw new Error(`GitHub API ${method} ${path} returned ${response.status}: ${JSON.stringify(responseBody)}`)
+    }
+    if (response.status === 204) return undefined
+    return response.json()
+  }
+}
+
+/**
+ * Fetch every pull-request review or fail before scoring a partial list.
+ * @param {(path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>} api GitHub API caller.
+ * @param {string} repository Owner/name repository identifier.
+ * @param {number} pullNumber Pull-request number.
+ * @returns {Promise<unknown[]>} Complete review list within the supported limit.
+ */
+export async function listPullRequestReviews(api, repository, pullNumber) {
+  const reviews = []
+  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`)
+    }
+  }
+}
+
+/**
+ * Evaluate approval points from current reviews and repository permissions.
+ * @param {{event: unknown, policySource: string, api: (path: string, options?: {method?: string, body?: unknown}) => Promise<unknown>}} options Runtime inputs.
+ * @returns {Promise<{pull: {repository: string, number: number, headSha: string}, state: 'failure' | 'pending' | 'success', description: string, points: number, requiredPoints: number, approvals: Array<{login: string, points: number}>, blockers: string[], ignoredReviewers: string[]}>} Approval decision and status payload fields.
+ */
+export async function evaluateApproval({ event, policySource, api }) {
+  const pull = pullRequestFromEvent(event)
+  const policy = parseApprovalPolicy(policySource)
+  if (pull.draft) {
+    return approvalResult(pull, policy.requiredPoints, [], [], [], 'pending', 'draft pull request')
+  }
+
+  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 approvals = []
+  const blockers = []
+  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,
+      })
+    }
+  }
+  approvals.sort((left, right) => left.login.localeCompare(right.login, 'en'))
+  blockers.sort((left, right) => left.localeCompare(right, 'en'))
+  ignoredReviewers.sort((left, right) => left.localeCompare(right, 'en'))
+  const points = approvals.reduce((total, approval) => {
+    const next = total + approval.points
+    if (!Number.isSafeInteger(next)) throw new Error('approval points exceed the safe integer range')
+    return next
+  }, 0)
+  if (blockers.length > 0) {
+    return approvalResult(pull, policy.requiredPoints, approvals, blockers, ignoredReviewers, 'failure',
+      `${blockers.length} blocking change request${blockers.length === 1 ? '' : 's'}`)
+  }
+  const state = points >= policy.requiredPoints ? 'success' : 'pending'
+  return approvalResult(
+    pull,
+    policy.requiredPoints,
+    approvals,
+    blockers,
+    ignoredReviewers,
+    state,
+    `${points}/${policy.requiredPoints} approval points`,
+  )
+}
+
+/**
+ * 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}} options Runtime inputs.
+ * @returns {Promise<Awaited<ReturnType<typeof evaluateApproval>>>} Published approval decision.
+ */
+export async function runApprovalCheck({ event, policySource, api, runUrl, write = line => process.stdout.write(`${line}\n`) }) {
+  const pull = pullRequestFromEvent(event)
+  write(STATUS_PREFIX)
+  let result
+  try {
+    result = await evaluateApproval({ event, policySource, api })
+  } catch (error) {
+    await publishStatus(api, pull, 'error', `${STATUS_PREFIX}: approval evaluation failed.`, runUrl)
+    throw error
+  }
+  write(`Approval score: ${result.points}/${result.requiredPoints}.`)
+  writeList(write, 'Counted approvals', result.approvals.map(({ login, points }) => `@${login}: ${points}`))
+  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)
+  write(`Published ${JSON.stringify(STATUS_CONTEXT)} status ${JSON.stringify(result.state)}.`)
+  return result
+}
+
+function approvalResult(pull, requiredPoints, approvals, blockers, ignoredReviewers, state, detail) {
+  return {
+    pull: { repository: pull.repository, number: pull.number, headSha: pull.headSha },
+    state,
+    description: `${STATUS_PREFIX}: ${detail}.`,
+    points: approvals.reduce((total, approval) => total + approval.points, 0),
+    requiredPoints,
+    approvals,
+    blockers,
+    ignoredReviewers,
+  }
+}
+
+async function reviewerPermission(api, repository, login) {
+  const response = await api(`/repos/${repository}/collaborators/${encodeURIComponent(login)}/permission`)
+  if (!isRecord(response) || typeof response.permission !== 'string') {
+    throw new Error(`collaborator permission response for @${login} has no permission`)
+  }
+  return response.permission.toLowerCase()
+}
+
+async function publishStatus(api, pull, state, description, runUrl) {
+  if (!/^https:\/\/[^\s]+$/u.test(runUrl)) throw new Error('workflow run URL must use HTTPS')
+  if (description.length > 140) throw new Error('commit status description exceeds 140 characters')
+  await api(`/repos/${pull.repository}/statuses/${pull.headSha}`, {
+    method: 'POST',
+    body: {
+      state,
+      context: STATUS_CONTEXT,
+      description,
+      target_url: runUrl,
+    },
+  })
+}
+
+function pullRequestFromEvent(event) {
+  if (!isRecord(event) || !isRecord(event.repository) || typeof event.repository.full_name !== 'string'
+    || !/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/u.test(event.repository.full_name)) {
+    throw new Error('event has no valid repository.full_name')
+  }
+  if (!isRecord(event.pull_request) || !isRecord(event.pull_request.user)
+    || !isRecord(event.pull_request.head)) {
+    throw new Error('event has no pull_request')
+  }
+  const pull = event.pull_request
+  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')
+  if (typeof pull.head.sha !== 'string' || !/^[0-9a-f]{40}$/u.test(pull.head.sha)) {
+    throw new Error('pull request has no valid head SHA')
+  }
+  return {
+    repository: event.repository.full_name,
+    number: pull.number,
+    draft: pull.draft,
+    author,
+    headSha: pull.head.sha,
+  }
+}
+
+function positiveInteger(value, field) {
+  if (!Number.isSafeInteger(value) || value <= 0) throw new Error(`${field} must be a positive integer`)
+  return value
+}
+
+function validateLogin(value, subject) {
+  if (typeof value !== 'string' || !LOGIN.test(value)) throw new Error(`${subject} has an invalid login`)
+  return value
+}
+
+function writeList(write, title, entries) {
+  write(`${title}:`)
+  if (entries.length === 0) write('- (none)')
+  else for (const entry of entries) write(`- ${entry}`)
+}
+
+function isRecord(value) {
+  return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+async function main() {
+  const eventPath = process.env.GITHUB_EVENT_PATH
+  if (!eventPath) throw new Error('GITHUB_EVENT_PATH is not set')
+  const event = JSON.parse(readFileSync(eventPath, 'utf8'))
+  const policySource = readFileSync(new URL('approval-policy.json', import.meta.url), 'utf8')
+  const api = createGitHubApi({
+    token: process.env.GITHUB_TOKEN ?? '',
+    apiUrl: process.env.GITHUB_API_URL,
+  })
+  await runApprovalCheck({
+    event,
+    policySource,
+    api,
+    runUrl: process.env.GITHUB_RUN_URL ?? '',
+  })
+}
+
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+  main().catch((error) => {
+    process.stderr.write(`weighted approval failed: ${error instanceof Error ? error.message : String(error)}\n`)
+    process.exitCode = 1
+  })
+}

+ 244 - 0
.github/review-ownership/check-approval.test.mjs

@@ -0,0 +1,244 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import test from 'node:test'
+
+import {
+  createGitHubApi,
+  effectiveReviewDecisions,
+  evaluateApproval,
+  listPullRequestReviews,
+  parseApprovalPolicy,
+  runApprovalCheck,
+} from './check-approval.mjs'
+
+const policySource = readFileSync(new URL('approval-policy.json', import.meta.url), 'utf8')
+const HEAD_SHA = '1234567890abcdef1234567890abcdef12345678'
+
+const pullRequestEvent = ({ author = 'author', draft = false } = {}) => ({
+  repository: { full_name: 'deepseek-harness/deepseek-harness' },
+  pull_request: {
+    number: 42,
+    draft,
+    user: { login: author },
+    head: { sha: HEAD_SHA },
+  },
+})
+
+const review = (login, state) => ({ user: { login }, state })
+
+test('loads the repository approval score policy', () => {
+  const policy = parseApprovalPolicy(policySource)
+  assert.equal(policy.requiredPoints, 2)
+  assert.equal(policy.defaultPoints, 1)
+  assert.deepEqual([...policy.reviewerPoints], [
+    ['07akioni', 2],
+    ['imccyu', 2],
+    ['tianyicui', 2],
+    ['tianyicui-bot', 2],
+    ['turtle1999', 2],
+    ['turtle2099', 2],
+  ])
+})
+
+test('rejects invalid approval score policies', () => {
+  for (const [source, message] of [
+    ['[]', /must be an object/u],
+    ['{"requiredPoints":0,"defaultPoints":1,"reviewerPoints":{}}', /requiredPoints/u],
+    ['{"requiredPoints":2,"defaultPoints":0,"reviewerPoints":{}}', /defaultPoints/u],
+    ['{"requiredPoints":2,"defaultPoints":1,"reviewerPoints":[]}', /reviewerPoints must be an object/u],
+    ['{"requiredPoints":2,"defaultPoints":1,"reviewerPoints":{},"typo":2}', /contain only/u],
+    ['{"requiredPoints":2,"defaultPoints":1,"reviewerPoints":{"bad login":2}}', /invalid login/u],
+    ['{"requiredPoints":2,"defaultPoints":1,"reviewerPoints":{"User":2,"user":2}}', /duplicate/u],
+    ['{"requiredPoints":2,"defaultPoints":1,"reviewerPoints":{"user":-1}}', /positive integer/u],
+  ]) {
+    assert.throws(() => parseApprovalPolicy(source), message)
+  }
+})
+
+test('uses each reviewer latest approval or change request while ignoring comments and dismissed reviews', () => {
+  assert.deepEqual(effectiveReviewDecisions([
+    review('first', 'APPROVED'),
+    review('first', 'COMMENTED'),
+    review('second', 'CHANGES_REQUESTED'),
+    review('second', 'APPROVED'),
+    review('third', 'APPROVED'),
+    review('third', 'CHANGES_REQUESTED'),
+    review('dismissed', 'DISMISSED'),
+  ]), [
+    { login: 'first', state: 'APPROVED' },
+    { login: 'second', state: 'APPROVED' },
+    { login: 'third', state: 'CHANGES_REQUESTED' },
+  ])
+})
+
+test('fetches every pull-request review and rejects an unbounded history', async () => {
+  let calls = 0
+  const reviews = await listPullRequestReviews(async () => {
+    calls++
+    return calls === 1 ? Array.from({ length: 100 }, () => review('user', 'COMMENTED')) : []
+  }, 'owner/repo', 42)
+  assert.equal(reviews.length, 100)
+  assert.equal(calls, 2)
+
+  calls = 0
+  await assert.rejects(listPullRequestReviews(async () => {
+    calls++
+    return Array.from({ length: 100 }, () => review('user', 'COMMENTED'))
+  }, 'owner/repo', 42), /exceed 3000/u)
+  assert.equal(calls, 30)
+})
+
+test('accepts one two-point approval from a write-capable reviewer', async () => {
+  const calls = []
+  const result = await evaluateApproval({
+    event: pullRequestEvent(),
+    policySource,
+    api: async (path) => {
+      calls.push(path)
+      if (path.includes('/reviews?')) return [review('07akioni', 'APPROVED')]
+      if (path.includes('/collaborators/07akioni/permission')) return { permission: 'write' }
+      throw new Error(`unexpected API path ${path}`)
+    },
+  })
+  assert.equal(result.state, 'success')
+  assert.equal(result.points, 2)
+  assert.deepEqual(result.approvals, [{ login: '07akioni', points: 2 }])
+  assert.equal(calls.length, 2)
+})
+
+test('accepts two one-point approvals and ignores reviews without write access', async () => {
+  const result = await evaluateApproval({
+    event: pullRequestEvent(),
+    policySource,
+    api: async (path) => {
+      if (path.includes('/reviews?')) {
+        return [
+          review('reader', 'APPROVED'),
+          review('writer-b', 'APPROVED'),
+          review('writer-a', 'APPROVED'),
+        ]
+      }
+      if (path.includes('/collaborators/reader/permission')) return { permission: 'read' }
+      if (path.includes('/collaborators/writer-a/permission')) return { permission: 'admin' }
+      if (path.includes('/collaborators/writer-b/permission')) return { permission: 'write' }
+      throw new Error(`unexpected API path ${path}`)
+    },
+  })
+  assert.equal(result.state, 'success')
+  assert.equal(result.points, 2)
+  assert.deepEqual(result.approvals, [
+    { login: 'writer-a', points: 1 },
+    { login: 'writer-b', points: 1 },
+  ])
+  assert.deepEqual(result.ignoredReviewers, ['reader'])
+})
+
+test('blocks on a write-capable change request but ignores the author and read-only blockers', async () => {
+  const result = await evaluateApproval({
+    event: pullRequestEvent({ author: 'author' }),
+    policySource,
+    api: async (path) => {
+      if (path.includes('/reviews?')) {
+        return [
+          review('turtle1999', 'APPROVED'),
+          review('blocker', 'CHANGES_REQUESTED'),
+          review('reader', 'CHANGES_REQUESTED'),
+          review('author', 'CHANGES_REQUESTED'),
+        ]
+      }
+      if (path.includes('/collaborators/turtle1999/permission')) return { permission: 'admin' }
+      if (path.includes('/collaborators/blocker/permission')) return { permission: 'write' }
+      if (path.includes('/collaborators/reader/permission')) return { permission: 'read' }
+      throw new Error(`unexpected API path ${path}`)
+    },
+  })
+  assert.equal(result.state, 'failure')
+  assert.equal(result.points, 2)
+  assert.deepEqual(result.blockers, ['blocker'])
+  assert.deepEqual(result.ignoredReviewers, ['reader'])
+})
+
+test('keeps drafts pending without reading reviews', async () => {
+  const result = await evaluateApproval({
+    event: pullRequestEvent({ draft: true }),
+    policySource,
+    api: async () => { throw new Error('draft evaluation must not call GitHub') },
+  })
+  assert.equal(result.state, 'pending')
+  assert.equal(result.points, 0)
+  assert.match(result.description, /draft pull request/u)
+})
+
+test('publishes the required status and replaces stale success with error on evaluation failure', async () => {
+  const calls = []
+  const output = []
+  const result = await runApprovalCheck({
+    event: pullRequestEvent(),
+    policySource,
+    runUrl: 'https://github.example/actions/runs/1',
+    api: async (path, options = {}) => {
+      calls.push({ path, options })
+      if (path.includes('/reviews?')) return [review('turtle2099', 'APPROVED')]
+      if (path.includes('/collaborators/turtle2099/permission')) return { permission: 'write' }
+      if (path.includes('/statuses/')) return {}
+      throw new Error(`unexpected API path ${path}`)
+    },
+    write: line => output.push(line),
+  })
+  assert.equal(result.state, 'success')
+  assert.deepEqual(calls.at(-1), {
+    path: `/repos/deepseek-harness/deepseek-harness/statuses/${HEAD_SHA}`,
+    options: {
+      method: 'POST',
+      body: {
+        state: 'success',
+        context: 'weighted approval',
+        description: 'This is by automated Angry Turtle Cyborg, not a human: 2/2 approval points.',
+        target_url: 'https://github.example/actions/runs/1',
+      },
+    },
+  })
+  assert.equal(output[0], 'This is by automated Angry Turtle Cyborg, not a human')
+
+  const failures = []
+  await assert.rejects(runApprovalCheck({
+    event: pullRequestEvent(),
+    policySource,
+    runUrl: 'https://github.example/actions/runs/2',
+    api: async (path, options = {}) => {
+      if (path.includes('/reviews?')) throw new Error('reviews unavailable')
+      if (path.includes('/statuses/')) {
+        failures.push({ path, options })
+        return {}
+      }
+      throw new Error(`unexpected API path ${path}`)
+    },
+    write: () => {},
+  }), /reviews unavailable/u)
+  assert.equal(failures[0].options.body.state, 'error')
+})
+
+test('sends authenticated JSON and escapes an API error body', async () => {
+  const requests = []
+  const api = createGitHubApi({
+    token: 'secret',
+    apiUrl: 'https://github.example/api/v3/',
+    fetchImpl: async (url, options) => {
+      requests.push({ url, options })
+      return new Response(JSON.stringify({ ok: true }), {
+        status: 200,
+        headers: { 'Content-Type': 'application/json' },
+      })
+    },
+  })
+  assert.deepEqual(await api('/repos/owner/repo', { method: 'POST', body: { value: 1 } }), { ok: true })
+  assert.equal(requests[0].url, 'https://github.example/api/v3/repos/owner/repo')
+  assert.equal(requests[0].options.headers.Authorization, 'Bearer secret')
+  assert.equal(requests[0].options.body, '{"value":1}')
+
+  const failing = createGitHubApi({
+    token: 'secret',
+    fetchImpl: async () => new Response('::error::untrusted\nbody', { status: 422 }),
+  })
+  await assert.rejects(failing('/failure'), /"::error::untrusted\\nbody"/u)
+})

+ 35 - 0
.github/workflows/weighted-approval.yml

@@ -0,0 +1,35 @@
+name: weighted-approval
+
+on:
+  pull_request_target:
+    types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
+  pull_request_review:
+    types: [submitted, edited, dismissed]
+
+permissions:
+  contents: read
+  pull-requests: read
+  statuses: write
+
+concurrency:
+  group: weighted-approval-${{ github.event.pull_request.number }}
+  cancel-in-progress: true
+
+jobs:
+  publish-status:
+    name: publish weighted approval status
+    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.
+      - name: Check out trusted approval policy
+        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
+        with:
+          ref: ${{ github.event.repository.default_branch }}
+          persist-credentials: false
+      - name: Publish weighted approval status
+        env:
+          GITHUB_TOKEN: ${{ github.token }}
+          GITHUB_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+        run: node .github/review-ownership/check-approval.mjs

+ 1 - 0
package.json

@@ -57,6 +57,7 @@
     "test:expected": "vitest run --config vitest.expected.config.ts",
     "test:expected:refresh": "DSH_SNAPSHOT=refresh vitest run --config vitest.expected.config.ts",
     "test:issue-management": "node .github/issue-management/policy.test.mjs",
+    "test:approval-policy": "node --test .github/review-ownership/check-approval.test.mjs",
     "test:request-review": "node --test .github/review-ownership/request-review.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",

+ 45 - 0
scripts/ci-workflow.spec.ts

@@ -815,6 +815,51 @@ describe('Request review workflow', () => {
   })
 })
 
+describe('Weighted approval workflow', () => {
+  it('publishes a trusted status on pull request and review updates', () => {
+    const workflow = loadWorkflow('.github/workflows/weighted-approval.yml')
+    const pullRequest = workflowEvent(workflow, 'pull_request_target')
+    const review = workflowEvent(workflow, 'pull_request_review')
+    const job = workflowJob(workflow, 'publish-status')
+    if (!isRecord(workflow.on)) throw new TypeError('weighted-approval workflow must define events')
+    if (!Array.isArray(job.steps)) throw new TypeError('weighted-approval job must define steps')
+    const steps = job.steps.filter(isRecord)
+    const checkout = steps.find(step => step.name === 'Check out trusted approval policy')
+    const publish = steps.find(step => step.name === 'Publish weighted approval status')
+
+    expect(workflow.name).toBe('weighted-approval')
+    expect(Object.keys(workflow.on)).toEqual(['pull_request_target', 'pull_request_review'])
+    expect(pullRequest.types).toEqual(['opened', 'synchronize', 'reopened', 'ready_for_review', 'converted_to_draft'])
+    expect(review.types).toEqual(['submitted', 'edited', 'dismissed'])
+    expect(workflow.permissions).toEqual({ contents: 'read', 'pull-requests': 'read', statuses: 'write' })
+    expect(workflow.concurrency).toEqual({
+      group: 'weighted-approval-${{ github.event.pull_request.number }}',
+      'cancel-in-progress': true,
+    })
+    expect(job).toMatchObject({
+      name: 'publish weighted approval status',
+      'runs-on': 'ubuntu-latest',
+      'timeout-minutes': 5,
+    })
+    expect(checkout).toMatchObject({
+      uses: 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1',
+      with: {
+        ref: '${{ github.event.repository.default_branch }}',
+        'persist-credentials': false,
+      },
+    })
+    expect(publish).toMatchObject({
+      env: {
+        GITHUB_TOKEN: '${{ github.token }}',
+        GITHUB_RUN_URL: '${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}',
+      },
+      run: 'node .github/review-ownership/check-approval.mjs',
+    })
+    expect(JSON.stringify(workflow)).not.toContain('github.event.pull_request.head')
+    expect(JSON.stringify(workflow)).not.toContain('secrets.')
+  })
+})
+
 describe('Issue lifecycle workflow', () => {
   it('runs the lifecycle job on every PR/review event but gates token and board steps', () => {
     const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml')

+ 2 - 0
scripts/run-gates.ts

@@ -267,6 +267,7 @@ export function gatesForMode(selected: Mode): Gate[] {
         pnpmScript('client-domain-graph', 'verify-client-domain-graph', { label: 'client domain graph' }),
         pnpmScript('test', 'test'),
         pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
+        pnpmScript('approval-policy', 'test:approval-policy', { label: 'Weighted approval policy' }),
         pnpmScript('request-review', 'test:request-review', { label: 'Review request policy' }),
         pnpmScript('duplication', 'duplication'),
         snapshotGate(),
@@ -311,6 +312,7 @@ function ciSharedStaticGates(): Gate[] {
     pnpmScript('client-ui-i18n', 'verify-client-ui-i18n', { label: 'client UI i18n' }),
     pnpmScript('no-bare-dispatcher', 'verify-no-bare-dispatcher', { label: 'proxy-aware dispatchers' }),
     pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
+    pnpmScript('approval-policy', 'test:approval-policy', { label: 'Weighted approval policy' }),
     pnpmScript('request-review', 'test:request-review', { label: 'Review request policy' }),
   ]
 }