You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@maka.apache.org by as...@apache.org on 2026/08/26 16:42:43 UTC

(maka) 01/01: feat(ci): label pull requests by review state and reading effort

This is an automated email from the ASF dual-hosted git repository.

Astro-Han pushed a commit to branch feat/pr-triage-state-labels
in repository https://gitbox.apache.org/repos/asf/maka.git

commit ae5fc436ee38913ec3cd5fc6cdad254bb4b1c7ca
Author: AstroHan <le...@outlook.com>
AuthorDate: Thu Aug 27 00:42:28 2026 +0800

    feat(ci): label pull requests by review state and reading effort
    
    The pull request list carries no labels at all, so finding the work that
    actually needs a reviewer means opening pull requests one at a time. At the
    current rate that scan is repeated over more than a hundred open pull
    requests, and two facts it depends on are not visible from the list at all:
    which stalled runs are waiting on a maintainer rather than on the author, and
    which approvals have already been retired by a later push.
    
    Project each pull request onto two independent axes instead.
    
    Status answers whose move it is. `waiting-infra` covers a run held by the fork
    approval gate or never created, which the contributor cannot clear; the gate
    outranks a sibling failure because no amount of fixing produces a complete
    verdict while a run is held. `needs-author` covers a conclusive failure or a
    standing request for changes. `review-ready` is green and unapproved, and is
    the only queue a reviewer needs to read. `merge-ready` additionally carries a
    committer approval on the current head.
    
    Effort answers how much reading a pull request is, on tiers that map to the
    only two decisions a reviewer makes about size: whether it can be cleared
    between other work, and whether it is too large to read as one unit.
    Lockfiles, regenerated artifacts and binaries are excluded from the count,
    since a pull request that touches one would otherwise land several tiers above
    what it asks anyone to read. Test code is not discounted; it is reviewed too.
    
    Approvals and requests for changes are treated asymmetrically on purpose. An
    approval binds to the head it was given on, so a push retires it even where
    stale-review dismissal is off. A request for changes survives a push, or
    pushing anything at all would clear the block.
    
    The labels are a projection and nothing more. Branch protection remains the
    only merge authority, and no label here grants or withholds it. A draft is
    left unlabelled rather than having a check result overwrite the author's own
    statement that it is not ready.
    
    `scripts/pr-triage-dryrun.mjs` reports the same classification over live pull
    requests without writing anything, so a rule change is reviewed against real
    data before it reaches anyone's pull request.
    
    Generated-by: Claude Code
---
 .github/workflows/ci.yml        |   6 +
 .github/workflows/pr-triage.yml | 149 +++++++++++++++++++++++
 scripts/pr-triage-dryrun.mjs    | 134 +++++++++++++++++++++
 scripts/pr-triage.mjs           | 228 +++++++++++++++++++++++++++++++++++
 scripts/pr-triage.test.mjs      | 261 ++++++++++++++++++++++++++++++++++++++++
 5 files changed, 778 insertions(+)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f3367e20f..551f9fe04 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -61,6 +61,12 @@ jobs:
       - name: Test CI planner
         run: node --test --test-concurrency=1 scripts/ci-test-plan.test.mjs scripts/verify-windows-harness.test.mjs
 
+      # Pure Node like the planner test, and the triage workflow imports this
+      # module directly, so a rule change is caught here rather than by
+      # mislabelling live pull requests.
+      - name: Test PR triage classification
+        run: node --test --test-concurrency=1 scripts/pr-triage.test.mjs
+
       # Same shape and the same needs: a regenerate-and-diff contract that runs
       # on Node alone, so it belongs beside the planner test rather than behind
       # an install.
diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml
new file mode 100644
index 000000000..948f672bd
--- /dev/null
+++ b/.github/workflows/pr-triage.yml
@@ -0,0 +1,149 @@
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+name: PR triage
+
+# Every trigger here is an event that can change which of the two questions a
+# pull request answers differently: whose move it is, and how much reading it
+# is. CI finishing is one of those, so the CI workflow completing re-runs this
+# as well; without it a pull request would sit mislabelled until its next push.
+on:
+  pull_request_target:
+    types: [opened, reopened, synchronize, ready_for_review, converted_to_draft]
+    branches: [main]
+  pull_request_review:
+    types: [submitted, dismissed]
+  workflow_run:
+    workflows: [CI]
+    types: [completed]
+
+permissions:
+  contents: read
+  pull-requests: write
+
+concurrency:
+  group: >-
+    pr-triage-${{ github.event.pull_request.number
+      || github.event.workflow_run.id
+      || github.run_id }}
+  cancel-in-progress: true
+
+jobs:
+  triage:
+    runs-on: ubuntu-latest
+    timeout-minutes: 5
+
+    steps:
+      # pull_request_target runs with a writable token against fork pull
+      # requests. This checkout is the base branch, never the pull request head,
+      # and nothing from the pull request is executed or installed here.
+      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+        with:
+          persist-credentials: false
+
+      - uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+        with:
+          script: |
+            const path = require("node:path")
+            const { pathToFileURL } = require("node:url")
+
+            const { planLabels } = await import(
+              pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, "scripts/pr-triage.mjs")).href
+            )
+
+            const { owner, repo } = context.repo
+
+            // workflow_run carries the head SHA rather than a pull request, so
+            // the run is mapped back to whatever pull requests that SHA heads.
+            async function resolveNumbers() {
+              const fromEvent = context.payload.pull_request?.number
+              if (fromEvent) return [fromEvent]
+
+              const sha = context.payload.workflow_run?.head_sha
+              if (!sha) return []
+
+              const { data } = await github.rest.repos.listPullRequestsAssociatedWithCommit({
+                owner,
+                repo,
+                commit_sha: sha,
+              })
+              return data.filter((pull) => pull.state === "open").map((pull) => pull.number)
+            }
+
+            for (const pull_number of await resolveNumbers()) {
+              const { data: pull } = await github.rest.pulls.get({ owner, repo, pull_number })
+
+              // Re-read the head from the API rather than trusting the event
+              // payload: a push racing this run would otherwise let a stale
+              // approval be projected as merge-ready.
+              const headSha = pull.head.sha
+
+              const [checks, reviews, files, current] = await Promise.all([
+                github.paginate(github.rest.checks.listForRef, {
+                  owner,
+                  repo,
+                  ref: headSha,
+                  per_page: 100,
+                }),
+                github.paginate(github.rest.pulls.listReviews, { owner, repo, pull_number, per_page: 100 }),
+                github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number, per_page: 100 }),
+                github.paginate(github.rest.issues.listLabelsOnIssue, {
+                  owner,
+                  repo,
+                  issue_number: pull_number,
+                  per_page: 100,
+                }),
+              ])
+
+              const plan = planLabels(
+                {
+                  isDraft: pull.draft,
+                  headSha,
+                  authorLogin: pull.user?.login,
+                  checks: checks.map((check) => ({
+                    status: check.status,
+                    conclusion: check.conclusion,
+                  })),
+                  reviews,
+                  files,
+                },
+                current.map((label) => label.name),
+              )
+
+              if (plan.addLabels.length > 0) {
+                await github.rest.issues.addLabels({
+                  owner,
+                  repo,
+                  issue_number: pull_number,
+                  labels: plan.addLabels,
+                })
+              }
+
+              for (const name of plan.removeLabels) {
+                try {
+                  await github.rest.issues.removeLabel({ owner, repo, issue_number: pull_number, name })
+                } catch (error) {
+                  // Another run may have removed it first; anything else is real.
+                  if (error.status !== 404) throw error
+                }
+              }
+
+              core.info(
+                `#${pull_number}: status=${plan.status ?? "(none)"} effort=${plan.effort ?? "(none)"}` +
+                  ` +[${plan.addLabels.join(", ")}] -[${plan.removeLabels.join(", ")}]`,
+              )
+            }
diff --git a/scripts/pr-triage-dryrun.mjs b/scripts/pr-triage-dryrun.mjs
new file mode 100644
index 000000000..10c697002
--- /dev/null
+++ b/scripts/pr-triage-dryrun.mjs
@@ -0,0 +1,134 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Reports what pr-triage.mjs would label on every open pull request, without
+// writing anything. Run it before enabling the workflow and after changing a
+// rule, so the classification is reviewed against real pull requests rather
+// than only against fixtures.
+//
+//   node scripts/pr-triage-dryrun.mjs [--repo apache/maka] [--limit 300]
+
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+
+import { classifyEffort, classifyStatus } from './pr-triage.mjs';
+
+const run = promisify(execFile);
+
+function arg(name, fallback) {
+  const index = process.argv.indexOf(`--${name}`);
+  return index === -1 ? fallback : process.argv[index + 1];
+}
+
+const repo = arg('repo', 'apache/maka');
+const limit = arg('limit', '300');
+
+// A full sweep makes one request per reviewed pull request, so a single
+// transient socket error should not discard the whole run.
+async function gh(args, attempt = 1) {
+  try {
+    const { stdout } = await run('gh', args, { maxBuffer: 256 * 1024 * 1024 });
+    return stdout.trim() ? JSON.parse(stdout) : null;
+  } catch (error) {
+    if (attempt >= 3) throw error;
+    await new Promise((resolve) => setTimeout(resolve, 1000 * attempt));
+    return gh(args, attempt + 1);
+  }
+}
+
+const pulls = await gh([
+  'pr',
+  'list',
+  '--repo',
+  repo,
+  '--state',
+  'open',
+  '--limit',
+  limit,
+  '--json',
+  'number,isDraft,author,reviewDecision,headRefOid,statusCheckRollup,files,labels',
+]);
+
+// Only pull requests carrying a review at all need the per-review detail that
+// decides merge-ready and needs-author, so the extra request stays off the
+// common path.
+const reviewsByNumber = new Map();
+for (const pull of pulls) {
+  if (pull.reviewDecision === null) continue;
+  reviewsByNumber.set(
+    pull.number,
+    await gh(['api', `repos/${repo}/pulls/${pull.number}/reviews`, '--paginate']),
+  );
+}
+
+const rows = pulls.map((pull) => {
+  const input = {
+    isDraft: pull.isDraft,
+    headSha: pull.headRefOid,
+    authorLogin: pull.author?.login,
+    checks: pull.statusCheckRollup ?? [],
+    reviews: reviewsByNumber.get(pull.number) ?? [],
+    files: (pull.files ?? []).map((file) => ({
+      filename: file.path,
+      additions: file.additions,
+      deletions: file.deletions,
+    })),
+  };
+
+  const effort = classifyEffort(input.files);
+  return {
+    number: pull.number,
+    status: classifyStatus(input) ?? '(none)',
+    effort: pull.isDraft ? '(none)' : effort.label,
+    readable: effort.lines,
+    raw: (pull.files ?? []).reduce((sum, file) => sum + file.additions + file.deletions, 0),
+  };
+});
+
+function tally(rows, key) {
+  const counts = new Map();
+  for (const row of rows) counts.set(row[key], (counts.get(row[key]) ?? 0) + 1);
+  return [...counts].sort((a, b) => b[1] - a[1]);
+}
+
+console.log(`${repo}: ${rows.length} open pull requests\n`);
+
+console.log('status');
+for (const [label, count] of tally(rows, 'status')) {
+  console.log(`  ${label.padEnd(22)} ${String(count).padStart(4)}`);
+}
+
+console.log('\neffort');
+for (const [label, count] of tally(rows, 'effort')) {
+  console.log(`  ${label.padEnd(22)} ${String(count).padStart(4)}`);
+}
+
+const discounted = rows
+  .filter((row) => row.raw !== row.readable)
+  .sort((a, b) => b.raw - a.raw)
+  .slice(0, 10);
+
+if (discounted.length > 0) {
+  console.log('\nlargest gaps between raw and readable lines');
+  for (const row of discounted) {
+    console.log(
+      `  #${String(row.number).padEnd(6)} raw ${String(row.raw).padStart(7)} -> ${String(row.readable).padStart(7)}  ${row.effort}`,
+    );
+  }
+}
diff --git a/scripts/pr-triage.mjs b/scripts/pr-triage.mjs
new file mode 100644
index 000000000..85c2653ec
--- /dev/null
+++ b/scripts/pr-triage.mjs
@@ -0,0 +1,228 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// Projects a pull request's own facts onto two independent label axes so the
+// list view answers "whose move is it" and "how much reading is this" without
+// opening anything. The labels are a projection: branch protection stays the
+// only merge authority, and nothing here grants or withholds it.
+
+export const STATUS_LABELS = [
+  'status/waiting-infra',
+  'status/needs-author',
+  'status/review-ready',
+  'status/merge-ready',
+];
+
+export const EFFORT_LABELS = ['effort/S', 'effort/M', 'effort/XL'];
+
+// Reviewing carries write access on this repository, and GitHub reports the
+// association per review, so committer standing needs no separate roster that
+// could drift out of date.
+const COMMITTER_ASSOCIATIONS = new Set(['OWNER', 'MEMBER', 'COLLABORATOR']);
+
+// Counted changes should track what a human actually reads. Lockfiles,
+// regenerated artifacts and binaries are verified by their own contracts, so
+// letting their line counts reach the effort tiers would inflate every pull
+// request that happens to touch one.
+const UNREAD_PATTERNS = [
+  /(^|\/)(package-lock\.json|pnpm-lock\.yaml|yarn\.lock)$/,
+  /(^|\/)THIRD_PARTY_NOTICES[^/]*$/,
+  /\.generated\.[cm]?[jt]sx?$/,
+  /\.snapshot\.json$/,
+  /\.min\.(js|css)$/,
+  /\.(png|jpe?g|gif|ico|webp|woff2?|ttf|sqlite|zip|gz|pdf)$/,
+];
+
+// Only two decisions need a boundary: whether a reviewer can clear this
+// between other work, and whether it is too large to read as one unit at all.
+const EFFORT_TIERS = [
+  { label: 'effort/S', maxLines: 100 },
+  { label: 'effort/M', maxLines: 1000 },
+  { label: 'effort/XL', maxLines: Number.POSITIVE_INFINITY },
+];
+
+export function isUnreadPath(path) {
+  const normalized = String(path).replace(/\\/g, '/');
+  return UNREAD_PATTERNS.some((pattern) => pattern.test(normalized));
+}
+
+/**
+ * @param {Array<{filename: string, additions?: number, deletions?: number}>} files
+ */
+export function countReadableLines(files = []) {
+  return files.reduce((total, file) => {
+    if (isUnreadPath(file.filename)) return total;
+    return total + (file.additions ?? 0) + (file.deletions ?? 0);
+  }, 0);
+}
+
+export function classifyEffort(files = []) {
+  const lines = countReadableLines(files);
+  const tier = EFFORT_TIERS.find((candidate) => lines <= candidate.maxLines);
+  return { label: tier.label, lines };
+}
+
+/**
+ * Collapses check runs into the one fact that changes what happens next.
+ *
+ * `action_required` is the fork approval gate: the run exists but no maintainer
+ * has released it, so the contributor cannot act and the stall is not theirs.
+ * `cancelled` is concurrency superseding a run rather than a verdict, so it
+ * never counts as red on its own.
+ *
+ * @param {Array<{status?: string, conclusion?: string, state?: string}>} checks
+ */
+export function classifyChecks(checks = []) {
+  if (checks.length === 0) return 'infra';
+
+  let sawGate = false;
+  let sawFailure = false;
+  let sawPending = false;
+  let sawConclusive = false;
+
+  for (const check of checks) {
+    const conclusion = String(check.conclusion ?? check.state ?? '').toLowerCase();
+    const status = String(check.status ?? '').toLowerCase();
+
+    if (conclusion === 'action_required') {
+      sawGate = true;
+      continue;
+    }
+    if (conclusion === 'failure' || conclusion === 'timed_out') {
+      sawFailure = true;
+      continue;
+    }
+    if (conclusion === 'cancelled') continue;
+
+    if (!conclusion || status === 'queued' || status === 'in_progress' || status === 'pending') {
+      sawPending = true;
+      continue;
+    }
+
+    sawConclusive = true;
+  }
+
+  // The gate outranks a sibling failure. While any run is still held, the
+  // author cannot obtain a complete verdict however much they fix, so the
+  // outstanding move is the maintainer's and the label should say so.
+  if (sawGate) return 'infra';
+  if (sawFailure) return 'red';
+  if (sawPending) return 'pending';
+
+  // Every run was cancelled: nothing was verified and nothing failed, so this
+  // is the same "no verdict yet" as pending rather than a reason to move the
+  // pull request onto either queue.
+  return sawConclusive ? 'green' : 'pending';
+}
+
+/**
+ * Reduces the review history to the two facts that move a pull request between
+ * queues, so both are read off one pass and cannot disagree.
+ *
+ * The two are deliberately asymmetric. An approval speaks only for the tree it
+ * was given on, so a new push retires it even where the repository has not
+ * enabled stale-review dismissal. A request for changes is not retired by a
+ * push — otherwise pushing anything at all would clear the block — and stands
+ * until that reviewer says something else.
+ *
+ * @param {Array<{user?: {login?: string}, state?: string, commit_id?: string,
+ *   author_association?: string}>} reviews
+ */
+export function summarizeReviews(reviews = [], headSha, authorLogin) {
+  const latestByReviewer = new Map();
+
+  for (const review of reviews) {
+    const state = String(review.state ?? '').toUpperCase();
+    // COMMENTED and PENDING state no position, so they neither establish nor
+    // withdraw one.
+    if (state !== 'APPROVED' && state !== 'CHANGES_REQUESTED' && state !== 'DISMISSED') continue;
+
+    const login = review.user?.login;
+    if (!login || login === authorLogin) continue;
+
+    // Reviews arrive in submission order, so a later entry is this reviewer's
+    // current position and replaces whatever they said before.
+    latestByReviewer.set(login, review);
+  }
+
+  let changesRequested = false;
+  let committerApprovalOnHead = false;
+
+  for (const review of latestByReviewer.values()) {
+    const state = String(review.state).toUpperCase();
+
+    if (state === 'CHANGES_REQUESTED') {
+      changesRequested = true;
+      continue;
+    }
+
+    if (state !== 'APPROVED') continue;
+    if (review.commit_id !== headSha) continue;
+    if (!COMMITTER_ASSOCIATIONS.has(String(review.author_association ?? '').toUpperCase()))
+      continue;
+
+    committerApprovalOnHead = true;
+  }
+
+  return { changesRequested, committerApprovalOnHead };
+}
+
+/**
+ * @param {{isDraft?: boolean, headSha?: string, authorLogin?: string,
+ *   checks?: Array<object>, reviews?: Array<object>}} pull
+ */
+export function classifyStatus(pull) {
+  // Draft is the author's own statement that this is not up for review yet. A
+  // check result is a different fact and must not overwrite that statement.
+  if (pull.isDraft) return null;
+
+  const { changesRequested, committerApprovalOnHead } = summarizeReviews(
+    pull.reviews,
+    pull.headSha,
+    pull.authorLogin,
+  );
+
+  if (changesRequested) return 'status/needs-author';
+
+  const checks = classifyChecks(pull.checks);
+  if (checks === 'infra') return 'status/waiting-infra';
+  if (checks === 'red') return 'status/needs-author';
+  if (checks === 'pending') return null;
+
+  return committerApprovalOnHead ? 'status/merge-ready' : 'status/review-ready';
+}
+
+/**
+ * @param {object} pull
+ * @param {string[]} currentLabels
+ */
+export function planLabels(pull, currentLabels = []) {
+  const status = classifyStatus(pull);
+  const effort = pull.isDraft ? null : classifyEffort(pull.files).label;
+  const desired = new Set([status, effort].filter(Boolean));
+  const current = new Set(currentLabels);
+  const managed = [...STATUS_LABELS, ...EFFORT_LABELS];
+
+  return {
+    status,
+    effort,
+    addLabels: [...desired].filter((label) => !current.has(label)),
+    removeLabels: managed.filter((label) => current.has(label) && !desired.has(label)),
+  };
+}
diff --git a/scripts/pr-triage.test.mjs b/scripts/pr-triage.test.mjs
new file mode 100644
index 000000000..2aa0f0315
--- /dev/null
+++ b/scripts/pr-triage.test.mjs
@@ -0,0 +1,261 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements.  See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership.  The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied.  See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { describe, it } from 'node:test';
+
+import {
+  classifyChecks,
+  classifyEffort,
+  classifyStatus,
+  countReadableLines,
+  summarizeReviews,
+  planLabels,
+} from './pr-triage.mjs';
+
+const HEAD = 'abc123';
+
+function pull(overrides = {}) {
+  return {
+    isDraft: false,
+    headSha: HEAD,
+    authorLogin: 'contributor',
+    checks: [{ status: 'completed', conclusion: 'success' }],
+    reviews: [],
+    files: [{ filename: 'packages/core/src/index.ts', additions: 5, deletions: 1 }],
+    ...overrides,
+  };
+}
+
+function approval(overrides = {}) {
+  return {
+    user: { login: 'committer' },
+    state: 'APPROVED',
+    commit_id: HEAD,
+    author_association: 'MEMBER',
+    ...overrides,
+  };
+}
+
+describe('classifyChecks', () => {
+  it('reads a fork PR awaiting workflow approval as infrastructure, not author fault', () => {
+    assert.equal(classifyChecks([{ status: 'completed', conclusion: 'action_required' }]), 'infra');
+  });
+
+  it('reads no checks at all as infrastructure', () => {
+    assert.equal(classifyChecks([]), 'infra');
+  });
+
+  it('reports red only for a conclusive failure', () => {
+    assert.equal(classifyChecks([{ conclusion: 'success' }, { conclusion: 'failure' }]), 'red');
+    assert.equal(classifyChecks([{ conclusion: 'timed_out' }]), 'red');
+  });
+
+  it('does not treat a superseded run as a verdict', () => {
+    assert.equal(classifyChecks([{ conclusion: 'cancelled' }, { conclusion: 'success' }]), 'green');
+    assert.equal(classifyChecks([{ conclusion: 'cancelled' }]), 'pending');
+  });
+
+  it('holds while any run is still going', () => {
+    assert.equal(classifyChecks([{ conclusion: 'success' }, { status: 'in_progress' }]), 'pending');
+  });
+
+  it('prefers the fork gate over a sibling failure, since the author cannot act either way', () => {
+    assert.equal(
+      classifyChecks([{ conclusion: 'failure' }, { conclusion: 'action_required' }]),
+      'infra',
+    );
+  });
+});
+
+describe('countReadableLines', () => {
+  it('excludes lockfiles, generated artifacts and binaries', () => {
+    const files = [
+      { filename: 'packages/cli/src/main.ts', additions: 20, deletions: 4 },
+      { filename: 'pnpm-lock.yaml', additions: 9000, deletions: 8000 },
+      { filename: 'packages/core/src/model-metadata.generated.ts', additions: 5000, deletions: 0 },
+      {
+        filename: 'scripts/model-metadata/models-dev-api.snapshot.json',
+        additions: 4000,
+        deletions: 0,
+      },
+      { filename: 'apps/desktop/build/background@2x.png', additions: 1, deletions: 0 },
+      {
+        filename: 'apps/desktop/resources/licenses/npm/THIRD_PARTY_NOTICES.txt',
+        additions: 900,
+        deletions: 0,
+      },
+    ];
+    assert.equal(countReadableLines(files), 24);
+  });
+
+  it('does not discount test code, which still has to be reviewed', () => {
+    const files = [
+      { filename: 'packages/core/test/session.test.ts', additions: 300, deletions: 0 },
+    ];
+    assert.equal(countReadableLines(files), 300);
+  });
+});
+
+describe('classifyEffort', () => {
+  it('places each tier on its boundary', () => {
+    assert.equal(
+      classifyEffort([{ filename: 'a.ts', additions: 100, deletions: 0 }]).label,
+      'effort/S',
+    );
+    assert.equal(
+      classifyEffort([{ filename: 'a.ts', additions: 101, deletions: 0 }]).label,
+      'effort/M',
+    );
+    assert.equal(
+      classifyEffort([{ filename: 'a.ts', additions: 1000, deletions: 0 }]).label,
+      'effort/M',
+    );
+    assert.equal(
+      classifyEffort([{ filename: 'a.ts', additions: 1001, deletions: 0 }]).label,
+      'effort/XL',
+    );
+  });
+
+  it('keeps a lockfile-only change small', () => {
+    assert.equal(
+      classifyEffort([{ filename: 'pnpm-lock.yaml', additions: 5000, deletions: 4000 }]).label,
+      'effort/S',
+    );
+  });
+});
+
+describe('summarizeReviews', () => {
+  it('accepts a committer approval on the current head', () => {
+    assert.equal(summarizeReviews([approval()], HEAD, 'contributor').committerApprovalOnHead, true);
+  });
+
+  it('rejects an approval left on a superseded commit', () => {
+    assert.equal(
+      summarizeReviews([approval({ commit_id: 'stale' })], HEAD, 'contributor')
+        .committerApprovalOnHead,
+      false,
+    );
+  });
+
+  it('rejects an approval from someone without write access', () => {
+    assert.equal(
+      summarizeReviews([approval({ author_association: 'CONTRIBUTOR' })], HEAD, 'contributor')
+        .committerApprovalOnHead,
+      false,
+    );
+  });
+
+  it('rejects the author approving their own pull request', () => {
+    assert.equal(
+      summarizeReviews([approval({ user: { login: 'contributor' } })], HEAD, 'contributor')
+        .committerApprovalOnHead,
+      false,
+    );
+  });
+
+  it("counts only each reviewer's latest position", () => {
+    const reviews = [approval(), approval({ state: 'CHANGES_REQUESTED' })];
+    assert.equal(summarizeReviews(reviews, HEAD, 'contributor').committerApprovalOnHead, false);
+
+    const reconsidered = [approval({ state: 'CHANGES_REQUESTED' }), approval()];
+    assert.equal(summarizeReviews(reconsidered, HEAD, 'contributor').committerApprovalOnHead, true);
+  });
+
+  it('still finds an approval when another reviewer is undecided', () => {
+    const reviews = [approval({ user: { login: 'other' }, state: 'COMMENTED' }), approval()];
+    assert.equal(summarizeReviews(reviews, HEAD, 'contributor').committerApprovalOnHead, true);
+  });
+});
+
+describe('classifyStatus', () => {
+  it("leaves a draft unlabelled rather than overriding the author's own statement", () => {
+    assert.equal(
+      classifyStatus(pull({ isDraft: true, checks: [{ conclusion: 'failure' }] })),
+      null,
+    );
+  });
+
+  it('moves a requested change back to the author', () => {
+    const reviews = [approval({ state: 'CHANGES_REQUESTED' })];
+    assert.equal(classifyStatus(pull({ reviews })), 'status/needs-author');
+  });
+
+  it('keeps a requested change in force across a later push', () => {
+    const reviews = [approval({ state: 'CHANGES_REQUESTED', commit_id: 'older' })];
+    assert.equal(classifyStatus(pull({ reviews })), 'status/needs-author');
+  });
+
+  it('blames the author only for a conclusive failure', () => {
+    assert.equal(
+      classifyStatus(pull({ checks: [{ conclusion: 'failure' }] })),
+      'status/needs-author',
+    );
+  });
+
+  it('surfaces a stalled fork run as waiting on infrastructure', () => {
+    assert.equal(
+      classifyStatus(pull({ checks: [{ conclusion: 'action_required' }] })),
+      'status/waiting-infra',
+    );
+  });
+
+  it('holds off entirely while checks are still running', () => {
+    assert.equal(classifyStatus(pull({ checks: [{ status: 'in_progress' }] })), null);
+  });
+
+  it('queues a green pull request for review', () => {
+    assert.equal(classifyStatus(pull()), 'status/review-ready');
+  });
+
+  it('marks a green pull request with a current committer approval as mergeable', () => {
+    assert.equal(classifyStatus(pull({ reviews: [approval()] })), 'status/merge-ready');
+  });
+
+  it('drops back to review-ready once a new push invalidates the approval', () => {
+    const pushed = pull({ headSha: 'def456', reviews: [approval()] });
+    assert.equal(classifyStatus(pushed), 'status/review-ready');
+  });
+});
+
+describe('planLabels', () => {
+  it('adds the projected pair and removes nothing it does not own', () => {
+    const plan = planLabels(pull(), ['bug']);
+    assert.deepEqual(plan.addLabels.sort(), ['effort/S', 'status/review-ready']);
+    assert.deepEqual(plan.removeLabels, []);
+  });
+
+  it('replaces a superseded status without touching the effort label', () => {
+    const plan = planLabels(pull({ reviews: [approval()] }), ['status/review-ready', 'effort/S']);
+    assert.deepEqual(plan.addLabels, ['status/merge-ready']);
+    assert.deepEqual(plan.removeLabels, ['status/review-ready']);
+  });
+
+  it('clears its own labels from a pull request that went back to draft', () => {
+    const plan = planLabels(pull({ isDraft: true }), ['status/review-ready', 'effort/M', 'bug']);
+    assert.deepEqual(plan.addLabels, []);
+    assert.deepEqual(plan.removeLabels.sort(), ['effort/M', 'status/review-ready']);
+  });
+
+  it('is idempotent once the labels already match', () => {
+    const plan = planLabels(pull(), ['status/review-ready', 'effort/S']);
+    assert.deepEqual(plan.addLabels, []);
+    assert.deepEqual(plan.removeLabels, []);
+  });
+});


---------------------------------------------------------------------
To unsubscribe, e-mail: commits-unsubscribe@maka.apache.org
For additional commands, e-mail: commits-help@maka.apache.org