// 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", "workshops", "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: "", 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 "/" 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); } }