diff --git a/.github/review-ownership/README.md b/.github/review-ownership/README.md
index f6f232c895..3616b9bba6 100644
--- a/.github/review-ownership/README.md
+++ b/.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 write-capable 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)
@@ -28,6 +29,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.
+
+
+## Approval scoring
+
+The weighted approval workflow publishes the `weighted approval` commit status on the pull request head. Branch rules must require this status with GitHub Actions as its expected source; a context-only requirement can accept a same-named status from another integration. 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 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, or becomes a draft. 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.
+
## Review exclusions
@@ -44,15 +57,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. The review-event workflow has no `GITHUB_TOKEN` permissions and passes only a decimal pull-request number in its 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 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.
## Verification
-Run `pnpm run test:request-review` for ownership parsing, file classification, complete-patch checks, comment parsing, changed-LOC ranking, pagination, approval-state reduction, 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 ownership parsing, file classification, complete-patch checks, comment parsing, changed-LOC ranking, pagination, approval-state reduction, logging order, non-draft reconciliation, draft cancellation, reviewer provenance, reviewer filtering, and API behavior. 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 both policy checks and the workflow tests in CI.
diff --git a/.github/review-ownership/approval-policy.json b/.github/review-ownership/approval-policy.json
new file mode 100644
index 0000000000..9ecc46ff5b
--- /dev/null
+++ b/.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
+ }
+}
diff --git a/.github/review-ownership/check-approval.mjs b/.github/review-ownership/check-approval.mjs
new file mode 100644
index 0000000000..ac354e601f
--- /dev/null
+++ b/.github/review-ownership/check-approval.mjs
@@ -0,0 +1,377 @@
+#!/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
+
+class GitHubApiError extends Error {
+ constructor(message, status) {
+ super(message)
+ this.name = 'GitHubApiError'
+ this.status = status
+ }
+}
+
+/**
+ * Parse the approval score policy.
+ * @param {string} source Approval policy JSON.
+ * @returns {{requiredPoints: number, defaultPoints: number, reviewerPoints: Map}} 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 current approval or change-request 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)) throw new Error('pull-request review is not an object')
+ if (review.user === null) continue
+ if (!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()
+ const key = login.toLowerCase()
+ if (state === 'DISMISSED') {
+ decisions.delete(key)
+ } else if (state === 'APPROVED' || state === 'CHANGES_REQUESTED') {
+ decisions.set(key, { 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} 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 GitHubApiError(
+ `GitHub API ${method} ${path} returned ${response.status}: ${JSON.stringify(responseBody)}`,
+ response.status,
+ )
+ }
+ 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} api GitHub API caller.
+ * @param {string} repository Owner/name repository identifier.
+ * @param {number} pullNumber Pull-request number.
+ * @returns {Promise} 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}} 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, runUrl: string, write?: (line: string) => void}} options Runtime inputs.
+ * @returns {Promise>>} 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
+}
+
+/**
+ * Resolve the reviewed pull request from a completed review-event workflow run.
+ * @param {{event: unknown, api: (path: string, options?: {method?: string, body?: unknown}) => Promise}} options Trusted workflow inputs.
+ * @returns {Promise | null>} Event with a current pull request, or null after the pull-request head changes.
+ */
+export async function approvalEventFromWorkflowRun({ event, api }) {
+ const repository = repositoryFromEvent(event)
+ if (!isRecord(event.workflow_run) || event.workflow_run.name !== 'weighted-approval-review-event'
+ || event.workflow_run.event !== 'pull_request_review' || event.workflow_run.conclusion !== 'success') {
+ throw new Error('event has no successful weighted approval review workflow run')
+ }
+ const expectedHeadSha = validateHeadSha(event.workflow_run.head_sha, 'workflow run')
+ const pullNumber = parsePullNumber(event.workflow_run.display_title)
+ if (!Array.isArray(event.workflow_run.pull_requests)) {
+ throw new Error('workflow run has no pull_requests array')
+ }
+ if (event.workflow_run.pull_requests.length > 0
+ && !event.workflow_run.pull_requests.some(pull => isRecord(pull) && pull.number === pullNumber)) {
+ throw new Error(`workflow run is not associated with pull request #${pullNumber}`)
+ }
+ const pull = await api(`/repos/${repository}/pulls/${pullNumber}`)
+ if (!isRecord(pull) || !isRecord(pull.head)) throw new Error(`pull request #${pullNumber} response is invalid`)
+ if (pull.head.sha !== expectedHeadSha) return null
+ return { ...event, pull_request: pull }
+}
+
+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) {
+ let response
+ try {
+ response = await api(`/repos/${repository}/collaborators/${encodeURIComponent(login)}/permission`)
+ } catch (error) {
+ if (error instanceof GitHubApiError && error.status === 404) return 'none'
+ throw error
+ }
+ 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) {
+ const repository = repositoryFromEvent(event)
+ 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')
+ const headSha = validateHeadSha(pull.head.sha, 'pull request')
+ return {
+ repository,
+ number: pull.number,
+ draft: pull.draft,
+ author,
+ headSha,
+ }
+}
+
+function repositoryFromEvent(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')
+ }
+ return event.repository.full_name
+}
+
+function validateHeadSha(value, subject) {
+ if (typeof value !== 'string' || !/^[0-9a-f]{40}$/u.test(value)) {
+ throw new Error(`${subject} has no valid head SHA`)
+ }
+ return value
+}
+
+function parsePullNumber(source) {
+ if (typeof source !== 'string') throw new Error('review event has no valid run title')
+ const match = /^weighted-approval-review-event:([1-9][0-9]*)$/u.exec(source)
+ if (!match) throw new Error('review event has no valid run title')
+ const pullNumber = Number(match[1])
+ if (!Number.isSafeInteger(pullNumber)) throw new Error('review event pull request number is not a safe integer')
+ return pullNumber
+}
+
+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')
+ let 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,
+ })
+ if (isRecord(event) && isRecord(event.workflow_run)) {
+ const resolved = await approvalEventFromWorkflowRun({
+ event,
+ api,
+ })
+ if (resolved === null) {
+ process.stdout.write(`${STATUS_PREFIX}\n`)
+ process.stdout.write('Skipped a review event for a superseded pull-request head.\n')
+ return
+ }
+ event = resolved
+ }
+ 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
+ })
+}
diff --git a/.github/review-ownership/check-approval.test.mjs b/.github/review-ownership/check-approval.test.mjs
new file mode 100644
index 0000000000..9a1412b25a
--- /dev/null
+++ b/.github/review-ownership/check-approval.test.mjs
@@ -0,0 +1,317 @@
+import assert from 'node:assert/strict'
+import { readFileSync } from 'node:fs'
+import test from 'node:test'
+
+import {
+ approvalEventFromWorkflowRun,
+ 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 current decision and clears it on dismissal', () => {
+ assert.deepEqual(effectiveReviewDecisions([
+ review('first', 'APPROVED'),
+ review('first', 'APPROVED'),
+ review('first', 'COMMENTED'),
+ review('first', 'DISMISSED'),
+ review('second', 'CHANGES_REQUESTED'),
+ review('second', 'APPROVED'),
+ review('third', 'APPROVED'),
+ review('third', 'CHANGES_REQUESTED'),
+ review('dismissed', 'DISMISSED'),
+ { user: null, state: 'APPROVED' },
+ ]), [
+ { login: 'second', state: 'APPROVED' },
+ { login: 'third', state: 'CHANGES_REQUESTED' },
+ ])
+})
+
+test('resolves a review workflow run to the current pull request and rejects stale heads', async () => {
+ const workflowRunEvent = {
+ repository: { full_name: 'deepseek-harness/deepseek-harness' },
+ workflow_run: {
+ name: 'weighted-approval-review-event',
+ event: 'pull_request_review',
+ conclusion: 'success',
+ head_sha: HEAD_SHA,
+ display_title: 'weighted-approval-review-event:42',
+ pull_requests: [],
+ },
+ }
+ const current = await approvalEventFromWorkflowRun({
+ event: workflowRunEvent,
+ api: async path => {
+ assert.equal(path, '/repos/deepseek-harness/deepseek-harness/pulls/42')
+ return pullRequestEvent().pull_request
+ },
+ })
+ assert.equal(current.pull_request.number, 42)
+
+ assert.equal(await approvalEventFromWorkflowRun({
+ event: workflowRunEvent,
+ api: async () => ({
+ ...pullRequestEvent().pull_request,
+ head: { sha: 'abcdef1234567890abcdef1234567890abcdef12' },
+ }),
+ }), null)
+ await assert.rejects(approvalEventFromWorkflowRun({
+ event: {
+ ...workflowRunEvent,
+ workflow_run: { ...workflowRunEvent.workflow_run, display_title: '../42' },
+ },
+ api: async () => { throw new Error('invalid number must not call GitHub') },
+ }), /valid run title/u)
+})
+
+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('keeps one one-point approval pending without failing the status', async () => {
+ const result = await evaluateApproval({
+ event: pullRequestEvent(),
+ policySource,
+ api: async (path) => {
+ if (path.includes('/reviews?')) return [review('writer', 'APPROVED')]
+ if (path.includes('/collaborators/writer/permission')) return { permission: 'write' }
+ throw new Error(`unexpected API path ${path}`)
+ },
+ })
+ assert.equal(result.state, 'pending')
+ assert.equal(result.points, 1)
+})
+
+test('ignores a reviewer whose collaborator permission lookup returns 404', async () => {
+ const api = createGitHubApi({
+ token: 'secret',
+ fetchImpl: async (url) => {
+ if (url.includes('/reviews?')) {
+ return new Response(JSON.stringify([review('former-writer', 'APPROVED')]), {
+ status: 200,
+ headers: { 'Content-Type': 'application/json' },
+ })
+ }
+ if (url.includes('/collaborators/former-writer/permission')) return new Response('Not Found', { status: 404 })
+ throw new Error(`unexpected API URL ${url}`)
+ },
+ })
+ const result = await evaluateApproval({ event: pullRequestEvent(), policySource, api })
+ assert.equal(result.state, 'pending')
+ assert.deepEqual(result.ignoredReviewers, ['former-writer'])
+})
+
+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)
+})
diff --git a/.github/workflows/weighted-approval-review-event.yml b/.github/workflows/weighted-approval-review-event.yml
new file mode 100644
index 0000000000..403b8c14dd
--- /dev/null
+++ b/.github/workflows/weighted-approval-review-event.yml
@@ -0,0 +1,19 @@
+name: weighted-approval-review-event
+run-name: weighted-approval-review-event:${{ github.event.pull_request.number }}
+
+on:
+ pull_request_review:
+ types: [submitted, edited, dismissed]
+
+permissions: {}
+
+jobs:
+ record-review-event:
+ name: record weighted approval review event
+ runs-on: ubuntu-latest
+ timeout-minutes: 2
+ steps:
+ - name: Record review event
+ run: |
+ echo 'This is by automated Angry Turtle Cyborg, not a human'
+ echo 'Recorded a weighted approval review event.'
diff --git a/.github/workflows/weighted-approval.yml b/.github/workflows/weighted-approval.yml
new file mode 100644
index 0000000000..ed57b60d8d
--- /dev/null
+++ b/.github/workflows/weighted-approval.yml
@@ -0,0 +1,37 @@
+name: weighted-approval
+
+on:
+ pull_request_target:
+ types: [opened, synchronize, reopened, ready_for_review, converted_to_draft]
+ workflow_run:
+ workflows: [weighted-approval-review-event]
+ types: [completed]
+
+permissions:
+ contents: read
+ pull-requests: read
+ statuses: write
+
+concurrency:
+ group: weighted-approval-${{ github.event.pull_request.number || github.event.workflow_run.head_sha }}
+ cancel-in-progress: false
+
+jobs:
+ publish-status:
+ if: github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'
+ 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
diff --git a/package.json b/package.json
index ba1093021f..91ca336e0f 100644
--- a/package.json
+++ b/package.json
@@ -57,6 +57,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",
"test:issue-management": "node .github/issue-management/policy.test.mjs",
"test:request-review": "node --test .github/review-ownership/request-review.test.mjs",
"test:snapshot": "vitest run --config vitest.snapshot.config.ts",
diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts
index 8c458d0b0c..6f0cd051da 100644
--- a/scripts/ci-workflow.spec.ts
+++ b/scripts/ci-workflow.spec.ts
@@ -815,6 +815,78 @@ describe('Request review workflow', () => {
})
})
+describe('Weighted approval workflow', () => {
+ it('publishes from the trusted default branch after pull request and review updates', () => {
+ const publisher = loadWorkflow('.github/workflows/weighted-approval.yml')
+ const reviewEvent = loadWorkflow('.github/workflows/weighted-approval-review-event.yml')
+ const pullRequest = workflowEvent(publisher, 'pull_request_target')
+ const workflowRun = workflowEvent(publisher, 'workflow_run')
+ const review = workflowEvent(reviewEvent, 'pull_request_review')
+ const job = workflowJob(publisher, 'publish-status')
+ const recordJob = workflowJob(reviewEvent, 'record-review-event')
+ if (!isRecord(publisher.on)) throw new TypeError('weighted-approval workflow must define events')
+ if (!isRecord(reviewEvent.on)) throw new TypeError('weighted-approval review event workflow must define events')
+ if (!Array.isArray(job.steps)) throw new TypeError('weighted-approval job must define steps')
+ if (!Array.isArray(recordJob.steps)) throw new TypeError('weighted-approval review event 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')
+ const recordSteps = recordJob.steps.filter(isRecord)
+ 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(pullRequest.types).toEqual(['opened', 'synchronize', 'reopened', 'ready_for_review', 'converted_to_draft'])
+ expect(workflowRun).toEqual({ workflows: ['weighted-approval-review-event'], types: ['completed'] })
+ expect(reviewEvent.name).toBe('weighted-approval-review-event')
+ expect(reviewEvent['run-name']).toBe('weighted-approval-review-event:${{ github.event.pull_request.number }}')
+ expect(Object.keys(reviewEvent.on)).toEqual(['pull_request_review'])
+ expect(review.types).toEqual(['submitted', 'edited', 'dismissed'])
+ expect(reviewEvent.permissions).toEqual({})
+ expect(publisher.permissions).toEqual({
+ contents: 'read',
+ 'pull-requests': 'read',
+ statuses: 'write',
+ })
+ expect(publisher.concurrency).toEqual({
+ group: 'weighted-approval-${{ github.event.pull_request.number || github.event.workflow_run.head_sha }}',
+ 'cancel-in-progress': false,
+ })
+ expect(job).toMatchObject({
+ if: "github.event_name != 'workflow_run' || github.event.workflow_run.conclusion == 'success'",
+ 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(recordJob).toMatchObject({
+ name: 'record weighted approval review event',
+ 'runs-on': 'ubuntu-latest',
+ 'timeout-minutes': 2,
+ })
+ expect(record).toBeDefined()
+ expect(record?.run).toContain('This is by automated Angry Turtle Cyborg, not a human')
+ expect(recordSteps).toHaveLength(1)
+ expect(JSON.stringify(publisher)).not.toContain('github.event.pull_request.head')
+ expect(JSON.stringify(publisher)).not.toContain('secrets.')
+ expect(JSON.stringify(reviewEvent)).not.toContain('github.token')
+ expect(JSON.stringify(reviewEvent)).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')
diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts
index 31f6bc2aff..61e445ad89 100644
--- a/scripts/run-gates.spec.ts
+++ b/scripts/run-gates.spec.ts
@@ -267,6 +267,15 @@ describe('gate graph validation', () => {
},
)
+ it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
+ 'keeps weighted approval policy tests in %s',
+ (mode) => {
+ const ids = withPnpmEntrypoint(() => gatesForMode(mode).map(subject => subject.id))
+
+ expect(ids).toContain('approval-policy')
+ },
+ )
+
it.each(['ci-primary', 'ci-static', 'check-all'] as const)(
'keeps review request policy tests in %s',
(mode) => {
diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts
index 1b08040d06..f49fa72011 100644
--- a/scripts/run-gates.ts
+++ b/scripts/run-gates.ts
@@ -266,6 +266,7 @@ export function gatesForMode(selected: Mode): Gate[] {
pnpmScript('cordis-config', 'verify-cordis-config', { label: 'Cordis config' }),
pnpmScript('client-domain-graph', 'verify-client-domain-graph', { label: 'client domain graph' }),
pnpmScript('test', 'test'),
+ pnpmScript('approval-policy', 'test:approval-policy', { label: 'Weighted approval policy' }),
pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
pnpmScript('request-review', 'test:request-review', { label: 'Review request policy' }),
pnpmScript('duplication', 'duplication'),
@@ -310,6 +311,7 @@ function ciSharedStaticGates(): Gate[] {
pnpmScript('client-packages', 'verify-client-packages', { label: 'client packages' }),
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('approval-policy', 'test:approval-policy', { label: 'Weighted approval policy' }),
pnpmScript('issue-management', 'test:issue-management', { label: 'Issue management policy' }),
pnpmScript('request-review', 'test:request-review', { label: 'Review request policy' }),
]