mirror of
https://github.com/github/awesome-copilot.git
synced 2026-10-12 23:04:14 +00:00
* Phase 1: distributed CODEOWNERS and automated reviewer routing (#4184) - Replace the individual default CODEOWNERS owner with the core-maintainer team and add domain team ownership for extensions, plugins, content, hooks, workflows, .github, and eng. - Add .github/review-routing.yml as the single source of truth for reviewer pools, routes, labels, and SLA settings (ships in dry-run mode). - Add eng/review-routing.mjs (+ unit tests) with business-day math, least-loaded reviewer selection, routing and escalation planning, and workflow_run artifact validation. - Add Review Routing (workflow_run/dispatch/schedule), Review Routing Request (read-only needs-reviewer reader), and Review Escalation (weekday schedule) workflows. - Add needs-reviewer, review-overdue, review-escalated to setup-labels. - Document the model in docs/maintainers/review-routing.md. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address review feedback on reviewer routing and escalation - Escalate at routedOn + escalation business days (Mon -> Fri) - Leave labels untouched when requestReviewers fails and fail the job - Serialize read/plan/apply per PR across routing and escalation - Treat Label PR Intent artifact labels as authoritative - Re-route to an uncovered pool on head changes without restarting SLA - Use the due-label timestamp as the review boundary - Fall back to pool/escalation teams and report what was requested Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Address second-round review feedback on review routing (#4184) - CODEOWNERS: route /hooks/ and /workflows/ to content reviewers, drop the separate /.github/workflows/ rule, and leave per-resource owner lines as on main (their removal is deferred to a separate cleanup). - Routing: ignore pending requests for unavailable reviewers when checking coverage; needs-reviewer excludes prior reviewers and changes nothing when no new reviewer can be requested. - Escalation comments show the actual routing date. - Cap sweep matrices at 256 targets and defer the remainder. - Docs: best-effort load balancing, matrix cap, and needs-reviewer semantics. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Route workflow and hooks labels to the content pool (#4184) Drop the workflow-security reviewer pool per maintainer feedback: agentic workflows and hooks are reviewed like other content, and the repository's own GitHub Actions workflows stay with core maintainers. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Pin routing contract in tests; make CONTRIBUTING routing note dry-run aware - Assert the exact label-to-pool mapping and priority order, pool set, and default/escalation pools for the shipped config. - CONTRIBUTING.md: describe automatic routing as active only once dry_run is turned off. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Simplify dry_run validation condition Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Aaron Powell <me@aaron-powell.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
co-authored by
Copilot App
Copilot Autofix powered by AI
Aaron Powell
parent
c442f0d917
commit
0c54bcee82
@@ -0,0 +1,85 @@
|
||||
# Review routing configuration — single source of truth for reviewer pools.
|
||||
#
|
||||
# Read by eng/review-routing.mjs from the Review Routing and Review Escalation
|
||||
# workflows. See docs/maintainers/review-routing.md for the full operating model.
|
||||
#
|
||||
# Handles are GitHub logins without the leading "@". Team values use the
|
||||
# "<org>/<team-slug>" form. Keep every pool at three or more reviewers with
|
||||
# cross-time-zone coverage before turning off dry_run.
|
||||
|
||||
version: 1
|
||||
|
||||
# When true, workflows only log the actions they would take (no reviewer
|
||||
# requests, labels, or comments). Set to false once the teams and pools below
|
||||
# are populated.
|
||||
dry_run: true # TODO: set to false after the reviewer pools are staffed.
|
||||
|
||||
sla:
|
||||
# Business days (Monday–Friday, UTC, excluding holidays) after routing by
|
||||
# which a first review is expected. The date is shown in the review-due label.
|
||||
first_review_business_days: 2
|
||||
# Business days after routing before the PR escalates to the core pool.
|
||||
escalation_business_days: 4
|
||||
# Optional non-working days (YYYY-MM-DD, UTC) skipped by business-day math.
|
||||
holidays: []
|
||||
|
||||
labels:
|
||||
needs_reviewer: needs-reviewer
|
||||
due_prefix: "review-due:"
|
||||
overdue: review-overdue
|
||||
escalated: review-escalated
|
||||
|
||||
# Pool that receives escalations and acts as the last-resort fallback.
|
||||
escalation_pool: core-maintainers
|
||||
|
||||
# Pool used when no intent label matches a route below.
|
||||
default_pool: core-maintainers
|
||||
|
||||
pools:
|
||||
core-maintainers:
|
||||
team: github/awesome-copilot-core-maintainers
|
||||
reviewers: [] # TODO: add at least three core maintainers.
|
||||
backup: [] # TODO: optional; escalation already targets this pool.
|
||||
canvas:
|
||||
team: github/awesome-copilot-canvas-reviewers
|
||||
reviewers: [] # TODO: add at least three canvas reviewers.
|
||||
backup: [] # TODO: add backup reviewers for canvas extensions.
|
||||
plugin:
|
||||
team: github/awesome-copilot-plugin-reviewers
|
||||
reviewers: [] # TODO: add at least three plugin reviewers.
|
||||
backup: [] # TODO: add backup reviewers for plugins.
|
||||
content:
|
||||
team: github/awesome-copilot-content-reviewers
|
||||
reviewers: [] # TODO: add at least three reviewers for skills, agents, and instructions.
|
||||
backup: [] # TODO: add backup content reviewers.
|
||||
|
||||
# Intent labels (applied by .github/workflows/label-pr-intent*.yml) mapped to
|
||||
# pools. Order is priority: the first route whose label is on the PR wins.
|
||||
routes:
|
||||
- label: canvas-extension
|
||||
pool: canvas
|
||||
- label: external-plugin
|
||||
pool: plugin
|
||||
- label: plugin
|
||||
pool: plugin
|
||||
- label: skills
|
||||
pool: content
|
||||
- label: agent
|
||||
pool: content
|
||||
- label: instructions
|
||||
pool: content
|
||||
- label: workflow
|
||||
pool: content
|
||||
- label: hooks
|
||||
pool: content
|
||||
- label: website-update
|
||||
pool: core-maintainers
|
||||
|
||||
# Reviewers temporarily unavailable (vacation, leave). They are never selected.
|
||||
unavailable: []
|
||||
|
||||
# PR authors that are never routed (for example, automation accounts).
|
||||
skip_authors: []
|
||||
|
||||
# PRs carrying any of these labels are not routed or escalated.
|
||||
skip_labels: []
|
||||
@@ -0,0 +1,160 @@
|
||||
name: Review Escalation
|
||||
|
||||
# Enforces the review SLA from .github/review-routing.yml for routed PRs
|
||||
# (those with a `review-due:YYYY-MM-DD` label):
|
||||
# - no review after the due date -> request a backup (or a team), add `review-overdue`
|
||||
# - no review escalation_business_days after routing -> request the
|
||||
# core-maintainer pool, add `review-escalated`
|
||||
# - first human review since routing -> clear the SLA labels
|
||||
# Business days skip weekends and configured holidays, so the schedule only
|
||||
# runs Monday–Friday.
|
||||
#
|
||||
# The read-only `plan` job selects PRs needing action; the `escalate` job then
|
||||
# handles each PR in the `review-routing-pr-<number>` concurrency group shared
|
||||
# with Review Routing. Only trusted default-branch code is executed.
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 14 * * 1-5"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
targets: ${{ steps.plan.outputs.targets }}
|
||||
steps:
|
||||
- name: Checkout default branch
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Select pull requests needing SLA action
|
||||
id: plan
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
|
||||
const routing = await import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, 'eng', 'review-routing.mjs')).href);
|
||||
const config = routing.loadReviewRoutingConfig(path.join(process.env.GITHUB_WORKSPACE, '.github', 'review-routing.yml'));
|
||||
|
||||
const plans = await routing.planEscalationSweep({ github, owner: context.repo.owner, repo: context.repo.repo, config });
|
||||
const actionable = plans.filter((plan) => ['reviewed', 'overdue', 'escalate'].includes(plan.action));
|
||||
|
||||
await core.summary.addRaw(routing.formatResultsSummary(actionable, { title: 'Review escalation plan', dryRun: config.dryRun })).write();
|
||||
const limited = routing.limitTargets(actionable.map((plan) => ({ pr: plan.prNumber })));
|
||||
if (limited.deferred > 0) core.warning(`${limited.deferred} PR(s) exceed the ${routing.MAX_MATRIX_TARGETS}-job matrix limit and are deferred to the next run.`);
|
||||
core.setOutput('targets', JSON.stringify(limited.targets));
|
||||
|
||||
escalate:
|
||||
needs: plan
|
||||
if: needs.plan.outputs.targets != '' && needs.plan.outputs.targets != '[]'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# Sequential so each PR sees reviewer load updated by the previous one.
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
target: ${{ fromJSON(needs.plan.outputs.targets) }}
|
||||
concurrency:
|
||||
group: review-routing-pr-${{ matrix.target.pr }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout default branch
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Apply review SLA
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
env:
|
||||
TARGET_PR: ${{ matrix.target.pr }}
|
||||
with:
|
||||
script: |
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
|
||||
const routing = await import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, 'eng', 'review-routing.mjs')).href);
|
||||
const config = routing.loadReviewRoutingConfig(path.join(process.env.GITHUB_WORKSPACE, '.github', 'review-routing.yml'));
|
||||
|
||||
const prNumber = Number(process.env.TARGET_PR);
|
||||
if (!Number.isInteger(prNumber) || prNumber < 1) throw new Error(`Invalid target PR: ${process.env.TARGET_PR}`);
|
||||
|
||||
const plan = await routing.escalatePullRequest({
|
||||
github,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
config,
|
||||
prNumber,
|
||||
log: core,
|
||||
});
|
||||
|
||||
await core.summary.addRaw(routing.formatResultsSummary([plan], { title: `Review escalation for #${prNumber}`, dryRun: config.dryRun })).write();
|
||||
if (plan.requestError) core.setFailed(`Reviewer request failed for PR #${prNumber}: ${plan.requestError}`);
|
||||
|
||||
cleanup:
|
||||
needs: plan
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: read
|
||||
steps:
|
||||
- name: Checkout default branch
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Delete stale review-due labels
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
|
||||
const routing = await import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, 'eng', 'review-routing.mjs')).href);
|
||||
const config = routing.loadReviewRoutingConfig(path.join(process.env.GITHUB_WORKSPACE, '.github', 'review-routing.yml'));
|
||||
const deleted = await routing.cleanupStaleDueLabels({ github, owner: context.repo.owner, repo: context.repo.repo, config, log: core });
|
||||
core.info(`Stale review-due labels ${config.dryRun ? 'that would be ' : ''}deleted: ${deleted.join(', ') || 'none'}`);
|
||||
@@ -0,0 +1,45 @@
|
||||
name: Review Routing Request
|
||||
|
||||
# Reader half of the needs-reviewer routing flow. Runs with a read-only token
|
||||
# and only records which PR asked for a reviewer; the privileged Review Routing
|
||||
# workflow validates this artifact and performs the request.
|
||||
#
|
||||
# Labels added with GITHUB_TOKEN do not emit `labeled` events, so automation
|
||||
# should dispatch review-routing.yml directly (or rely on its scheduled sweep).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [labeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
capture:
|
||||
runs-on: ubuntu-latest
|
||||
if: github.event.label.name == 'needs-reviewer'
|
||||
steps:
|
||||
- name: Record routing request
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const outDir = path.join(process.env.RUNNER_TEMP, 'review-routing-request');
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(outDir, 'result.json'), `${JSON.stringify({
|
||||
schema_version: 'review-routing-request/v1',
|
||||
event: 'pull_request',
|
||||
pr_number: context.payload.pull_request.number,
|
||||
head_sha: context.payload.pull_request.head.sha,
|
||||
run_id: process.env.GITHUB_RUN_ID,
|
||||
}, null, 2)}\n`);
|
||||
|
||||
- name: Upload routing request artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: review-routing-request
|
||||
path: ${{ runner.temp }}/review-routing-request/result.json
|
||||
if-no-files-found: error
|
||||
retention-days: 3
|
||||
@@ -0,0 +1,197 @@
|
||||
name: Review Routing
|
||||
|
||||
# Requests a reviewer from the pool configured in .github/review-routing.yml
|
||||
# and marks the PR with a `review-due:YYYY-MM-DD` label.
|
||||
#
|
||||
# Triggers:
|
||||
# - workflow_run of "Label PR Intent": route new PRs, and re-route when a new
|
||||
# head changes the target pool (trusted artifact, authoritative intent labels).
|
||||
# - workflow_run of "Review Routing Request": a human added `needs-reviewer`.
|
||||
# - workflow_dispatch with pr_number: automation (e.g. `/request-review`) asks
|
||||
# for routing after adding `needs-reviewer` with GITHUB_TOKEN.
|
||||
# - schedule / workflow_dispatch without pr_number: sweep all open PRs.
|
||||
#
|
||||
# The read-only `plan` job selects target PRs. The `route` job then handles each
|
||||
# PR in the `review-routing-pr-<number>` concurrency group (shared with Review
|
||||
# Escalation) so read/plan/apply never overlaps for the same PR.
|
||||
#
|
||||
# Only trusted code from the default branch is checked out. PR code is never
|
||||
# checked out or executed.
|
||||
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Label PR Intent", "Review Routing Request"]
|
||||
types: [completed]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr_number:
|
||||
description: "Pull request number to route (leave empty to sweep all open PRs)"
|
||||
required: false
|
||||
type: string
|
||||
schedule:
|
||||
- cron: "17 * * * *"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
plan:
|
||||
runs-on: ubuntu-latest
|
||||
if: >-
|
||||
github.event_name != 'workflow_run' ||
|
||||
(github.event.workflow_run.event == 'pull_request' && github.event.workflow_run.conclusion == 'success')
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
pull-requests: read
|
||||
outputs:
|
||||
targets: ${{ steps.plan.outputs.targets }}
|
||||
steps:
|
||||
- name: Checkout default branch
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Download reader artifact
|
||||
id: download
|
||||
if: github.event_name == 'workflow_run'
|
||||
continue-on-error: true
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: ${{ github.event.workflow_run.name == 'Label PR Intent' && 'label-pr-intent-result' || 'review-routing-request' }}
|
||||
path: ${{ runner.temp }}/review-routing-input
|
||||
run-id: ${{ github.event.workflow_run.id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Select pull requests to route
|
||||
id: plan
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
env:
|
||||
PR_NUMBER_INPUT: ${{ inputs.pr_number }}
|
||||
DOWNLOAD_OUTCOME: ${{ steps.download.outcome }}
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
|
||||
const routing = await import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, 'eng', 'review-routing.mjs')).href);
|
||||
const config = routing.loadReviewRoutingConfig(path.join(process.env.GITHUB_WORKSPACE, '.github', 'review-routing.yml'));
|
||||
const { owner, repo } = context.repo;
|
||||
const targets = [];
|
||||
|
||||
if (context.eventName === 'workflow_run') {
|
||||
if (process.env.DOWNLOAD_OUTCOME !== 'success') {
|
||||
core.info('No reader artifact was available; nothing to route.');
|
||||
} else {
|
||||
const artifact = JSON.parse(fs.readFileSync(path.join(process.env.RUNNER_TEMP, 'review-routing-input', 'result.json'), 'utf8'));
|
||||
const resolved = await routing.resolveWorkflowRunArtifact({ github, owner, repo, workflowRun: context.payload.workflow_run, artifact });
|
||||
if (resolved.skip) {
|
||||
core.info(`Skipping: ${resolved.skip}`);
|
||||
} else {
|
||||
targets.push({
|
||||
pr: resolved.prNumber,
|
||||
head_sha: resolved.headSha,
|
||||
intent_labels: resolved.intentLabels === null ? '' : JSON.stringify(resolved.intentLabels),
|
||||
});
|
||||
}
|
||||
}
|
||||
} else if (process.env.PR_NUMBER_INPUT) {
|
||||
const prNumber = Number(process.env.PR_NUMBER_INPUT);
|
||||
if (!Number.isInteger(prNumber) || prNumber < 1) {
|
||||
core.setFailed(`Invalid pr_number input: ${process.env.PR_NUMBER_INPUT}`);
|
||||
return;
|
||||
}
|
||||
targets.push({ pr: prNumber, head_sha: '', intent_labels: '' });
|
||||
} else {
|
||||
const plans = await routing.planRoutingSweep({ github, owner, repo, config });
|
||||
for (const plan of plans.filter((candidate) => candidate.action === 'route')) {
|
||||
targets.push({ pr: plan.prNumber, head_sha: '', intent_labels: '' });
|
||||
}
|
||||
await core.summary
|
||||
.addRaw(routing.formatResultsSummary(plans.filter((plan) => plan.action === 'route'), { title: 'Review routing sweep plan', dryRun: config.dryRun }))
|
||||
.write();
|
||||
}
|
||||
|
||||
const limited = routing.limitTargets(targets);
|
||||
if (limited.deferred > 0) core.warning(`${limited.deferred} PR(s) exceed the ${routing.MAX_MATRIX_TARGETS}-job matrix limit and are deferred to the next sweep.`);
|
||||
core.info(`Routing targets: ${limited.targets.map((target) => `#${target.pr}`).join(', ') || 'none'}`);
|
||||
core.setOutput('targets', JSON.stringify(limited.targets));
|
||||
|
||||
route:
|
||||
needs: plan
|
||||
if: needs.plan.outputs.targets != '' && needs.plan.outputs.targets != '[]'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
strategy:
|
||||
fail-fast: false
|
||||
# Sequential so each PR sees reviewer load updated by the previous one.
|
||||
max-parallel: 1
|
||||
matrix:
|
||||
target: ${{ fromJSON(needs.plan.outputs.targets) }}
|
||||
concurrency:
|
||||
group: review-routing-pr-${{ matrix.target.pr }}
|
||||
cancel-in-progress: false
|
||||
steps:
|
||||
- name: Checkout default branch
|
||||
uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --ignore-scripts
|
||||
|
||||
- name: Route reviewer
|
||||
uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.1.0
|
||||
env:
|
||||
TARGET_PR: ${{ matrix.target.pr }}
|
||||
TARGET_HEAD_SHA: ${{ matrix.target.head_sha }}
|
||||
TARGET_INTENT_LABELS: ${{ matrix.target.intent_labels }}
|
||||
with:
|
||||
script: |
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
|
||||
const routing = await import(pathToFileURL(path.join(process.env.GITHUB_WORKSPACE, 'eng', 'review-routing.mjs')).href);
|
||||
const config = routing.loadReviewRoutingConfig(path.join(process.env.GITHUB_WORKSPACE, '.github', 'review-routing.yml'));
|
||||
|
||||
const prNumber = Number(process.env.TARGET_PR);
|
||||
if (!Number.isInteger(prNumber) || prNumber < 1) throw new Error(`Invalid target PR: ${process.env.TARGET_PR}`);
|
||||
const headSha = process.env.TARGET_HEAD_SHA || null;
|
||||
if (headSha && !/^[0-9a-f]{40}$/i.test(headSha)) throw new Error('Invalid target head SHA');
|
||||
const intentLabels = process.env.TARGET_INTENT_LABELS
|
||||
? routing.validateIntentLabels(JSON.parse(process.env.TARGET_INTENT_LABELS))
|
||||
: null;
|
||||
|
||||
const plan = await routing.routePullRequest({
|
||||
github,
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
config,
|
||||
prNumber,
|
||||
intentLabels,
|
||||
expectedHeadSha: headSha,
|
||||
log: core,
|
||||
});
|
||||
|
||||
await core.summary.addRaw(routing.formatResultsSummary([plan], { title: `Review routing for #${prNumber}`, dryRun: config.dryRun })).write();
|
||||
if (plan.requestError) core.setFailed(`Reviewer request failed for PR #${prNumber}: ${plan.requestError}`);
|
||||
@@ -89,6 +89,19 @@ jobs:
|
||||
'awaiting-approval': {
|
||||
color: 'FBCA04',
|
||||
description: 'External plugin awaiting maintainer approval'
|
||||
},
|
||||
// Review routing labels (see .github/review-routing.yml)
|
||||
'needs-reviewer': {
|
||||
color: 'D4C5F9',
|
||||
description: 'Request automated reviewer routing for this PR'
|
||||
},
|
||||
'review-overdue': {
|
||||
color: 'E99695',
|
||||
description: 'First review is past its due date; a backup reviewer was requested'
|
||||
},
|
||||
'review-escalated': {
|
||||
color: 'B60205',
|
||||
description: 'Review escalated to the core-maintainer pool'
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
+22
-1
@@ -1,5 +1,26 @@
|
||||
# Ownership model: see docs/maintainers/review-routing.md
|
||||
#
|
||||
# - Teams own paths; no individual is the default owner. Do not add individual
|
||||
# users to the `*` rule or to the team block below.
|
||||
# - The last matching rule wins, so per-resource owners appended at the end of
|
||||
# this file (for example by the `#codeowner` automation) override the team
|
||||
# defaults for their specific paths.
|
||||
# - Reviewer pools used for automatic review requests live in
|
||||
# .github/review-routing.yml.
|
||||
|
||||
# Default owner for everything
|
||||
* @aaronpowell
|
||||
* @github/awesome-copilot-core-maintainers
|
||||
|
||||
# Domain reviewer teams
|
||||
/extensions/ @github/awesome-copilot-canvas-reviewers
|
||||
/plugins/ @github/awesome-copilot-plugin-reviewers
|
||||
/agents/ @github/awesome-copilot-content-reviewers
|
||||
/instructions/ @github/awesome-copilot-content-reviewers
|
||||
/skills/ @github/awesome-copilot-content-reviewers
|
||||
/hooks/ @github/awesome-copilot-content-reviewers
|
||||
/workflows/ @github/awesome-copilot-content-reviewers
|
||||
/.github/ @github/awesome-copilot-core-maintainers
|
||||
/eng/ @github/awesome-copilot-core-maintainers
|
||||
|
||||
# Adding Dan Wahlin as the owner for the CLI for Beginners content and images
|
||||
/website/src/content/docs/learning-hub/cli-for-beginners/ @DanWahlin
|
||||
|
||||
@@ -437,6 +437,9 @@ Create a daily summary of open issues for the team.
|
||||
> [!IMPORTANT]
|
||||
> All pull requests should target the **`main`** branch, not `staged`.
|
||||
|
||||
> [!TIP]
|
||||
> You don't need to ping a maintainer. Once automatic review routing is enabled, pull requests are routed to a reviewer pool based on what they change, and a `review-due:YYYY-MM-DD` label shows when a first review is expected. Until then, routing runs in dry-run mode and maintainers assign reviewers. See [Review routing, ownership, and SLAs](docs/maintainers/review-routing.md).
|
||||
|
||||
> [!NOTE]
|
||||
> Branch migration tracking for source/published branch changes lives in [Issue #1368](https://github.com/github/awesome-copilot/issues/1368). Phase 2 migration work stays gated until maintainers confirm external tooling rollout is complete.
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
# Review routing, ownership, and SLAs
|
||||
|
||||
This page describes how pull requests in Awesome Copilot are assigned to reviewers, how quickly a first review is expected, and what happens when that target is missed. It covers Phase 1 of [github/awesome-copilot#4184](https://github.com/github/awesome-copilot/issues/4184).
|
||||
|
||||
## Ownership (`CODEOWNERS`)
|
||||
|
||||
The root [`CODEOWNERS`](../../CODEOWNERS) file assigns every path to a team, not to an individual.
|
||||
|
||||
| Path | Owning team |
|
||||
|---|---|
|
||||
| `*` (default) | `@github/awesome-copilot-core-maintainers` |
|
||||
| `/extensions/` | `@github/awesome-copilot-canvas-reviewers` |
|
||||
| `/plugins/` | `@github/awesome-copilot-plugin-reviewers` |
|
||||
| `/agents/`, `/instructions/`, `/skills/`, `/hooks/`, `/workflows/` | `@github/awesome-copilot-content-reviewers` |
|
||||
| `/.github/` (including GitHub Actions workflows), `/eng/` | `@github/awesome-copilot-core-maintainers` |
|
||||
|
||||
GitHub applies the **last** matching rule, so the per-resource entries at the bottom of the file override the team defaults for their paths. Those entries are added through the `#codeowner` command (`.github/workflows/codeowner-update.md`), which only appends a block at the end of the file. The existing per-resource entries are kept as they are for now; removing them is planned as a separate cleanup. When you edit `CODEOWNERS` by hand:
|
||||
|
||||
- Keep the `*` rule and the domain team block at the top of the file.
|
||||
- Never add an individual user to the `*` rule.
|
||||
- Append new per-resource owners at the end of the file.
|
||||
|
||||
## Reviewer pools (`.github/review-routing.yml`)
|
||||
|
||||
[`.github/review-routing.yml`](../../.github/review-routing.yml) is the single source of truth for automatic review requests. Workflows never hard-code reviewer handles.
|
||||
|
||||
| Key | Purpose |
|
||||
|---|---|
|
||||
| `dry_run` | When `true`, workflows log their planned actions to the job summary but make no changes. |
|
||||
| `sla.first_review_business_days` | Business days after routing until a first review is due (default `2`). |
|
||||
| `sla.escalation_business_days` | Business days after routing until escalation to the core pool (default `4`). |
|
||||
| `sla.holidays` | Optional list of `YYYY-MM-DD` UTC dates excluded from business-day math. |
|
||||
| `labels` | Names of the labels the routing workflows manage. |
|
||||
| `pools.<name>.team` | Team in `<org>/<team-slug>` form. It is requested when no individual is available, and the escalation pool's team is always requested on escalation. |
|
||||
| `pools.<name>.reviewers` | Individual GitHub logins that routing chooses from. |
|
||||
| `pools.<name>.backup` | Individuals requested when a review is overdue. |
|
||||
| `routes` | Ordered list mapping intent labels to pools. The first route whose label is on the PR wins. |
|
||||
| `default_pool` | Pool used when no route matches, for example a docs-only PR. |
|
||||
| `escalation_pool` | Pool that receives escalations and serves as the last-resort fallback. |
|
||||
| `unavailable` | Logins that are temporarily away. They are never selected. |
|
||||
| `skip_authors`, `skip_labels` | PRs by these authors, or with these labels, are not routed or escalated. |
|
||||
|
||||
Current routes, in priority order:
|
||||
|
||||
| Intent label | Pool |
|
||||
|---|---|
|
||||
| `canvas-extension` | `canvas` |
|
||||
| `external-plugin`, `plugin` | `plugin` |
|
||||
| `skills`, `agent`, `instructions`, `workflow`, `hooks` | `content` |
|
||||
| `website-update` | `core-maintainers` |
|
||||
|
||||
Intent labels are applied by `.github/workflows/label-pr-intent.yml` and `label-pr-intent-writer.yml`.
|
||||
|
||||
### Updating the pools
|
||||
|
||||
1. Edit `.github/review-routing.yml`. Keep at least three reviewers per pool, with coverage across time zones.
|
||||
2. Validate the file with `node eng/review-routing.mjs validate`, then run the unit tests with `node --test eng/review-routing.test.mjs`.
|
||||
3. Open a PR. The file is owned by the core-maintainer team.
|
||||
|
||||
To take a reviewer out of rotation temporarily, add their login to `unavailable` instead of removing them from every pool.
|
||||
|
||||
## Routing
|
||||
|
||||
The **Review Routing** workflow (`.github/workflows/review-routing.yml`) runs when:
|
||||
|
||||
- **Label PR Intent** finishes for a PR. Routing uses that run's intent labels even if they have not been applied yet.
|
||||
- A person adds the `needs-reviewer` label. The read-only **Review Routing Request** workflow records the request, and Review Routing acts on it.
|
||||
- It is dispatched with a `pr_number` input. Automation such as the `/request-review` command uses this path because labels added with `GITHUB_TOKEN` do not trigger other workflows.
|
||||
- The hourly schedule runs, or it is dispatched without a `pr_number`. This sweep evaluates every open, non-draft PR. It routes PRs that are unrouted, that have the `needs-reviewer` label, or whose pool is not covered. Each routed PR is handled in its own job.
|
||||
|
||||
For each eligible PR, routing does the following:
|
||||
|
||||
1. Picks the pool using `routes`, falling back to `default_pool`. For runs triggered by **Label PR Intent**, that run's intent labels replace the PR's current intent labels, so stale labels can't pick the wrong pool.
|
||||
2. Checks whether the pool is already **covered**. A pool is covered when one of its reviewers or backups, or a core maintainer, is already requested or has already reviewed the PR. If the pool isn't covered, routing requests one individual from the pool, excluding the PR author, reviewers already requested, bots, and anyone listed in `unavailable`. A pending request for someone in `unavailable` doesn't count as coverage, but a review they already submitted does. It prefers the reviewer with the **fewest open review requests** across all open PRs. Ties rotate by PR number.
|
||||
3. If the pool has no eligible individual, routing tries the pool's `backup` list, then the escalation pool's reviewers, and finally requests the pool team.
|
||||
4. Adds a `review-due:YYYY-MM-DD` label. The date is the routing day plus `first_review_business_days`, in UTC. The time the label was added marks the start of the SLA cycle.
|
||||
5. If `needs-reviewer` triggered the run, routing always requests a new reviewer: someone who is not already requested and has not already reviewed the PR. It removes `needs-reviewer`, `review-overdue`, and `review-escalated`, then removes and re-adds the due label to restart the SLA. If no new reviewer or team can be requested, routing changes nothing, so `needs-reviewer` and the current due date stay, and the hourly sweep tries again.
|
||||
|
||||
A PR that is already routed is checked again whenever a new commit changes its intent labels. If the new target pool isn't covered, for example because a skills PR now also touches a workflow, routing requests a reviewer from that pool. The existing due date stays the same.
|
||||
|
||||
Routing skips drafts, closed PRs, and unrouted PRs that already have a human review, unless `needs-reviewer` is present. Draft PRs are routed when they are marked ready for review.
|
||||
|
||||
### Failures and concurrency
|
||||
|
||||
- Routing requests reviewers before it changes any labels. If GitHub rejects the request, for example because a team is missing or the token lacks access, routing leaves the labels alone and fails the job. Nothing is marked as routed, so the next run or sweep tries again.
|
||||
- Each workflow first runs a read-only `plan` job that picks the target PRs. It then handles each PR in a job that uses the `review-routing-pr-<number>` concurrency group. Review Routing and Review Escalation share that group, so their read, plan, and apply steps never overlap for the same PR. Within one run, the per-PR jobs run one at a time, and each job re-reads reviewer load before it picks someone.
|
||||
- Load balancing across separate runs is best-effort. Two runs that route different PRs at the same moment can read the same load and pick the same reviewer. A repository-wide lock would avoid this, but GitHub cancels pending runs that wait on the same concurrency group, so routing requests could be lost. The next routing run sees the updated load.
|
||||
- A GitHub Actions matrix can hold at most 256 jobs. If a sweep finds more PRs than that, it handles the first 256, logs a warning, and leaves the rest for the next scheduled run. Planning is idempotent, so no PR is skipped permanently.
|
||||
|
||||
## SLA and escalation
|
||||
|
||||
The **Review Escalation** workflow (`.github/workflows/review-escalation.yml`) runs at 14:00 UTC on weekdays and can also be dispatched manually. It checks every routed PR, meaning every PR with a `review-due:` label.
|
||||
|
||||
| Condition | Action |
|
||||
|---|---|
|
||||
| A human review arrives after the due label was added | Removes `review-due:*`, `review-overdue`, and `review-escalated` |
|
||||
| No review by the end of the due date | Requests a backup from the pool's `backup` list, then from the escalation pool. If no individual is available, it requests the pool team and then the escalation team, unless they are already requested. Adds `review-overdue` and posts a comment that says who, if anyone, was requested. |
|
||||
| No review `escalation_business_days` after routing | Requests the escalation pool team and one core maintainer; adds `review-escalated`; posts a comment |
|
||||
|
||||
Business days are Monday through Friday in UTC, minus `sla.holidays`. A PR routed on Monday is due Wednesday, becomes overdue Thursday, and escalates Friday, four business days after routing. A PR routed on Wednesday is due Friday, becomes overdue Monday, and escalates Tuesday. Reviews from the author or from bots, including Copilot code review, don't count as a first review. Only reviews submitted after the current due label was added count. Each action happens once per SLA cycle because the labels record the state. If a reviewer request fails, the labels and comment are skipped so the next run retries.
|
||||
|
||||
The workflow also deletes `review-due:*` labels whose dates are more than 14 days in the past and that are no longer on any open PR.
|
||||
|
||||
## Labels
|
||||
|
||||
| Label | Meaning |
|
||||
|---|---|
|
||||
| `needs-reviewer` | Asks routing to (re)assign a reviewer. Routing removes it after it runs. |
|
||||
| `review-due:YYYY-MM-DD` | Date by which the first review is expected. |
|
||||
| `review-overdue` | The first review is late, and a backup reviewer was requested. |
|
||||
| `review-escalated` | The review was escalated to the core-maintainer pool. |
|
||||
|
||||
The **Setup Repository Labels** workflow creates `needs-reviewer`, `review-overdue`, and `review-escalated`. Routing creates each `review-due:` label when it first uses it.
|
||||
|
||||
## Security model
|
||||
|
||||
- Workflows triggered by pull requests (**Label PR Intent** and **Review Routing Request**) run with read-only permissions and only upload a small JSON artifact.
|
||||
- **Review Routing** runs from `workflow_run`, `workflow_dispatch`, or `schedule`. It checks out only the default branch, validates the artifact against the triggering run, and never checks out or runs PR code. This is the same approach as `label-pr-intent-writer.yml`.
|
||||
- In both Review Routing and Review Escalation, the `plan` job has read-only permissions. Only the per-PR jobs can write to issues and pull requests.
|
||||
- All reviewer logic lives in `eng/review-routing.mjs` and is covered by `eng/review-routing.test.mjs`.
|
||||
|
||||
## Enabling routing
|
||||
|
||||
The routing workflows ship with `dry_run: true` and empty reviewer lists. To turn them on:
|
||||
|
||||
1. Create every team referenced in `CODEOWNERS` and in the `pools` of `.github/review-routing.yml`, each with at least three members, and give each team access to the repository.
|
||||
2. Add the team members to the matching `reviewers` and `backup` lists in `.github/review-routing.yml`.
|
||||
3. Run **Setup Repository Labels**.
|
||||
4. Set `dry_run: false`.
|
||||
5. Once the teams and coverage are in place, enable **Require review from Code Owners** in the `main` ruleset.
|
||||
@@ -24,6 +24,14 @@ npm run plugin:generate-marketplace
|
||||
### `generate-website-data.mjs`
|
||||
Generates JSON data files for the website from repository content.
|
||||
|
||||
### `review-routing.mjs`
|
||||
Reviewer routing and review SLA escalation used by the Review Routing and Review Escalation workflows. Reviewer pools live in `.github/review-routing.yml`; see [docs/maintainers/review-routing.md](../docs/maintainers/review-routing.md).
|
||||
|
||||
```bash
|
||||
node eng/review-routing.mjs validate # validate .github/review-routing.yml
|
||||
node --test eng/review-routing.test.mjs # unit tests
|
||||
```
|
||||
|
||||
## Contributor Tools
|
||||
|
||||
- `contributor-report.mjs` — generates a markdown report of merged PRs for missing contributors (includes shared helpers).
|
||||
|
||||
@@ -0,0 +1,924 @@
|
||||
// Reviewer routing and SLA escalation for pull requests.
|
||||
//
|
||||
// Configuration lives in .github/review-routing.yml. The pure planning
|
||||
// functions (planRouting, planEscalation, business-day helpers) are unit
|
||||
// tested in review-routing.test.mjs; the async runners wrap them with the
|
||||
// GitHub REST calls made by the Review Routing and Review Escalation workflows.
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as yaml from "js-yaml";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
export const REVIEW_ROUTING_CONFIG_PATH = path.join(__dirname, "..", ".github", "review-routing.yml");
|
||||
|
||||
export const INTENT_LABELS = Object.freeze([
|
||||
"agent",
|
||||
"canvas-extension",
|
||||
"external-plugin",
|
||||
"hooks",
|
||||
"instructions",
|
||||
"new-submission",
|
||||
"plugin",
|
||||
"skills",
|
||||
"website-update",
|
||||
"workflow",
|
||||
]);
|
||||
|
||||
export const INTENT_ARTIFACT_SCHEMA = "label-pr-intent-result/v1";
|
||||
export const ROUTING_REQUEST_ARTIFACT_SCHEMA = "review-routing-request/v1";
|
||||
|
||||
export const COMMENT_MARKERS = Object.freeze({
|
||||
overdue: "<!-- review-routing:overdue -->",
|
||||
escalated: "<!-- review-routing:escalated -->",
|
||||
});
|
||||
|
||||
const LOGIN_PATTERN = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,38})$/;
|
||||
const TEAM_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
||||
const DATE_KEY_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||
const DUE_LABEL_RETENTION_DAYS = 14;
|
||||
|
||||
/** GitHub Actions allows at most 256 jobs per matrix. */
|
||||
export const MAX_MATRIX_TARGETS = 256;
|
||||
|
||||
/**
|
||||
* Cap sweep targets to what one matrix can run. The remainder is picked up by
|
||||
* the next scheduled run (planning is idempotent), so nothing is lost.
|
||||
*/
|
||||
export function limitTargets(targets, max = MAX_MATRIX_TARGETS) {
|
||||
return { targets: targets.slice(0, max), deferred: Math.max(0, targets.length - max) };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isPlainObject(value) {
|
||||
return value !== null && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function validateLoginList(value, where, errors) {
|
||||
if (value === undefined || value === null) return [];
|
||||
if (!Array.isArray(value)) {
|
||||
errors.push(`${where} must be a list of GitHub logins`);
|
||||
return [];
|
||||
}
|
||||
const logins = [];
|
||||
for (const entry of value) {
|
||||
if (typeof entry !== "string" || !LOGIN_PATTERN.test(entry)) {
|
||||
errors.push(`${where} contains an invalid GitHub login: ${JSON.stringify(entry)}`);
|
||||
continue;
|
||||
}
|
||||
logins.push(entry);
|
||||
}
|
||||
return logins;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and normalize a parsed review-routing.yml document.
|
||||
* @returns {{ config: object|null, errors: string[] }}
|
||||
*/
|
||||
export function normalizeReviewRoutingConfig(raw) {
|
||||
const errors = [];
|
||||
if (!isPlainObject(raw)) {
|
||||
return { config: null, errors: ["configuration must be a YAML mapping"] };
|
||||
}
|
||||
if (raw.version !== 1) errors.push("version must be 1");
|
||||
|
||||
const sla = isPlainObject(raw.sla) ? raw.sla : {};
|
||||
if (!isPlainObject(raw.sla)) errors.push("sla must be a mapping");
|
||||
const firstReviewBusinessDays = sla.first_review_business_days;
|
||||
const escalationBusinessDays = sla.escalation_business_days;
|
||||
if (!Number.isInteger(firstReviewBusinessDays) || firstReviewBusinessDays < 1) {
|
||||
errors.push("sla.first_review_business_days must be a positive integer");
|
||||
}
|
||||
if (!Number.isInteger(escalationBusinessDays) || escalationBusinessDays <= firstReviewBusinessDays) {
|
||||
errors.push("sla.escalation_business_days must be an integer greater than sla.first_review_business_days");
|
||||
}
|
||||
const holidays = [];
|
||||
for (const holiday of sla.holidays ?? []) {
|
||||
const key = holiday instanceof Date ? holiday.toISOString().slice(0, 10) : holiday;
|
||||
if (typeof key !== "string" || !DATE_KEY_PATTERN.test(key)) {
|
||||
errors.push(`sla.holidays contains an invalid date: ${JSON.stringify(holiday)}`);
|
||||
} else {
|
||||
holidays.push(key);
|
||||
}
|
||||
}
|
||||
|
||||
const rawLabels = isPlainObject(raw.labels) ? raw.labels : {};
|
||||
const labels = {
|
||||
needsReviewer: rawLabels.needs_reviewer,
|
||||
duePrefix: rawLabels.due_prefix,
|
||||
overdue: rawLabels.overdue,
|
||||
escalated: rawLabels.escalated,
|
||||
};
|
||||
for (const [key, value] of Object.entries(labels)) {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
errors.push(`labels.${key} must be a non-empty string`);
|
||||
}
|
||||
}
|
||||
|
||||
const pools = {};
|
||||
if (!isPlainObject(raw.pools) || Object.keys(raw.pools).length === 0) {
|
||||
errors.push("pools must be a non-empty mapping");
|
||||
} else {
|
||||
for (const [name, pool] of Object.entries(raw.pools)) {
|
||||
if (!isPlainObject(pool)) {
|
||||
errors.push(`pools.${name} must be a mapping`);
|
||||
continue;
|
||||
}
|
||||
if (pool.team !== undefined && pool.team !== null && (typeof pool.team !== "string" || !TEAM_PATTERN.test(pool.team))) {
|
||||
errors.push(`pools.${name}.team must use the "<org>/<team-slug>" form`);
|
||||
}
|
||||
pools[name] = {
|
||||
name,
|
||||
team: pool.team ?? null,
|
||||
reviewers: validateLoginList(pool.reviewers, `pools.${name}.reviewers`, errors),
|
||||
backup: validateLoginList(pool.backup, `pools.${name}.backup`, errors),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const escalationPool = raw.escalation_pool;
|
||||
const defaultPool = raw.default_pool;
|
||||
if (!pools[escalationPool]) errors.push("escalation_pool must name a configured pool");
|
||||
if (!pools[defaultPool]) errors.push("default_pool must name a configured pool");
|
||||
|
||||
const routes = [];
|
||||
if (!Array.isArray(raw.routes)) {
|
||||
errors.push("routes must be a list");
|
||||
} else {
|
||||
raw.routes.forEach((route, index) => {
|
||||
if (!isPlainObject(route) || typeof route.label !== "string" || route.label.trim() === "") {
|
||||
errors.push(`routes[${index}] must have a label`);
|
||||
return;
|
||||
}
|
||||
if (!pools[route.pool]) {
|
||||
errors.push(`routes[${index}] (${route.label}) references unknown pool ${JSON.stringify(route.pool)}`);
|
||||
return;
|
||||
}
|
||||
routes.push({ label: route.label, pool: route.pool });
|
||||
});
|
||||
}
|
||||
|
||||
const skipLabels = Array.isArray(raw.skip_labels) ? raw.skip_labels.filter((label) => typeof label === "string") : [];
|
||||
if (raw.skip_labels !== undefined && raw.skip_labels !== null && !Array.isArray(raw.skip_labels)) {
|
||||
errors.push("skip_labels must be a list");
|
||||
}
|
||||
|
||||
if (typeof raw.dry_run !== "boolean") {
|
||||
errors.push("dry_run must be true or false");
|
||||
}
|
||||
|
||||
const config = {
|
||||
version: raw.version,
|
||||
dryRun: raw.dry_run === true,
|
||||
sla: { firstReviewBusinessDays, escalationBusinessDays, holidays },
|
||||
labels,
|
||||
pools,
|
||||
routes,
|
||||
escalationPool,
|
||||
defaultPool,
|
||||
unavailable: validateLoginList(raw.unavailable, "unavailable", errors),
|
||||
skipAuthors: Array.isArray(raw.skip_authors) ? raw.skip_authors.filter((login) => typeof login === "string") : [],
|
||||
skipLabels,
|
||||
};
|
||||
|
||||
return { config: errors.length === 0 ? config : null, errors };
|
||||
}
|
||||
|
||||
export function loadReviewRoutingConfig(filePath = REVIEW_ROUTING_CONFIG_PATH) {
|
||||
const raw = yaml.load(fs.readFileSync(filePath, "utf8"));
|
||||
const { config, errors } = normalizeReviewRoutingConfig(raw);
|
||||
if (!config) {
|
||||
throw new Error(`Invalid review routing configuration (${filePath}):\n- ${errors.join("\n- ")}`);
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Business-day math (UTC dates as YYYY-MM-DD keys)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function toDateKey(value) {
|
||||
if (typeof value === "string" && DATE_KEY_PATTERN.test(value)) return value;
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) throw new Error(`Invalid date: ${value}`);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function shiftDateKey(key, days) {
|
||||
const date = new Date(`${key}T00:00:00.000Z`);
|
||||
date.setUTCDate(date.getUTCDate() + days);
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export function isBusinessDay(value, holidays = []) {
|
||||
const key = toDateKey(value);
|
||||
const weekday = new Date(`${key}T00:00:00.000Z`).getUTCDay();
|
||||
return weekday !== 0 && weekday !== 6 && !holidays.includes(key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move `days` business days from `start` (negative values move backwards).
|
||||
* Weekends and configured holidays are skipped. Returns a YYYY-MM-DD key.
|
||||
*/
|
||||
export function addBusinessDays(start, days, holidays = []) {
|
||||
let key = toDateKey(start);
|
||||
const step = days >= 0 ? 1 : -1;
|
||||
let remaining = Math.abs(days);
|
||||
while (remaining > 0) {
|
||||
key = shiftDateKey(key, step);
|
||||
if (isBusinessDay(key, holidays)) remaining -= 1;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
/**
|
||||
* SLA milestones for a PR whose review-due label is `dueDate`.
|
||||
* - routedOn: business day the PR was routed (dueDate - first_review_business_days)
|
||||
* - dueDate: last business day for a first review
|
||||
* - overdueOn: first business day after dueDate (backup reviewer requested)
|
||||
* - escalateOn: routedOn + escalation_business_days (core pool requested)
|
||||
*/
|
||||
export function slaMilestones(dueDate, config) {
|
||||
const { firstReviewBusinessDays, escalationBusinessDays, holidays } = config.sla;
|
||||
const routedOn = addBusinessDays(dueDate, -firstReviewBusinessDays, holidays);
|
||||
return {
|
||||
routedOn,
|
||||
dueDate,
|
||||
overdueOn: addBusinessDays(dueDate, 1, holidays),
|
||||
escalateOn: addBusinessDays(routedOn, escalationBusinessDays, holidays),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Label, reviewer, and review helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const lower = (value) => String(value ?? "").toLowerCase();
|
||||
|
||||
export function isBotLogin(login) {
|
||||
return /\[bot\]$/i.test(String(login ?? ""));
|
||||
}
|
||||
|
||||
export function labelNames(labels = []) {
|
||||
return labels.map((label) => (typeof label === "string" ? label : label?.name)).filter(Boolean);
|
||||
}
|
||||
|
||||
export function dueLabelName(dateKey, config) {
|
||||
return `${config.labels.duePrefix}${dateKey}`;
|
||||
}
|
||||
|
||||
export function parseDueLabels(names, config) {
|
||||
const prefix = config.labels.duePrefix;
|
||||
return [...names]
|
||||
.filter((name) => name.startsWith(prefix))
|
||||
.map((name) => ({ name, date: name.slice(prefix.length) }))
|
||||
.filter(({ date }) => DATE_KEY_PATTERN.test(date))
|
||||
.sort((left, right) => left.date.localeCompare(right.date));
|
||||
}
|
||||
|
||||
/**
|
||||
* Labels used for routing decisions. When `intentLabels` is an array (from a
|
||||
* Label PR Intent artifact) it is authoritative: current intent labels are
|
||||
* replaced by it, even when it is empty. `null` means "use current labels".
|
||||
*/
|
||||
export function effectiveLabelNames(currentLabels, intentLabels = null) {
|
||||
const current = labelNames(currentLabels);
|
||||
if (intentLabels === null || intentLabels === undefined) return new Set(current);
|
||||
return new Set([...current.filter((name) => !INTENT_LABELS.includes(name)), ...intentLabels]);
|
||||
}
|
||||
|
||||
export function selectPool(names, config) {
|
||||
const set = new Set(names);
|
||||
const route = config.routes.find((candidate) => set.has(candidate.label));
|
||||
return route ? route.pool : config.defaultPool;
|
||||
}
|
||||
|
||||
export function teamSlug(team) {
|
||||
return team ? team.split("/").pop() : null;
|
||||
}
|
||||
|
||||
function isTeamRequested(pr, slug) {
|
||||
return Boolean(slug) && (pr.requested_teams ?? []).some((team) => lower(team?.slug) === lower(slug));
|
||||
}
|
||||
|
||||
/** Count open review requests per reviewer (lower-cased login) across open PRs. */
|
||||
export function computeReviewLoad(openPulls = []) {
|
||||
const load = new Map();
|
||||
for (const pull of openPulls) {
|
||||
for (const reviewer of pull.requested_reviewers ?? []) {
|
||||
const key = lower(reviewer?.login);
|
||||
if (key) load.set(key, (load.get(key) ?? 0) + 1);
|
||||
}
|
||||
}
|
||||
return load;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the eligible candidate with the fewest open review requests.
|
||||
* Ties rotate by `seed` (the PR number) so assignments spread across the pool.
|
||||
*/
|
||||
export function pickReviewer(candidates, { exclude = new Set(), load = new Map(), seed = 0 } = {}) {
|
||||
const seen = new Set();
|
||||
const eligible = [];
|
||||
for (const login of candidates) {
|
||||
const key = lower(login);
|
||||
if (!key || seen.has(key) || exclude.has(key) || isBotLogin(login)) continue;
|
||||
seen.add(key);
|
||||
eligible.push(login);
|
||||
}
|
||||
if (eligible.length === 0) return null;
|
||||
const minLoad = Math.min(...eligible.map((login) => load.get(lower(login)) ?? 0));
|
||||
const tied = eligible.filter((login) => (load.get(lower(login)) ?? 0) === minLoad);
|
||||
return tied[Math.abs(Number(seed) || 0) % tied.length];
|
||||
}
|
||||
|
||||
function isAfterBoundary(submittedAt, since) {
|
||||
if (!since) return true;
|
||||
if (!submittedAt) return false;
|
||||
if (DATE_KEY_PATTERN.test(since)) return toDateKey(submittedAt) >= since;
|
||||
return Date.parse(submittedAt) >= Date.parse(since);
|
||||
}
|
||||
|
||||
/**
|
||||
* True when someone other than the author (and not a bot) submitted a review
|
||||
* at or after `since` (an ISO timestamp, or a YYYY-MM-DD date key).
|
||||
*/
|
||||
export function hasHumanReview(reviews = [], author, since = null) {
|
||||
return reviews.some((review) => {
|
||||
const login = review?.user?.login;
|
||||
if (!login || isBotLogin(login) || review.user.type === "Bot") return false;
|
||||
if (lower(login) === lower(author)) return false;
|
||||
if (review.state === "PENDING") return false;
|
||||
return isAfterBoundary(review.submitted_at, since);
|
||||
});
|
||||
}
|
||||
|
||||
/** Logins (other than the author and bots) who submitted a non-pending review. */
|
||||
function submittedReviewers(reviews, author) {
|
||||
return reviews
|
||||
.filter((review) => review?.state !== "PENDING")
|
||||
.map((review) => review?.user?.login)
|
||||
.filter((login) => login && !isBotLogin(login) && lower(login) !== lower(author));
|
||||
}
|
||||
|
||||
/**
|
||||
* Individuals who can satisfy a pool: its reviewers and backups, plus the
|
||||
* escalation (core) pool, who may review anything. A pending request for an
|
||||
* `unavailable` reviewer does not count; a review they already submitted does.
|
||||
*/
|
||||
export function coveringReviewers(pr, reviews, poolName, config) {
|
||||
const pool = config.pools[poolName];
|
||||
const escalation = config.pools[config.escalationPool];
|
||||
const cover = new Set([...pool.reviewers, ...pool.backup, ...escalation.reviewers].map(lower));
|
||||
const unavailable = new Set(config.unavailable.map(lower));
|
||||
const author = lower(pr.user?.login);
|
||||
const logins = [
|
||||
...(pr.requested_reviewers ?? []).map((reviewer) => reviewer?.login).filter((login) => !unavailable.has(lower(login))),
|
||||
...submittedReviewers(reviews, author),
|
||||
];
|
||||
const result = [];
|
||||
for (const login of logins) {
|
||||
const key = lower(login);
|
||||
if (key && key !== author && cover.has(key) && !result.some((existing) => lower(existing) === key)) result.push(login);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function skipped(pr, reason, extra = {}) {
|
||||
return { prNumber: pr?.number, action: "skip", reason, ...extra };
|
||||
}
|
||||
|
||||
function exclusionSet(pr, config) {
|
||||
const requested = (pr.requested_reviewers ?? []).map((reviewer) => reviewer?.login).filter(Boolean);
|
||||
return new Set([pr.user?.login, ...requested, ...config.unavailable].filter(Boolean).map(lower));
|
||||
}
|
||||
|
||||
function commonSkip(pr, names, config) {
|
||||
if (!pr || pr.state !== "open") return skipped(pr, "not-open");
|
||||
if (pr.draft) return skipped(pr, "draft");
|
||||
const author = lower(pr.user?.login);
|
||||
if (config.skipAuthors.some((login) => lower(login) === author)) return skipped(pr, "skipped-author");
|
||||
const skipLabel = config.skipLabels.find((label) => names.has(label));
|
||||
if (skipLabel) return skipped(pr, "skipped-label", { label: skipLabel });
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Choose who to request for a pool: pool reviewers, then pool backups, then
|
||||
* the escalation pool, then the pool team (unless already requested).
|
||||
*/
|
||||
function pickPoolRequest(pr, poolName, config, load, alsoExclude = []) {
|
||||
const pool = config.pools[poolName];
|
||||
const escalation = config.pools[config.escalationPool];
|
||||
const exclude = exclusionSet(pr, config);
|
||||
for (const login of alsoExclude) exclude.add(lower(login));
|
||||
const tiers = [
|
||||
["pool", pool.reviewers],
|
||||
["backup", pool.backup],
|
||||
["escalation-pool", escalation.reviewers],
|
||||
];
|
||||
for (const [source, candidates] of tiers) {
|
||||
const login = pickReviewer(candidates, { exclude, load, seed: pr.number });
|
||||
if (login) return { reviewers: [login], teamReviewers: [], source };
|
||||
}
|
||||
const slug = teamSlug(pool.team);
|
||||
if (slug && !isTeamRequested(pr, slug)) return { reviewers: [], teamReviewers: [slug], source: "team" };
|
||||
return { reviewers: [], teamReviewers: [], source: slug ? "existing-team-request" : "none" };
|
||||
}
|
||||
|
||||
const hasRequest = (plan) => plan.reviewers.length > 0 || plan.teamReviewers.length > 0;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Planning
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Decide how to route a PR. Pure: performs no I/O.
|
||||
* @param {object} args
|
||||
* @param {object} args.pr Pull request (REST shape: number, state, draft, user, labels, requested_reviewers, requested_teams)
|
||||
* @param {object[]} args.reviews Reviews on the PR
|
||||
* @param {Map} args.load Open review requests per reviewer (see computeReviewLoad)
|
||||
* @param {string[]|null} args.intentLabels Authoritative intent labels from a Label PR Intent artifact, or null
|
||||
*/
|
||||
export function planRouting({ pr, reviews = [], load = new Map(), config, now = new Date(), intentLabels = null }) {
|
||||
const names = effectiveLabelNames(pr?.labels, intentLabels);
|
||||
const skip = commonSkip(pr, names, config);
|
||||
if (skip) return skip;
|
||||
|
||||
const author = pr.user?.login ?? "";
|
||||
const reviewRequested = names.has(config.labels.needsReviewer);
|
||||
const dueLabels = parseDueLabels(names, config);
|
||||
const routed = dueLabels.length > 0;
|
||||
const poolName = selectPool(names, config);
|
||||
|
||||
if (!reviewRequested && !routed && hasHumanReview(reviews, author)) return skipped(pr, "already-reviewed", { pool: poolName });
|
||||
|
||||
// needs-reviewer always asks for a fresh reviewer (someone not currently
|
||||
// requested who has not already reviewed); otherwise an individual who
|
||||
// already covers the target pool (requested or reviewed) is enough.
|
||||
let request;
|
||||
if (reviewRequested) {
|
||||
request = pickPoolRequest(pr, poolName, config, load, submittedReviewers(reviews, author));
|
||||
// Nothing new can be requested: keep needs-reviewer and the current SLA so
|
||||
// a later run retries once a reviewer becomes available.
|
||||
if (!hasRequest(request)) return skipped(pr, "no-reviewer-available", { pool: poolName });
|
||||
} else {
|
||||
const covering = coveringReviewers(pr, reviews, poolName, config);
|
||||
request = covering.length > 0 ? { reviewers: [], teamReviewers: [], source: "covered" } : pickPoolRequest(pr, poolName, config, load);
|
||||
}
|
||||
|
||||
if (routed && !reviewRequested) {
|
||||
// Already routed: only act if the target pool changed and is not covered.
|
||||
// The SLA (due label) is kept as-is. With no individuals configured, a
|
||||
// review from anyone counts, so the team is not re-requested repeatedly.
|
||||
if (!hasRequest(request)) return skipped(pr, "already-routed", { pool: poolName });
|
||||
if (request.reviewers.length === 0 && hasHumanReview(reviews, author)) return skipped(pr, "already-routed", { pool: poolName });
|
||||
return {
|
||||
prNumber: pr.number,
|
||||
action: "route",
|
||||
reason: "pool-changed",
|
||||
pool: poolName,
|
||||
...request,
|
||||
dueDate: dueLabels[dueLabels.length - 1].date,
|
||||
addLabels: [],
|
||||
removeLabels: [],
|
||||
};
|
||||
}
|
||||
|
||||
const dueDate = addBusinessDays(now, config.sla.firstReviewBusinessDays, config.sla.holidays);
|
||||
const dueLabel = dueLabelName(dueDate, config);
|
||||
const removeLabels = [];
|
||||
if (reviewRequested) {
|
||||
// Remove every due label (even one with the same date) before re-adding it,
|
||||
// so the label's `labeled` event marks the start of the new SLA cycle.
|
||||
removeLabels.push(...dueLabels.map((label) => label.name));
|
||||
const prLabels = new Set(labelNames(pr.labels));
|
||||
for (const label of [config.labels.needsReviewer, config.labels.overdue, config.labels.escalated]) {
|
||||
if (prLabels.has(label)) removeLabels.push(label);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
prNumber: pr.number,
|
||||
action: "route",
|
||||
reason: reviewRequested ? "needs-reviewer" : "unrouted",
|
||||
pool: poolName,
|
||||
...request,
|
||||
dueDate,
|
||||
addLabels: [dueLabel],
|
||||
removeLabels,
|
||||
};
|
||||
}
|
||||
|
||||
function mention(login) {
|
||||
return `@${login}`;
|
||||
}
|
||||
|
||||
const FOOTER = "_Automated by the Review Escalation workflow — see `docs/maintainers/review-routing.md`._";
|
||||
|
||||
/**
|
||||
* Decide whether a routed PR is overdue or must escalate. Pure: performs no I/O.
|
||||
* @param {string|null} args.routedAt ISO timestamp when the current due label was added (start of the SLA cycle)
|
||||
*/
|
||||
export function planEscalation({ pr, reviews = [], load = new Map(), config, now = new Date(), routedAt = null }) {
|
||||
const names = new Set(labelNames(pr?.labels));
|
||||
const skip = commonSkip(pr, names, config);
|
||||
if (skip) return skip;
|
||||
|
||||
const dueLabels = parseDueLabels(names, config);
|
||||
if (dueLabels.length === 0) return skipped(pr, "not-routed");
|
||||
|
||||
const author = pr.user?.login ?? "";
|
||||
const dueDate = dueLabels[dueLabels.length - 1].date;
|
||||
const milestones = slaMilestones(dueDate, config);
|
||||
const hasOverdue = names.has(config.labels.overdue);
|
||||
const hasEscalated = names.has(config.labels.escalated);
|
||||
const base = { prNumber: pr.number, ...milestones, routedAt };
|
||||
// The due label only implies a business day; prefer the actual routing time
|
||||
// (for example a weekend or holiday) for display.
|
||||
const routedOnDisplay = routedAt ? toDateKey(routedAt) : milestones.routedOn;
|
||||
|
||||
if (hasHumanReview(reviews, author, routedAt ?? milestones.routedOn)) {
|
||||
const removeLabels = dueLabels.map((label) => label.name);
|
||||
if (hasOverdue) removeLabels.push(config.labels.overdue);
|
||||
if (hasEscalated) removeLabels.push(config.labels.escalated);
|
||||
return { ...base, action: "reviewed", reviewers: [], teamReviewers: [], addLabels: [], removeLabels };
|
||||
}
|
||||
|
||||
const today = toDateKey(now);
|
||||
const poolName = selectPool(names, config);
|
||||
const pool = config.pools[poolName];
|
||||
const escalation = config.pools[config.escalationPool];
|
||||
const exclude = exclusionSet(pr, config);
|
||||
const escalationTeam = escalation.team;
|
||||
const escalationSlug = teamSlug(escalationTeam);
|
||||
|
||||
if (today >= milestones.escalateOn) {
|
||||
if (hasEscalated) return { ...base, action: "none", state: "escalated" };
|
||||
const reviewer = pickReviewer(escalation.reviewers, { exclude, load, seed: pr.number });
|
||||
const reviewers = reviewer ? [reviewer] : [];
|
||||
const teamReviewers = escalationSlug && !isTeamRequested(pr, escalationSlug) ? [escalationSlug] : [];
|
||||
const addLabels = [config.labels.escalated];
|
||||
if (!hasOverdue) addLabels.push(config.labels.overdue);
|
||||
const requested = [...reviewers.map(mention), ...(teamReviewers.length > 0 ? [mention(escalationTeam)] : [])];
|
||||
const body = [
|
||||
COMMENT_MARKERS.escalated,
|
||||
"### 🚨 Review escalated",
|
||||
"",
|
||||
`This pull request was routed to the **${poolName}** reviewer pool on ${routedOnDisplay} and has not received a review within ${config.sla.escalationBusinessDays} business days.`,
|
||||
"",
|
||||
requested.length > 0
|
||||
? `Escalating to the **${config.escalationPool}** pool: requested ${requested.join(", ")}.`
|
||||
: `Escalating to the **${config.escalationPool}** pool${escalationTeam ? ` (${mention(escalationTeam)})` : ""}. Its reviewers are already requested or unavailable, so no additional reviewer was requested.`,
|
||||
"",
|
||||
FOOTER,
|
||||
].join("\n");
|
||||
return { ...base, action: "escalate", pool: poolName, reviewers, teamReviewers, addLabels, removeLabels: [], comment: body };
|
||||
}
|
||||
|
||||
if (today >= milestones.overdueOn) {
|
||||
if (hasOverdue) return { ...base, action: "none", state: "overdue" };
|
||||
const backup = pickReviewer(pool.backup, { exclude, load, seed: pr.number }) ?? pickReviewer(escalation.reviewers, { exclude, load, seed: pr.number });
|
||||
const reviewers = backup ? [backup] : [];
|
||||
let teamReviewers = [];
|
||||
let fallbackTeam = null;
|
||||
if (!backup) {
|
||||
for (const team of [pool.team, escalationTeam]) {
|
||||
const slug = teamSlug(team);
|
||||
if (slug && !isTeamRequested(pr, slug)) {
|
||||
teamReviewers = [slug];
|
||||
fallbackTeam = team;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const currentlyRequested = (pr.requested_reviewers ?? []).map((reviewer) => reviewer?.login).filter((login) => login && !isBotLogin(login));
|
||||
let assignment;
|
||||
if (backup) assignment = `Requesting backup reviewer ${mention(backup)}.`;
|
||||
else if (fallbackTeam) assignment = `No individual backup reviewer is available; requesting the ${mention(fallbackTeam)} team.`;
|
||||
else assignment = "No additional reviewer is available: the configured backups and teams are already requested or unavailable.";
|
||||
const body = [
|
||||
COMMENT_MARKERS.overdue,
|
||||
"### ⏰ Review overdue",
|
||||
"",
|
||||
`This pull request was routed to the **${poolName}** reviewer pool on ${routedOnDisplay}; a first review was due by ${milestones.dueDate}.`,
|
||||
"",
|
||||
currentlyRequested.length > 0 ? `Currently requested: ${currentlyRequested.map(mention).join(", ")}.` : "No individual reviewer is currently requested.",
|
||||
assignment,
|
||||
"",
|
||||
`If no review arrives, this pull request escalates to the **${config.escalationPool}** pool on ${milestones.escalateOn}.`,
|
||||
"",
|
||||
FOOTER,
|
||||
].join("\n");
|
||||
return { ...base, action: "overdue", pool: poolName, reviewers, teamReviewers, addLabels: [config.labels.overdue], removeLabels: [], comment: body };
|
||||
}
|
||||
|
||||
return { ...base, action: "none", state: "on-track" };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GitHub I/O
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function createActions({ github, owner, repo, config, log }) {
|
||||
const dryRun = config.dryRun;
|
||||
const say = (message) => log?.info?.(message);
|
||||
const warn = (message) => (log?.warning ?? log?.info)?.(message);
|
||||
|
||||
async function ensureDueLabel(name) {
|
||||
try {
|
||||
await github.rest.issues.createLabel({ owner, repo, name, color: "C5DEF5", description: "First review target date (review routing)" });
|
||||
} catch (error) {
|
||||
if (error.status !== 422) throw error;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
/**
|
||||
* Apply a plan. Reviewer requests happen first; if they fail, no labels or
|
||||
* comments are changed so the next run retries. Returns false on failure.
|
||||
*/
|
||||
async apply(plan) {
|
||||
const prefix = `${dryRun ? "[dry-run] " : ""}PR #${plan.prNumber}`;
|
||||
if (plan.reviewers?.length || plan.teamReviewers?.length) {
|
||||
say(`${prefix}: request reviewers=${plan.reviewers.join(",") || "-"} teams=${plan.teamReviewers.join(",") || "-"}`);
|
||||
if (!dryRun) {
|
||||
try {
|
||||
await github.rest.pulls.requestReviewers({
|
||||
owner,
|
||||
repo,
|
||||
pull_number: plan.prNumber,
|
||||
reviewers: plan.reviewers,
|
||||
team_reviewers: plan.teamReviewers,
|
||||
});
|
||||
} catch (error) {
|
||||
plan.requestError = error.message;
|
||||
warn(`${prefix}: reviewer request failed; labels left unchanged so the next run retries: ${error.message}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const name of plan.removeLabels ?? []) {
|
||||
say(`${prefix}: remove label ${name}`);
|
||||
if (dryRun) continue;
|
||||
try {
|
||||
await github.rest.issues.removeLabel({ owner, repo, issue_number: plan.prNumber, name });
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
}
|
||||
if (plan.addLabels?.length) {
|
||||
say(`${prefix}: add labels ${plan.addLabels.join(", ")}`);
|
||||
if (!dryRun) {
|
||||
for (const name of plan.addLabels) {
|
||||
if (name.startsWith(config.labels.duePrefix)) await ensureDueLabel(name);
|
||||
}
|
||||
await github.rest.issues.addLabels({ owner, repo, issue_number: plan.prNumber, labels: plan.addLabels });
|
||||
}
|
||||
}
|
||||
if (plan.comment) {
|
||||
say(`${prefix}: post ${plan.action} comment`);
|
||||
if (!dryRun) await github.rest.issues.createComment({ owner, repo, issue_number: plan.prNumber, body: plan.comment });
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function listOpenPulls(github, owner, repo) {
|
||||
return github.paginate(github.rest.pulls.list, { owner, repo, state: "open", per_page: 100 });
|
||||
}
|
||||
|
||||
async function listReviews(github, owner, repo, pullNumber) {
|
||||
return github.paginate(github.rest.pulls.listReviews, { owner, repo, pull_number: pullNumber, per_page: 100 });
|
||||
}
|
||||
|
||||
/** Timestamp of the most recent `labeled` event for `labelName` on the PR, or null. */
|
||||
export async function findLabelAddedAt(github, owner, repo, issueNumber, labelName) {
|
||||
const events = await github.paginate(github.rest.issues.listEvents, { owner, repo, issue_number: issueNumber, per_page: 100 });
|
||||
let latest = null;
|
||||
for (const event of events) {
|
||||
if (event?.event === "labeled" && event.label?.name === labelName && event.created_at) {
|
||||
if (!latest || Date.parse(event.created_at) > Date.parse(latest)) latest = event.created_at;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
}
|
||||
|
||||
async function currentRoutedAt(github, owner, repo, pr, config) {
|
||||
const dueLabels = parseDueLabels(labelNames(pr.labels), config);
|
||||
if (dueLabels.length === 0) return null;
|
||||
return findLabelAddedAt(github, owner, repo, pr.number, dueLabels[dueLabels.length - 1].name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Route a single PR (read, plan, apply). Callers must serialize this per PR.
|
||||
* @param {string[]|null} intentLabels Authoritative intent labels from a Label PR Intent artifact
|
||||
* @param {string|null} expectedHeadSha Skip if the PR head moved since the artifact was produced
|
||||
*/
|
||||
export async function routePullRequest({ github, owner, repo, config, prNumber, intentLabels = null, expectedHeadSha = null, now = new Date(), log }) {
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
if (expectedHeadSha && pr.head?.sha !== expectedHeadSha) {
|
||||
log?.info?.(`PR #${prNumber}: skipped (head moved; a newer intent run will route it)`);
|
||||
return skipped(pr, "stale-head");
|
||||
}
|
||||
const [reviews, openPulls] = await Promise.all([listReviews(github, owner, repo, prNumber), listOpenPulls(github, owner, repo)]);
|
||||
const plan = planRouting({ pr, reviews, load: computeReviewLoad(openPulls), config, now, intentLabels });
|
||||
if (plan.action === "route") {
|
||||
await createActions({ github, owner, repo, config, log }).apply(plan);
|
||||
} else {
|
||||
log?.info?.(`PR #${prNumber}: skipped (${plan.reason})`);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
/** Read-only: plan routing for every open PR. Used to pick the PRs a sweep should route. */
|
||||
export async function planRoutingSweep({ github, owner, repo, config, now = new Date() }) {
|
||||
const openPulls = await listOpenPulls(github, owner, repo);
|
||||
const load = computeReviewLoad(openPulls);
|
||||
const plans = [];
|
||||
for (const pr of openPulls) {
|
||||
if (pr.draft) continue;
|
||||
const reviews = await listReviews(github, owner, repo, pr.number);
|
||||
plans.push(planRouting({ pr, reviews, load, config, now }));
|
||||
}
|
||||
return plans;
|
||||
}
|
||||
|
||||
/** Enforce the SLA on a single routed PR (read, plan, apply). Callers must serialize this per PR. */
|
||||
export async function escalatePullRequest({ github, owner, repo, config, prNumber, now = new Date(), log }) {
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
const [reviews, openPulls, routedAt] = await Promise.all([
|
||||
listReviews(github, owner, repo, prNumber),
|
||||
listOpenPulls(github, owner, repo),
|
||||
currentRoutedAt(github, owner, repo, pr, config),
|
||||
]);
|
||||
const plan = planEscalation({ pr, reviews, load: computeReviewLoad(openPulls), config, now, routedAt });
|
||||
if (["reviewed", "overdue", "escalate"].includes(plan.action)) {
|
||||
await createActions({ github, owner, repo, config, log }).apply(plan);
|
||||
} else {
|
||||
log?.info?.(`PR #${prNumber}: no action (${plan.reason ?? plan.state})`);
|
||||
}
|
||||
return plan;
|
||||
}
|
||||
|
||||
/** Read-only: plan SLA enforcement for every routed open PR. */
|
||||
export async function planEscalationSweep({ github, owner, repo, config, now = new Date() }) {
|
||||
const openPulls = await listOpenPulls(github, owner, repo);
|
||||
const load = computeReviewLoad(openPulls);
|
||||
const plans = [];
|
||||
for (const pr of openPulls) {
|
||||
if (pr.draft || parseDueLabels(labelNames(pr.labels), config).length === 0) continue;
|
||||
const [reviews, routedAt] = await Promise.all([listReviews(github, owner, repo, pr.number), currentRoutedAt(github, owner, repo, pr, config)]);
|
||||
plans.push(planEscalation({ pr, reviews, load, config, now, routedAt }));
|
||||
}
|
||||
return plans;
|
||||
}
|
||||
|
||||
/** Delete `review-due:*` labels older than the retention window that no open PR uses. */
|
||||
export async function cleanupStaleDueLabels({ github, owner, repo, config, now = new Date(), log }) {
|
||||
const openPulls = await listOpenPulls(github, owner, repo);
|
||||
const active = new Set(openPulls.flatMap((pr) => parseDueLabels(labelNames(pr.labels), config).map((label) => label.name)));
|
||||
const cutoff = shiftDateKey(toDateKey(now), -DUE_LABEL_RETENTION_DAYS);
|
||||
const repoLabels = await github.paginate(github.rest.issues.listLabelsForRepo, { owner, repo, per_page: 100 });
|
||||
const deleted = [];
|
||||
for (const { name, date } of parseDueLabels(labelNames(repoLabels), config)) {
|
||||
if (date >= cutoff || active.has(name)) continue;
|
||||
log?.info?.(`${config.dryRun ? "[dry-run] " : ""}delete stale label ${name}`);
|
||||
deleted.push(name);
|
||||
if (config.dryRun) continue;
|
||||
try {
|
||||
await github.rest.issues.deleteLabel({ owner, repo, name });
|
||||
} catch (error) {
|
||||
if (error.status !== 404) throw error;
|
||||
}
|
||||
}
|
||||
return deleted;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// workflow_run artifact validation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Validate a reader-workflow artifact and confirm it belongs to an open PR
|
||||
* whose head matches the triggering workflow_run. Mirrors the checks in
|
||||
* label-pr-intent-writer.yml.
|
||||
* @returns {Promise<{ prNumber: number, headSha: string, intentLabels: string[]|null } | { skip: string }>}
|
||||
* intentLabels is an array for Label PR Intent artifacts and null for needs-reviewer requests.
|
||||
*/
|
||||
export async function resolveWorkflowRunArtifact({ github, owner, repo, workflowRun, artifact }) {
|
||||
const fail = (message) => {
|
||||
throw new Error(`Invalid review routing artifact: ${message}`);
|
||||
};
|
||||
|
||||
if (!isPlainObject(artifact)) fail("artifact must be an object");
|
||||
const schema = artifact.schema_version;
|
||||
if (schema !== INTENT_ARTIFACT_SCHEMA && schema !== ROUTING_REQUEST_ARTIFACT_SCHEMA) fail("unexpected schema_version");
|
||||
if (artifact.event !== "pull_request") fail("unexpected event");
|
||||
if (!Number.isInteger(artifact.pr_number) || artifact.pr_number < 1) fail("invalid pr_number");
|
||||
if (!/^[0-9a-f]{40}$/i.test(String(artifact.head_sha || ""))) fail("invalid head_sha");
|
||||
if (workflowRun?.event !== "pull_request") fail("unexpected workflow_run event");
|
||||
if (String(artifact.run_id || "") !== String(workflowRun.id)) fail("run_id did not match workflow_run");
|
||||
if (artifact.head_sha !== workflowRun.head_sha) fail("head_sha did not match workflow_run");
|
||||
|
||||
let intentLabels = null;
|
||||
if (schema === INTENT_ARTIFACT_SCHEMA) {
|
||||
intentLabels = validateIntentLabels(artifact.desired_labels, fail);
|
||||
}
|
||||
|
||||
const prNumber = artifact.pr_number;
|
||||
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
||||
if (pr.state !== "open") return { skip: `PR #${prNumber} is not open` };
|
||||
|
||||
const expectedBase = `${owner}/${repo}`.toLowerCase();
|
||||
if (lower(pr.base?.repo?.full_name) !== expectedBase) fail(`PR #${prNumber} does not target this repository`);
|
||||
if (pr.head?.sha !== workflowRun.head_sha) return { skip: `stale artifact for PR #${prNumber}` };
|
||||
|
||||
const runHeadRepository = String(workflowRun.head_repository?.full_name || "");
|
||||
const [headOwner, headName] = runHeadRepository.split("/");
|
||||
const runHeadRef = String(workflowRun.head_branch || "");
|
||||
if (!headOwner || !headName || !runHeadRef || lower(pr.head?.repo?.full_name) !== lower(runHeadRepository) || String(pr.head?.ref || "") !== runHeadRef) {
|
||||
fail(`PR #${prNumber} head did not match workflow_run`);
|
||||
}
|
||||
|
||||
const runPulls = Array.isArray(workflowRun.pull_requests) ? workflowRun.pull_requests : [];
|
||||
if (runPulls.length > 0) {
|
||||
if (!runPulls.some((pull) => pull.number === prNumber)) fail(`PR #${prNumber} was not present in workflow_run.pull_requests`);
|
||||
} else {
|
||||
const candidates = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: "open",
|
||||
head: `${headOwner}:${runHeadRef}`,
|
||||
per_page: 100,
|
||||
});
|
||||
const matches = candidates.filter(
|
||||
(candidate) =>
|
||||
candidate.head?.sha === workflowRun.head_sha &&
|
||||
String(candidate.head?.ref || "") === runHeadRef &&
|
||||
lower(candidate.head?.repo?.full_name) === lower(runHeadRepository) &&
|
||||
lower(candidate.base?.repo?.full_name) === expectedBase
|
||||
);
|
||||
if (matches.length !== 1 || matches[0].number !== prNumber) fail(`PR #${prNumber} could not be uniquely associated with workflow_run`);
|
||||
}
|
||||
|
||||
return { prNumber, headSha: artifact.head_sha, intentLabels };
|
||||
}
|
||||
|
||||
/** Validate an intent label list; throws via `fail` (default: Error) on unknown labels. */
|
||||
export function validateIntentLabels(value, fail = (message) => { throw new Error(message); }) {
|
||||
if (!Array.isArray(value)) fail("invalid desired_labels");
|
||||
for (const label of value) {
|
||||
if (!INTENT_LABELS.includes(label)) fail(`unexpected desired label ${label}`);
|
||||
}
|
||||
return [...value];
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reporting
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function formatResultsSummary(results, { title, dryRun }) {
|
||||
const lines = [`## ${title}${dryRun ? " (dry run)" : ""}`, ""];
|
||||
if (results.length === 0) {
|
||||
lines.push("_No pull requests needed action._");
|
||||
return lines.join("\n");
|
||||
}
|
||||
lines.push("| PR | Action | Pool | Reviewers | Labels added | Labels removed | Notes |", "|---:|---|---|---|---|---|---|");
|
||||
for (const result of results) {
|
||||
const reviewers = [...(result.reviewers ?? []), ...(result.teamReviewers ?? []).map((team) => `team:${team}`)].join(", ");
|
||||
const notes = [result.reason, result.source, result.state, result.requestError && `request failed: ${result.requestError}`].filter(Boolean).join("; ");
|
||||
lines.push(
|
||||
`| #${result.prNumber} | ${result.action} | ${result.pool ?? ""} | ${reviewers} | ${(result.addLabels ?? []).join(", ")} | ${(result.removeLabels ?? []).join(", ")} | ${notes} |`
|
||||
);
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// CLI: `node eng/review-routing.mjs validate [path]`
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||||
const [command = "validate", filePath = REVIEW_ROUTING_CONFIG_PATH] = process.argv.slice(2);
|
||||
if (command !== "validate") {
|
||||
console.error("Usage: node eng/review-routing.mjs validate [config-path]");
|
||||
process.exit(2);
|
||||
}
|
||||
try {
|
||||
const config = loadReviewRoutingConfig(filePath);
|
||||
const pools = Object.values(config.pools)
|
||||
.map((pool) => `${pool.name}: ${pool.reviewers.length} reviewer(s), ${pool.backup.length} backup(s)`)
|
||||
.join("\n ");
|
||||
console.log(`✓ ${path.relative(process.cwd(), filePath)} is valid${config.dryRun ? " (dry_run enabled)" : ""}\n ${pools}`);
|
||||
} catch (error) {
|
||||
console.error(error.message);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { describe, test } from "node:test";
|
||||
import {
|
||||
COMMENT_MARKERS,
|
||||
REVIEW_ROUTING_CONFIG_PATH,
|
||||
addBusinessDays,
|
||||
computeReviewLoad,
|
||||
formatResultsSummary,
|
||||
hasHumanReview,
|
||||
isBusinessDay,
|
||||
limitTargets,
|
||||
MAX_MATRIX_TARGETS,
|
||||
loadReviewRoutingConfig,
|
||||
normalizeReviewRoutingConfig,
|
||||
pickReviewer,
|
||||
planEscalation,
|
||||
planRouting,
|
||||
resolveWorkflowRunArtifact,
|
||||
cleanupStaleDueLabels,
|
||||
coveringReviewers,
|
||||
effectiveLabelNames,
|
||||
escalatePullRequest,
|
||||
planEscalationSweep,
|
||||
planRoutingSweep,
|
||||
routePullRequest,
|
||||
selectPool,
|
||||
slaMilestones,
|
||||
validateIntentLabels,
|
||||
} from "./review-routing.mjs";
|
||||
|
||||
function rawConfig(overrides = {}) {
|
||||
return {
|
||||
version: 1,
|
||||
dry_run: false,
|
||||
sla: { first_review_business_days: 2, escalation_business_days: 4, holidays: [] },
|
||||
labels: {
|
||||
needs_reviewer: "needs-reviewer",
|
||||
due_prefix: "review-due:",
|
||||
overdue: "review-overdue",
|
||||
escalated: "review-escalated",
|
||||
},
|
||||
escalation_pool: "core-maintainers",
|
||||
default_pool: "core-maintainers",
|
||||
pools: {
|
||||
"core-maintainers": { team: "github/core", reviewers: ["core1", "core2"], backup: [] },
|
||||
canvas: { team: "github/canvas", reviewers: ["canvas1", "canvas2", "canvas3"], backup: ["canvasBackup"] },
|
||||
plugin: { team: "github/plugin", reviewers: ["plugin1"], backup: [] },
|
||||
content: { team: "github/content", reviewers: [], backup: [] },
|
||||
automation: { team: "github/automation", reviewers: ["auto1"], backup: ["autoBackup"] },
|
||||
},
|
||||
routes: [
|
||||
{ label: "workflow", pool: "automation" },
|
||||
{ label: "hooks", pool: "automation" },
|
||||
{ label: "canvas-extension", pool: "canvas" },
|
||||
{ label: "plugin", pool: "plugin" },
|
||||
{ label: "skills", pool: "content" },
|
||||
],
|
||||
unavailable: [],
|
||||
skip_authors: [],
|
||||
skip_labels: ["do-not-merge"],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function config(overrides) {
|
||||
const { config: normalized, errors } = normalizeReviewRoutingConfig(rawConfig(overrides));
|
||||
assert.deepEqual(errors, []);
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function pr({ number = 42, author = "contributor", labels = [], requested = [], teams = [], draft = false, state = "open" } = {}) {
|
||||
return {
|
||||
number,
|
||||
state,
|
||||
draft,
|
||||
user: { login: author, type: "User" },
|
||||
labels: labels.map((name) => ({ name })),
|
||||
requested_reviewers: requested.map((login) => ({ login })),
|
||||
requested_teams: teams.map((slug) => ({ slug })),
|
||||
};
|
||||
}
|
||||
|
||||
function review(login, submittedAt = "2026-09-30T12:00:00Z", state = "COMMENTED") {
|
||||
return { user: { login, type: login.endsWith("[bot]") ? "Bot" : "User" }, state, submitted_at: submittedAt };
|
||||
}
|
||||
|
||||
// 2026-09-28 is a Monday.
|
||||
const MONDAY = new Date("2026-09-28T10:00:00Z");
|
||||
|
||||
describe("configuration", () => {
|
||||
test("repository config file is valid", () => {
|
||||
const loaded = loadReviewRoutingConfig(REVIEW_ROUTING_CONFIG_PATH);
|
||||
assert.equal(loaded.sla.firstReviewBusinessDays, 2);
|
||||
assert.equal(loaded.sla.escalationBusinessDays, 4);
|
||||
assert.ok(loaded.pools[loaded.escalationPool]);
|
||||
// Pin the routing contract: label -> pool, in priority order.
|
||||
assert.deepEqual(
|
||||
loaded.routes.map((route) => [route.label, route.pool]),
|
||||
[
|
||||
["canvas-extension", "canvas"],
|
||||
["external-plugin", "plugin"],
|
||||
["plugin", "plugin"],
|
||||
["skills", "content"],
|
||||
["agent", "content"],
|
||||
["instructions", "content"],
|
||||
["workflow", "content"],
|
||||
["hooks", "content"],
|
||||
["website-update", "core-maintainers"],
|
||||
]
|
||||
);
|
||||
assert.deepEqual(Object.keys(loaded.pools).sort(), ["canvas", "content", "core-maintainers", "plugin"]);
|
||||
assert.equal(loaded.defaultPool, "core-maintainers");
|
||||
assert.equal(loaded.escalationPool, "core-maintainers");
|
||||
});
|
||||
|
||||
test("rejects unknown pools, bad logins, and inverted SLAs", () => {
|
||||
const { config: normalized, errors } = normalizeReviewRoutingConfig(
|
||||
rawConfig({
|
||||
sla: { first_review_business_days: 4, escalation_business_days: 2 },
|
||||
routes: [{ label: "skills", pool: "missing" }],
|
||||
pools: { "core-maintainers": { team: "not a team", reviewers: ["@bad login"] } },
|
||||
default_pool: "nope",
|
||||
})
|
||||
);
|
||||
assert.equal(normalized, null);
|
||||
assert.ok(errors.some((error) => error.includes("escalation_business_days")));
|
||||
assert.ok(errors.some((error) => error.includes("unknown pool")));
|
||||
assert.ok(errors.some((error) => error.includes("invalid GitHub login")));
|
||||
assert.ok(errors.some((error) => error.includes("<org>/<team-slug>")));
|
||||
assert.ok(errors.some((error) => error.includes("default_pool")));
|
||||
});
|
||||
});
|
||||
|
||||
describe("business days", () => {
|
||||
test("weekends and holidays are not business days", () => {
|
||||
assert.equal(isBusinessDay("2026-10-02"), true); // Friday
|
||||
assert.equal(isBusinessDay("2026-10-03"), false); // Saturday
|
||||
assert.equal(isBusinessDay("2026-10-04"), false); // Sunday
|
||||
assert.equal(isBusinessDay("2026-10-05", ["2026-10-05"]), false);
|
||||
});
|
||||
|
||||
test("addBusinessDays skips weekends and holidays", () => {
|
||||
assert.equal(addBusinessDays("2026-09-28", 2), "2026-09-30"); // Mon -> Wed
|
||||
assert.equal(addBusinessDays("2026-10-01", 2), "2026-10-05"); // Thu -> Mon
|
||||
assert.equal(addBusinessDays("2026-10-02", 2), "2026-10-06"); // Fri -> Tue
|
||||
assert.equal(addBusinessDays("2026-10-03", 2), "2026-10-06"); // Sat -> Tue
|
||||
assert.equal(addBusinessDays("2026-10-02", 1, ["2026-10-05"]), "2026-10-06");
|
||||
assert.equal(addBusinessDays("2026-10-06", -2), "2026-10-02");
|
||||
assert.equal(addBusinessDays(new Date("2026-09-28T23:59:00Z"), 0), "2026-09-28");
|
||||
});
|
||||
|
||||
test("slaMilestones derives routing, overdue, and escalation dates", () => {
|
||||
assert.deepEqual(slaMilestones("2026-09-30", config()), {
|
||||
routedOn: "2026-09-28",
|
||||
dueDate: "2026-09-30",
|
||||
overdueOn: "2026-10-01",
|
||||
escalateOn: "2026-10-02",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("reviewer selection", () => {
|
||||
test("selectPool honors route priority and falls back to the default pool", () => {
|
||||
const cfg = config();
|
||||
assert.equal(selectPool(["skills", "plugin"], cfg), "plugin");
|
||||
assert.equal(selectPool(["plugin", "canvas-extension"], cfg), "canvas");
|
||||
assert.equal(selectPool(["canvas-extension", "workflow"], cfg), "automation");
|
||||
assert.equal(selectPool(["website-update"], cfg), "core-maintainers");
|
||||
});
|
||||
|
||||
test("effectiveLabelNames treats artifact intent labels as authoritative", () => {
|
||||
const labels = [{ name: "workflow" }, { name: "needs-reviewer" }, { name: "review-due:2026-09-30" }];
|
||||
assert.deepEqual([...effectiveLabelNames(labels, null)].sort(), ["needs-reviewer", "review-due:2026-09-30", "workflow"]);
|
||||
assert.deepEqual([...effectiveLabelNames(labels, ["skills"])].sort(), ["needs-reviewer", "review-due:2026-09-30", "skills"]);
|
||||
assert.deepEqual([...effectiveLabelNames(labels, [])].sort(), ["needs-reviewer", "review-due:2026-09-30"]);
|
||||
});
|
||||
|
||||
test("validateIntentLabels rejects unknown labels", () => {
|
||||
assert.deepEqual(validateIntentLabels(["skills", "plugin"]), ["skills", "plugin"]);
|
||||
assert.throws(() => validateIntentLabels(["approved"]), /unexpected desired label/);
|
||||
assert.throws(() => validateIntentLabels("skills"), /invalid desired_labels/);
|
||||
});
|
||||
|
||||
test("computeReviewLoad counts open review requests case-insensitively", () => {
|
||||
const load = computeReviewLoad([pr({ requested: ["Canvas1", "canvas2"] }), pr({ requested: ["canvas1"] })]);
|
||||
assert.equal(load.get("canvas1"), 2);
|
||||
assert.equal(load.get("canvas2"), 1);
|
||||
});
|
||||
|
||||
test("pickReviewer prefers the lowest load and rotates ties by seed", () => {
|
||||
const load = new Map([["canvas1", 3], ["canvas2", 1], ["canvas3", 1]]);
|
||||
assert.equal(pickReviewer(["canvas1", "canvas2", "canvas3"], { load, seed: 0 }), "canvas2");
|
||||
assert.equal(pickReviewer(["canvas1", "canvas2", "canvas3"], { load, seed: 1 }), "canvas3");
|
||||
assert.equal(pickReviewer(["canvas1", "canvas2"], { load, exclude: new Set(["canvas2"]) }), "canvas1");
|
||||
assert.equal(pickReviewer(["copilot[bot]"], {}), null);
|
||||
assert.equal(pickReviewer([], {}), null);
|
||||
});
|
||||
|
||||
test("hasHumanReview ignores the author, bots, pending, and reviews before the boundary", () => {
|
||||
assert.equal(hasHumanReview([review("contributor")], "contributor"), false);
|
||||
assert.equal(hasHumanReview([review("copilot-pull-request-reviewer[bot]")], "contributor"), false);
|
||||
assert.equal(hasHumanReview([review("core1", "2026-09-30T00:00:00Z", "PENDING")], "contributor"), false);
|
||||
assert.equal(hasHumanReview([review("core1", "2026-09-20T00:00:00Z")], "contributor", "2026-09-28"), false);
|
||||
assert.equal(hasHumanReview([review("core1", "2026-09-29T00:00:00Z")], "contributor", "2026-09-28"), true);
|
||||
assert.equal(hasHumanReview([review("core1", "2026-09-28T10:00:00Z")], "contributor", "2026-09-28T15:00:00Z"), false);
|
||||
assert.equal(hasHumanReview([review("core1", "2026-09-28T16:00:00Z")], "contributor", "2026-09-28T15:00:00Z"), true);
|
||||
});
|
||||
|
||||
test("coveringReviewers counts pool, backup, and core individuals who are requested or reviewed", () => {
|
||||
const cfg = config();
|
||||
const target = pr({ labels: ["plugin"], requested: ["canvas1", "core2"] });
|
||||
assert.deepEqual(coveringReviewers(target, [review("plugin1")], "plugin", cfg), ["core2", "plugin1"]);
|
||||
assert.deepEqual(coveringReviewers(pr({ requested: ["canvas1"] }), [], "automation", cfg), []);
|
||||
});
|
||||
|
||||
test("coveringReviewers ignores pending requests for unavailable reviewers but keeps their submitted reviews", () => {
|
||||
const cfg = config({ unavailable: ["plugin1"] });
|
||||
assert.deepEqual(coveringReviewers(pr({ labels: ["plugin"], requested: ["plugin1"] }), [], "plugin", cfg), []);
|
||||
assert.deepEqual(coveringReviewers(pr({ labels: ["plugin"] }), [review("plugin1")], "plugin", cfg), ["plugin1"]);
|
||||
});
|
||||
|
||||
test("limitTargets caps matrix targets and reports the deferred count", () => {
|
||||
const many = Array.from({ length: MAX_MATRIX_TARGETS + 5 }, (_, index) => ({ pr: index + 1 }));
|
||||
const limited = limitTargets(many);
|
||||
assert.equal(limited.targets.length, MAX_MATRIX_TARGETS);
|
||||
assert.equal(limited.deferred, 5);
|
||||
assert.deepEqual(limitTargets([{ pr: 1 }]), { targets: [{ pr: 1 }], deferred: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("planRouting", () => {
|
||||
test("requests the least-loaded pool reviewer, excluding the author, and sets a due label", () => {
|
||||
const plan = planRouting({
|
||||
pr: pr({ author: "canvas2", labels: ["canvas-extension", "plugin"] }),
|
||||
load: new Map([["canvas1", 2], ["canvas3", 0]]),
|
||||
config: config(),
|
||||
now: MONDAY,
|
||||
});
|
||||
assert.equal(plan.action, "route");
|
||||
assert.equal(plan.pool, "canvas");
|
||||
assert.deepEqual(plan.reviewers, ["canvas3"]);
|
||||
assert.deepEqual(plan.teamReviewers, []);
|
||||
assert.deepEqual(plan.addLabels, ["review-due:2026-09-30"]);
|
||||
assert.deepEqual(plan.removeLabels, []);
|
||||
});
|
||||
|
||||
test("artifact intent labels replace stale current intent labels", () => {
|
||||
const cfg = config();
|
||||
const stale = pr({ labels: ["workflow"] });
|
||||
assert.equal(planRouting({ pr: stale, intentLabels: ["plugin"], config: cfg, now: MONDAY }).pool, "plugin");
|
||||
assert.equal(planRouting({ pr: stale, intentLabels: [], config: cfg, now: MONDAY }).pool, "core-maintainers");
|
||||
assert.equal(planRouting({ pr: stale, intentLabels: null, config: cfg, now: MONDAY }).pool, "automation");
|
||||
});
|
||||
|
||||
test("falls back to backup, then escalation pool, then the team", () => {
|
||||
const cfg = config();
|
||||
assert.deepEqual(planRouting({ pr: pr({ author: "auto1", labels: ["hooks"] }), config: cfg, now: MONDAY }).reviewers, ["autoBackup"]);
|
||||
const content = planRouting({ pr: pr({ labels: ["skills"] }), config: cfg, now: MONDAY });
|
||||
assert.equal(content.source, "escalation-pool");
|
||||
assert.ok(["core1", "core2"].includes(content.reviewers[0]));
|
||||
|
||||
const empty = config({
|
||||
pools: { ...rawConfig().pools, "core-maintainers": { team: "github/core", reviewers: [] }, content: { team: "github/content" } },
|
||||
});
|
||||
const teamPlan = planRouting({ pr: pr({ labels: ["skills"] }), config: empty, now: MONDAY });
|
||||
assert.deepEqual(teamPlan.reviewers, []);
|
||||
assert.deepEqual(teamPlan.teamReviewers, ["content"]);
|
||||
assert.equal(teamPlan.source, "team");
|
||||
});
|
||||
|
||||
test("skips drafts, closed PRs, skip labels, covered routed PRs, and reviewed unrouted PRs", () => {
|
||||
const cfg = config();
|
||||
assert.equal(planRouting({ pr: pr({ draft: true }), config: cfg }).reason, "draft");
|
||||
assert.equal(planRouting({ pr: pr({ state: "closed" }), config: cfg }).reason, "not-open");
|
||||
assert.equal(planRouting({ pr: pr({ labels: ["do-not-merge"] }), config: cfg }).reason, "skipped-label");
|
||||
assert.equal(planRouting({ pr: pr({ labels: ["review-due:2026-09-30"], requested: ["core1"] }), config: cfg }).reason, "already-routed");
|
||||
assert.equal(planRouting({ pr: pr(), reviews: [review("core1")], config: cfg }).reason, "already-reviewed");
|
||||
});
|
||||
|
||||
test("a covering reviewer already requested is kept; a non-pool request still gets a pool reviewer", () => {
|
||||
const covered = planRouting({ pr: pr({ labels: ["plugin"], requested: ["plugin1"] }), config: config(), now: MONDAY });
|
||||
assert.equal(covered.source, "covered");
|
||||
assert.deepEqual(covered.reviewers, []);
|
||||
assert.deepEqual(covered.addLabels, ["review-due:2026-09-30"]);
|
||||
|
||||
const manual = planRouting({ pr: pr({ labels: ["plugin"], requested: ["someone"] }), config: config(), now: MONDAY });
|
||||
assert.deepEqual(manual.reviewers, ["plugin1"]);
|
||||
});
|
||||
|
||||
test("re-routes an already-routed PR when a new head changes the pool, keeping the SLA", () => {
|
||||
const routed = pr({ labels: ["canvas-extension", "review-due:2026-09-30"], requested: ["canvas1"] });
|
||||
const plan = planRouting({ pr: routed, intentLabels: ["canvas-extension", "workflow"], config: config(), now: new Date("2026-09-29T10:00:00Z") });
|
||||
assert.equal(plan.action, "route");
|
||||
assert.equal(plan.reason, "pool-changed");
|
||||
assert.equal(plan.pool, "automation");
|
||||
assert.deepEqual(plan.reviewers, ["auto1"]);
|
||||
assert.deepEqual(plan.addLabels, []);
|
||||
assert.deepEqual(plan.removeLabels, []);
|
||||
assert.equal(plan.dueDate, "2026-09-30");
|
||||
|
||||
const unchanged = planRouting({ pr: routed, intentLabels: ["canvas-extension"], config: config() });
|
||||
assert.equal(unchanged.reason, "already-routed");
|
||||
});
|
||||
|
||||
test("does not re-request a team after someone reviewed when no individuals are configured", () => {
|
||||
const empty = config({
|
||||
pools: { ...rawConfig().pools, "core-maintainers": { team: "github/core", reviewers: [] }, content: { team: "github/content" } },
|
||||
});
|
||||
const routed = pr({ labels: ["skills", "review-due:2026-09-30"] });
|
||||
assert.equal(planRouting({ pr: routed, reviews: [review("someone")], config: empty }).reason, "already-routed");
|
||||
assert.deepEqual(planRouting({ pr: routed, config: empty }).teamReviewers, ["content"]);
|
||||
});
|
||||
|
||||
test("needs-reviewer re-routes, restarts the SLA, and removes the request label", () => {
|
||||
const plan = planRouting({
|
||||
pr: pr({
|
||||
labels: ["canvas-extension", "needs-reviewer", "review-due:2026-09-10", "review-overdue", "review-escalated"],
|
||||
requested: ["canvas1"],
|
||||
}),
|
||||
reviews: [review("canvas1", "2026-09-09T00:00:00Z")],
|
||||
config: config(),
|
||||
now: MONDAY,
|
||||
});
|
||||
assert.equal(plan.action, "route");
|
||||
assert.equal(plan.reason, "needs-reviewer");
|
||||
assert.ok(["canvas2", "canvas3"].includes(plan.reviewers[0]));
|
||||
assert.deepEqual(plan.addLabels, ["review-due:2026-09-30"]);
|
||||
assert.deepEqual(plan.removeLabels.sort(), ["needs-reviewer", "review-due:2026-09-10", "review-escalated", "review-overdue"]);
|
||||
});
|
||||
|
||||
test("needs-reviewer skips reviewers who already submitted a review", () => {
|
||||
const plan = planRouting({
|
||||
pr: pr({ labels: ["canvas-extension", "needs-reviewer", "review-due:2026-09-10"], requested: ["canvas1"] }),
|
||||
reviews: [review("canvas2", "2026-09-09T00:00:00Z")],
|
||||
config: config(),
|
||||
now: MONDAY,
|
||||
});
|
||||
assert.equal(plan.action, "route");
|
||||
assert.deepEqual(plan.reviewers, ["canvas3"]);
|
||||
});
|
||||
|
||||
test("needs-reviewer with no new reviewer to request leaves labels and the SLA unchanged", () => {
|
||||
const plan = planRouting({
|
||||
pr: pr({ labels: ["plugin", "needs-reviewer", "review-due:2026-09-10"], requested: ["plugin1"], teams: ["plugin"] }),
|
||||
config: config({ unavailable: ["core1", "core2"] }),
|
||||
now: MONDAY,
|
||||
});
|
||||
assert.equal(plan.action, "skip");
|
||||
assert.equal(plan.reason, "no-reviewer-available");
|
||||
assert.equal(plan.addLabels, undefined);
|
||||
assert.equal(plan.removeLabels, undefined);
|
||||
});
|
||||
|
||||
test("needs-reviewer removes and re-adds a same-date due label to mark a new SLA cycle", () => {
|
||||
const plan = planRouting({ pr: pr({ labels: ["plugin", "needs-reviewer", "review-due:2026-09-30"] }), config: config(), now: MONDAY });
|
||||
assert.deepEqual(plan.removeLabels, ["review-due:2026-09-30", "needs-reviewer"]);
|
||||
assert.deepEqual(plan.addLabels, ["review-due:2026-09-30"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("planEscalation", () => {
|
||||
const routed = (extra = {}) =>
|
||||
pr({ labels: [extra.pool ?? "canvas-extension", `review-due:${extra.due ?? "2026-09-30"}`, ...(extra.labels ?? [])], requested: extra.requested ?? ["canvas1"] });
|
||||
|
||||
test("is on track through the due date", () => {
|
||||
assert.equal(planEscalation({ pr: routed(), config: config(), now: new Date("2026-09-30T20:00:00Z") }).state, "on-track");
|
||||
});
|
||||
|
||||
test("marks overdue and requests a backup the next business day", () => {
|
||||
const plan = planEscalation({ pr: routed(), config: config(), now: new Date("2026-10-01T14:00:00Z") });
|
||||
assert.equal(plan.action, "overdue");
|
||||
assert.deepEqual(plan.reviewers, ["canvasBackup"]);
|
||||
assert.deepEqual(plan.addLabels, ["review-overdue"]);
|
||||
assert.ok(plan.comment.startsWith(COMMENT_MARKERS.overdue));
|
||||
assert.match(plan.comment, /Requesting backup reviewer @canvasBackup/);
|
||||
assert.match(plan.comment, /2026-10-02/);
|
||||
});
|
||||
|
||||
test("falls back to a team when no backup individual is available, and says so", () => {
|
||||
const noBackups = config({ unavailable: ["core1", "core2"], pools: { ...rawConfig().pools, canvas: { team: "github/canvas", reviewers: ["canvas1"] } } });
|
||||
const now = new Date("2026-10-01T14:00:00Z");
|
||||
const plan = planEscalation({ pr: routed(), config: noBackups, now });
|
||||
assert.deepEqual(plan.reviewers, []);
|
||||
assert.deepEqual(plan.teamReviewers, ["canvas"]);
|
||||
assert.match(plan.comment, /requesting the @github\/canvas team/);
|
||||
|
||||
const allRequested = planEscalation({ pr: { ...routed(), requested_teams: [{ slug: "canvas" }, { slug: "core" }] }, config: noBackups, now });
|
||||
assert.deepEqual(allRequested.reviewers, []);
|
||||
assert.deepEqual(allRequested.teamReviewers, []);
|
||||
assert.match(allRequested.comment, /No additional reviewer is available/);
|
||||
});
|
||||
|
||||
test("does not repeat the overdue action and skips weekends", () => {
|
||||
const cfg = config();
|
||||
// Routed Wednesday 2026-09-30: due Friday, overdue Monday, escalates Tuesday.
|
||||
const wednesday = (labels = []) => routed({ due: "2026-10-02", labels });
|
||||
assert.equal(planEscalation({ pr: wednesday(), config: cfg, now: new Date("2026-10-03T14:00:00Z") }).state, "on-track");
|
||||
assert.equal(planEscalation({ pr: wednesday(), config: cfg, now: new Date("2026-10-04T14:00:00Z") }).state, "on-track");
|
||||
assert.equal(planEscalation({ pr: wednesday(), config: cfg, now: new Date("2026-10-05T14:00:00Z") }).action, "overdue");
|
||||
assert.equal(planEscalation({ pr: wednesday(["review-overdue"]), config: cfg, now: new Date("2026-10-05T20:00:00Z") }).state, "overdue");
|
||||
assert.equal(planEscalation({ pr: wednesday(["review-overdue"]), config: cfg, now: new Date("2026-10-06T14:00:00Z") }).action, "escalate");
|
||||
});
|
||||
|
||||
test("escalates to the core pool four business days after routing", () => {
|
||||
const plan = planEscalation({ pr: routed({ labels: ["review-overdue"] }), config: config(), now: new Date("2026-10-02T14:00:00Z") });
|
||||
assert.equal(plan.action, "escalate");
|
||||
assert.equal(plan.escalateOn, "2026-10-02");
|
||||
assert.equal(plan.reviewers.length, 1);
|
||||
assert.ok(["core1", "core2"].includes(plan.reviewers[0]));
|
||||
assert.deepEqual(plan.teamReviewers, ["core"]);
|
||||
assert.deepEqual(plan.addLabels, ["review-escalated"]);
|
||||
assert.ok(plan.comment.startsWith(COMMENT_MARKERS.escalated));
|
||||
});
|
||||
|
||||
test("adds both labels when overdue was skipped, and does not repeat escalation", () => {
|
||||
const cfg = config();
|
||||
assert.deepEqual(planEscalation({ pr: routed(), config: cfg, now: new Date("2026-10-07T14:00:00Z") }).addLabels, ["review-escalated", "review-overdue"]);
|
||||
assert.equal(planEscalation({ pr: routed({ labels: ["review-escalated"] }), config: cfg, now: new Date("2026-10-07T14:00:00Z") }).state, "escalated");
|
||||
});
|
||||
|
||||
test("clears SLA labels once a human review arrives after routing", () => {
|
||||
const plan = planEscalation({
|
||||
pr: routed({ labels: ["review-overdue"] }),
|
||||
reviews: [review("canvas1", "2026-10-01T09:00:00Z", "APPROVED")],
|
||||
config: config(),
|
||||
now: new Date("2026-10-02T14:00:00Z"),
|
||||
});
|
||||
assert.equal(plan.action, "reviewed");
|
||||
assert.deepEqual(plan.removeLabels, ["review-due:2026-09-30", "review-overdue"]);
|
||||
});
|
||||
|
||||
test("uses the exact routing time so earlier same-day reviews do not count", () => {
|
||||
const args = { pr: routed(), config: config(), now: new Date("2026-10-01T14:00:00Z"), routedAt: "2026-09-28T15:00:00Z" };
|
||||
assert.equal(planEscalation({ ...args, reviews: [review("canvas1", "2026-09-28T10:00:00Z")] }).action, "overdue");
|
||||
assert.equal(planEscalation({ ...args, reviews: [review("canvas1", "2026-09-28T16:00:00Z")] }).action, "reviewed");
|
||||
});
|
||||
|
||||
test("comments show the actual routing date when routing happened on a non-business day", () => {
|
||||
// Routed Saturday 2026-09-26: due Tuesday 2026-09-29, overdue Wednesday.
|
||||
const plan = planEscalation({ pr: routed({ due: "2026-09-29" }), config: config(), now: new Date("2026-09-30T14:00:00Z"), routedAt: "2026-09-26T12:00:00Z" });
|
||||
assert.equal(plan.action, "overdue");
|
||||
assert.match(plan.comment, /pool on 2026-09-26;/);
|
||||
});
|
||||
|
||||
test("ignores unrouted PRs", () => {
|
||||
assert.equal(planEscalation({ pr: pr({ labels: ["skills"] }), config: config(), now: MONDAY }).reason, "not-routed");
|
||||
});
|
||||
});
|
||||
|
||||
function fakeGithub({ pulls = [], reviews = {}, events = {}, repoLabels = [], failRequest = false } = {}) {
|
||||
const calls = [];
|
||||
const record = (name) => async (params) => {
|
||||
calls.push({ name, params });
|
||||
return { data: {} };
|
||||
};
|
||||
const rest = {
|
||||
pulls: {
|
||||
list: Symbol("pulls.list"),
|
||||
listReviews: Symbol("pulls.listReviews"),
|
||||
get: async ({ pull_number }) => ({ data: pulls.find((pull) => pull.number === pull_number) }),
|
||||
requestReviewers: async (params) => {
|
||||
calls.push({ name: "requestReviewers", params });
|
||||
if (failRequest) throw Object.assign(new Error("Reviews may only be requested from collaborators"), { status: 422 });
|
||||
return { data: {} };
|
||||
},
|
||||
},
|
||||
issues: {
|
||||
listEvents: Symbol("issues.listEvents"),
|
||||
listLabelsForRepo: Symbol("issues.listLabelsForRepo"),
|
||||
addLabels: record("addLabels"),
|
||||
removeLabel: record("removeLabel"),
|
||||
createLabel: record("createLabel"),
|
||||
createComment: record("createComment"),
|
||||
deleteLabel: record("deleteLabel"),
|
||||
},
|
||||
};
|
||||
return {
|
||||
calls,
|
||||
rest,
|
||||
async paginate(method, params) {
|
||||
if (method === rest.pulls.list) return params.head ? pulls.filter((pull) => `${pull.head?.repo?.full_name.split("/")[0]}:${pull.head?.ref}` === params.head) : pulls;
|
||||
if (method === rest.pulls.listReviews) return reviews[params.pull_number] ?? [];
|
||||
if (method === rest.issues.listEvents) return events[params.issue_number] ?? [];
|
||||
if (method === rest.issues.listLabelsForRepo) return repoLabels.map((name) => ({ name }));
|
||||
throw new Error("unexpected paginate call");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const writeCalls = (github) => github.calls.map((call) => call.name);
|
||||
|
||||
describe("GitHub runners", () => {
|
||||
test("planRoutingSweep is read-only and returns route plans", async () => {
|
||||
const pulls = [pr({ number: 1, labels: ["canvas-extension"] }), pr({ number: 2, labels: ["review-due:2026-09-30"], requested: ["core1"] }), pr({ number: 3, draft: true })];
|
||||
const github = fakeGithub({ pulls });
|
||||
const plans = await planRoutingSweep({ github, owner: "o", repo: "r", config: config(), now: MONDAY });
|
||||
assert.deepEqual(plans.map((plan) => [plan.prNumber, plan.action]), [[1, "route"], [2, "skip"]]);
|
||||
assert.deepEqual(github.calls, []);
|
||||
});
|
||||
|
||||
test("routePullRequest requests, creates the due label, and respects dry run", async () => {
|
||||
const pulls = [pr({ number: 1, labels: ["canvas-extension"] })];
|
||||
const github = fakeGithub({ pulls });
|
||||
const plan = await routePullRequest({ github, owner: "o", repo: "r", config: config(), prNumber: 1, now: MONDAY });
|
||||
assert.equal(plan.action, "route");
|
||||
assert.deepEqual(writeCalls(github), ["requestReviewers", "createLabel", "addLabels"]);
|
||||
|
||||
const dry = fakeGithub({ pulls });
|
||||
await routePullRequest({ github: dry, owner: "o", repo: "r", config: config({ dry_run: true }), prNumber: 1, now: MONDAY });
|
||||
assert.deepEqual(dry.calls, []);
|
||||
});
|
||||
|
||||
test("a failed reviewer request leaves labels untouched so the next run retries", async () => {
|
||||
const pulls = [pr({ number: 1, labels: ["canvas-extension", "needs-reviewer"] })];
|
||||
const github = fakeGithub({ pulls, failRequest: true });
|
||||
const plan = await routePullRequest({ github, owner: "o", repo: "r", config: config(), prNumber: 1, now: MONDAY });
|
||||
assert.match(plan.requestError, /collaborators/);
|
||||
assert.deepEqual(writeCalls(github), ["requestReviewers"]);
|
||||
});
|
||||
|
||||
test("routePullRequest skips when the PR head moved after the intent artifact", async () => {
|
||||
const pulls = [{ ...pr({ number: 1 }), head: { sha: "b".repeat(40) } }];
|
||||
const github = fakeGithub({ pulls });
|
||||
const plan = await routePullRequest({ github, owner: "o", repo: "r", config: config(), prNumber: 1, intentLabels: ["skills"], expectedHeadSha: "a".repeat(40) });
|
||||
assert.equal(plan.reason, "stale-head");
|
||||
assert.deepEqual(github.calls, []);
|
||||
});
|
||||
|
||||
test("escalation uses the due label's labeled event as the review boundary", async () => {
|
||||
const pulls = [pr({ number: 7, labels: ["plugin", "review-due:2026-09-30"], requested: ["plugin1"] })];
|
||||
const events = { 7: [{ event: "labeled", label: { name: "review-due:2026-09-30" }, created_at: "2026-09-28T15:00:00Z" }] };
|
||||
const reviews = { 7: [review("plugin1", "2026-09-28T10:00:00Z")] };
|
||||
const now = new Date("2026-10-01T14:00:00Z");
|
||||
|
||||
const plans = await planEscalationSweep({ github: fakeGithub({ pulls, events, reviews }), owner: "o", repo: "r", config: config(), now });
|
||||
assert.equal(plans[0].action, "overdue");
|
||||
assert.equal(plans[0].routedAt, "2026-09-28T15:00:00Z");
|
||||
|
||||
const github = fakeGithub({ pulls, events, reviews });
|
||||
const plan = await escalatePullRequest({ github, owner: "o", repo: "r", config: config(), prNumber: 7, now });
|
||||
assert.equal(plan.action, "overdue");
|
||||
assert.ok(github.calls.some((call) => call.name === "addLabels" && call.params.labels.includes("review-overdue")));
|
||||
assert.ok(github.calls.some((call) => call.name === "createComment"));
|
||||
});
|
||||
|
||||
test("a failed escalation request does not add labels or comments", async () => {
|
||||
const pulls = [pr({ number: 7, labels: ["plugin", "review-due:2026-09-30"], requested: ["plugin1"] })];
|
||||
const github = fakeGithub({ pulls, failRequest: true });
|
||||
const plan = await escalatePullRequest({ github, owner: "o", repo: "r", config: config(), prNumber: 7, now: new Date("2026-10-02T14:00:00Z") });
|
||||
assert.equal(plan.action, "escalate");
|
||||
assert.ok(plan.requestError);
|
||||
assert.deepEqual(writeCalls(github), ["requestReviewers"]);
|
||||
});
|
||||
|
||||
test("cleanupStaleDueLabels deletes only old, unused due labels", async () => {
|
||||
const pulls = [pr({ number: 7, labels: ["review-due:2026-09-01"] })];
|
||||
const github = fakeGithub({ pulls, repoLabels: ["review-due:2026-09-01", "review-due:2026-09-02", "review-due:2026-09-30", "plugin"] });
|
||||
const deleted = await cleanupStaleDueLabels({ github, owner: "o", repo: "r", config: config(), now: new Date("2026-10-01T14:00:00Z") });
|
||||
assert.deepEqual(deleted, ["review-due:2026-09-02"]);
|
||||
assert.deepEqual(writeCalls(github), ["deleteLabel"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveWorkflowRunArtifact", () => {
|
||||
const sha = "a".repeat(40);
|
||||
const openPr = {
|
||||
number: 5,
|
||||
state: "open",
|
||||
base: { repo: { full_name: "o/r" } },
|
||||
head: { sha, ref: "feature", repo: { full_name: "fork/r" } },
|
||||
};
|
||||
const workflowRun = { id: 99, event: "pull_request", head_sha: sha, head_branch: "feature", head_repository: { full_name: "fork/r" }, pull_requests: [] };
|
||||
|
||||
test("accepts a matching intent artifact and returns authoritative intent labels", async () => {
|
||||
const github = fakeGithub({ pulls: [openPr] });
|
||||
const result = await resolveWorkflowRunArtifact({
|
||||
github,
|
||||
owner: "o",
|
||||
repo: "r",
|
||||
workflowRun,
|
||||
artifact: { schema_version: "label-pr-intent-result/v1", event: "pull_request", pr_number: 5, head_sha: sha, run_id: "99", desired_labels: ["skills"], managed_labels: [] },
|
||||
});
|
||||
assert.deepEqual(result, { prNumber: 5, headSha: sha, intentLabels: ["skills"] });
|
||||
});
|
||||
|
||||
test("rejects forged or mismatched artifacts; request artifacts carry no intent labels", async () => {
|
||||
const github = fakeGithub({ pulls: [openPr] });
|
||||
const base = { schema_version: "review-routing-request/v1", event: "pull_request", pr_number: 5, head_sha: sha, run_id: "99" };
|
||||
await assert.rejects(resolveWorkflowRunArtifact({ github, owner: "o", repo: "r", workflowRun, artifact: { ...base, run_id: "1" } }), /run_id/);
|
||||
await assert.rejects(
|
||||
resolveWorkflowRunArtifact({ github, owner: "o", repo: "r", workflowRun, artifact: { ...base, schema_version: "label-pr-intent-result/v1", desired_labels: ["approved"] } }),
|
||||
/unexpected desired label/
|
||||
);
|
||||
await assert.rejects(resolveWorkflowRunArtifact({ github, owner: "o", repo: "r", workflowRun: { ...workflowRun, head_branch: "other" }, artifact: base }), /head did not match/);
|
||||
assert.deepEqual(await resolveWorkflowRunArtifact({ github, owner: "o", repo: "r", workflowRun, artifact: base }), { prNumber: 5, headSha: sha, intentLabels: null });
|
||||
});
|
||||
});
|
||||
|
||||
test("formatResultsSummary renders a table", () => {
|
||||
const summary = formatResultsSummary(
|
||||
[{ prNumber: 1, action: "route", pool: "canvas", reviewers: ["a"], teamReviewers: ["t"], addLabels: ["x"], removeLabels: [], source: "pool", requestError: "boom" }],
|
||||
{ title: "Review routing", dryRun: true }
|
||||
);
|
||||
assert.match(summary, /Review routing \(dry run\)/);
|
||||
assert.match(summary, /\| #1 \| route \| canvas \| a, team:t \| x \|/);
|
||||
assert.match(summary, /request failed: boom/);
|
||||
});
|
||||
Reference in New Issue
Block a user