mirror of
https://github.com/github/awesome-copilot.git
synced 2026-10-12 23:04:14 +00:00
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: f280c771-d797-4eb1-a96f-ada49c076e86
1362 lines
58 KiB
JavaScript
1362 lines
58 KiB
JavaScript
#!/usr/bin/env node
|
|
// Submission gate: aggregate check evaluation, merge-risk tiers, approval policy,
|
|
// PR state machine, status comment rendering, and PR commands.
|
|
// Used by .github/workflows/submission-gate*.yml and pr-commands.yml.
|
|
// See docs/maintainers/submission-gate.md.
|
|
|
|
import { execFileSync } from "node:child_process";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import * as yaml from "js-yaml";
|
|
|
|
export const GATE_CHECK_NAME = "submission-gate";
|
|
// external_id stamped on the check runs the trusted writer publishes.
|
|
export const GATE_CHECK_EXTERNAL_ID = "submission-gate-writer";
|
|
const CONTRIBUTOR_RESULT_JOB = "pr-check";
|
|
export const GATE_WORKFLOW_FILE = "submission-gate.yml";
|
|
export const STATUS_MARKER = "<!-- submission-gate-status -->";
|
|
export const RISK_TIERS = ["low", "medium", "high"];
|
|
export const RISK_LABELS = RISK_TIERS.map((tier) => `merge-risk:${tier}`);
|
|
export const STATE_LABELS = [
|
|
"awaiting-automation",
|
|
"requires-submitter-fixes",
|
|
"ready-for-review",
|
|
"review-in-progress",
|
|
"approved",
|
|
];
|
|
export const PR_EVENTS = new Set(["pull_request", "pull_request_review"]);
|
|
// PRs carrying these labels have their state labels managed by another workflow.
|
|
export const STATE_LABEL_OWNERS = ["external-plugin"];
|
|
|
|
const WRITE_PERMISSIONS = new Set(["admin", "maintain", "write"]);
|
|
const MAINTAINER_PERMISSIONS = new Set(["admin", "maintain"]);
|
|
const RERUNNABLE_CONCLUSIONS = new Set(["failure", "cancelled", "timed_out", "startup_failure"]);
|
|
|
|
const STATE_DISPLAY = {
|
|
"awaiting-automation": { icon: "⏳", text: "Awaiting automation" },
|
|
"requires-submitter-fixes": { icon: "🛠️", text: "Requires submitter fixes" },
|
|
"ready-for-review": { icon: "👀", text: "Ready for review" },
|
|
"review-in-progress": { icon: "💬", text: "Review in progress" },
|
|
approved: { icon: "✅", text: "Approved" },
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Configuration and path matching
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Convert a GitHub Actions style path glob to a RegExp. */
|
|
export function globToRegExp(glob) {
|
|
let source = "";
|
|
for (let i = 0; i < glob.length; i += 1) {
|
|
const char = glob[i];
|
|
if (char === "*") {
|
|
if (glob[i + 1] === "*") {
|
|
if (glob[i + 2] === "/") {
|
|
source += "(?:.*/)?";
|
|
i += 2;
|
|
} else {
|
|
source += ".*";
|
|
i += 1;
|
|
}
|
|
} else {
|
|
source += "[^/]*";
|
|
}
|
|
} else if (char === "?") {
|
|
source += "[^/]";
|
|
} else {
|
|
source += char.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
}
|
|
}
|
|
return new RegExp(`^${source}$`);
|
|
}
|
|
|
|
const globCache = new Map();
|
|
export function matchesAny(filePath, globs = []) {
|
|
return globs.some((glob) => {
|
|
if (!globCache.has(glob)) globCache.set(glob, globToRegExp(glob));
|
|
return globCache.get(glob).test(filePath);
|
|
});
|
|
}
|
|
|
|
function readYamlIfExists(filePath) {
|
|
if (!fs.existsSync(filePath)) return null;
|
|
return yaml.load(fs.readFileSync(filePath, "utf8"));
|
|
}
|
|
|
|
/** Load the gate, risk-tier, and (optional) review-routing configuration. */
|
|
export function loadGateConfig(rootDir = process.cwd()) {
|
|
const gate = readYamlIfExists(path.join(rootDir, ".github", "submission-gate.yml"));
|
|
const tiers = readYamlIfExists(path.join(rootDir, ".github", "risk-tiers.yml"));
|
|
if (!gate || !Array.isArray(gate.checks)) throw new Error(".github/submission-gate.yml is missing or has no checks");
|
|
if (!tiers || !tiers.high || !tiers.medium || !tiers.low) throw new Error(".github/risk-tiers.yml is missing a tier");
|
|
|
|
// A missing routing file is a supported fallback. A file that exists but can't be parsed must
|
|
// fail closed: treating it as missing would relax domain/owner approval requirements.
|
|
let routing;
|
|
try {
|
|
routing = readYamlIfExists(path.join(rootDir, ".github", "review-routing.yml"));
|
|
} catch (error) {
|
|
throw new Error(`.github/review-routing.yml could not be parsed: ${error.message}`);
|
|
}
|
|
if (routing !== null && routing !== undefined && (typeof routing !== "object" || Array.isArray(routing))) {
|
|
throw new Error(".github/review-routing.yml must be a mapping");
|
|
}
|
|
if (routing?.pools !== undefined && (typeof routing.pools !== "object" || routing.pools === null || Array.isArray(routing.pools))) {
|
|
throw new Error(".github/review-routing.yml `pools` must be a mapping");
|
|
}
|
|
return { gate, tiers, routing: routing ?? null };
|
|
}
|
|
|
|
function fileNames(files) {
|
|
const names = [];
|
|
for (const file of files) {
|
|
names.push(file.filename);
|
|
if (file.previous_filename) names.push(file.previous_filename);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
/** Checks that apply to this PR, mirroring each workflow's branch and path filters. */
|
|
export function selectApplicableChecks(checks, files, baseRef) {
|
|
const names = fileNames(files);
|
|
return checks.filter((check) => {
|
|
const branches = check.branches;
|
|
if (Array.isArray(branches) && branches.length > 0 && !branches.includes("*") && !branches.includes(baseRef)) {
|
|
return false;
|
|
}
|
|
if (Array.isArray(check.paths) && check.paths.length > 0) {
|
|
return names.some((name) => matchesAny(name, check.paths));
|
|
}
|
|
return true;
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Risk tiers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function addedLines(patch) {
|
|
if (typeof patch !== "string") return [];
|
|
return patch
|
|
.split("\n")
|
|
.filter((line) => line.startsWith("+") && !line.startsWith("+++"))
|
|
.map((line) => line.slice(1));
|
|
}
|
|
|
|
/**
|
|
* Classify a PR into exactly one merge-risk tier.
|
|
* `incompleteFiles` marks a changed-file list GitHub truncated; that fails closed to high.
|
|
* @returns {{tier: 'low'|'medium'|'high', reasons: string[]}}
|
|
*/
|
|
export function classifyRisk({ files, labels = [], contributorRisk = null, tiers, incompleteFiles = false }) {
|
|
const high = tiers.high || {};
|
|
const low = tiers.low || {};
|
|
const reasons = [];
|
|
|
|
if (incompleteFiles) reasons.push("GitHub did not return the complete list of changed files, so the PR is treated as high risk");
|
|
|
|
for (const file of files) {
|
|
for (const name of fileNames([file])) {
|
|
if (matchesAny(name, high.exclude_paths || [])) continue;
|
|
if (matchesAny(name, high.paths || [])) {
|
|
reasons.push(`\`${name}\` is a high-risk path (automation, scripts, MCP config, hooks, or review policy)`);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// GitHub omits `patch` for binary files and very large diffs. A text file whose added
|
|
// lines can't be scanned fails closed instead of skipping the capability triggers.
|
|
const capabilities = high.capabilities || [];
|
|
const unscannable = files.filter(
|
|
(file) =>
|
|
file.status !== "removed" &&
|
|
typeof file.patch !== "string" &&
|
|
Number(file.additions ?? 1) > 0 &&
|
|
!matchesAny(file.filename, high.unscanned_paths || []) &&
|
|
capabilities.some((capability) => !Array.isArray(capability.files) || matchesAny(file.filename, capability.files))
|
|
);
|
|
for (const file of unscannable) {
|
|
reasons.push(`\`${file.filename}\` has no diff available to scan for privileged capabilities`);
|
|
}
|
|
|
|
for (const capability of capabilities) {
|
|
const pattern = new RegExp(capability.pattern);
|
|
for (const file of files) {
|
|
if (Array.isArray(capability.files) && !matchesAny(file.filename, capability.files)) continue;
|
|
if (addedLines(file.patch).some((line) => pattern.test(line))) {
|
|
reasons.push(`${capability.description} in \`${file.filename}\``);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (const label of high.labels || []) {
|
|
if (labels.includes(label)) reasons.push(`Label \`${label}\` flags a high contributor-risk signal`);
|
|
}
|
|
if (String(contributorRisk || "").toUpperCase() === "HIGH" && !reasons.some((r) => r.includes("contributor-risk"))) {
|
|
reasons.push("Contributor reputation check reported a high contributor-risk signal");
|
|
}
|
|
|
|
if (reasons.length > 0) return { tier: "high", reasons: [...new Set(reasons)] };
|
|
|
|
const names = fileNames(files);
|
|
if (names.every((name) => matchesAny(name, low.paths || []))) {
|
|
return { tier: "low", reasons: ["Only documentation, metadata, or generated files changed"] };
|
|
}
|
|
|
|
const small = low.small_update || {};
|
|
const maxLines = Number(small.max_changed_lines ?? 0);
|
|
const totalChanges = files.reduce((sum, file) => sum + Number(file.changes ?? (file.additions || 0) + (file.deletions || 0)), 0);
|
|
const onlyModifiesResources = files.every(
|
|
(file) =>
|
|
file.status === "modified" &&
|
|
(matchesAny(file.filename, low.paths || []) || matchesAny(file.filename, small.resource_paths || []))
|
|
);
|
|
if (onlyModifiesResources && totalChanges <= maxLines) {
|
|
return {
|
|
tier: "low",
|
|
reasons: [`Small update (${totalChanges} changed lines) to existing resources with no added or removed files`],
|
|
};
|
|
}
|
|
|
|
const mediumReasons = [];
|
|
const added = files.filter((file) => file.status === "added").map((file) => file.filename);
|
|
if (added.length > 0) mediumReasons.push(`Adds ${added.length} file(s), e.g. \`${added[0]}\``);
|
|
if (files.some((file) => ["removed", "renamed"].includes(file.status))) mediumReasons.push("Removes or renames files");
|
|
if (totalChanges > maxLines) mediumReasons.push(`Changes ${totalChanges} lines (low-risk limit is ${maxLines})`);
|
|
if (mediumReasons.length === 0) mediumReasons.push("Changes files outside the low-risk documentation and resource paths");
|
|
return { tier: "medium", reasons: mediumReasons };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Approvals
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Normalize review-routing.yml into a Map of pool key -> Set of lowercase logins. */
|
|
export function normalizeRouting(routing) {
|
|
const pools = new Map();
|
|
const source = routing?.pools || routing?.teams || {};
|
|
if (!source || typeof source !== "object") return pools;
|
|
for (const [key, value] of Object.entries(source)) {
|
|
const logins = new Set();
|
|
const add = (list) => {
|
|
if (!Array.isArray(list)) return;
|
|
for (const entry of list) {
|
|
const login = typeof entry === "string" ? entry : entry?.login;
|
|
if (typeof login === "string" && login.trim()) logins.add(login.trim().replace(/^@/, "").toLowerCase());
|
|
}
|
|
};
|
|
if (Array.isArray(value)) add(value);
|
|
else {
|
|
add(value?.reviewers);
|
|
add(value?.members);
|
|
add(value?.backup);
|
|
}
|
|
pools.set(key, logins);
|
|
}
|
|
return pools;
|
|
}
|
|
|
|
function unionPools(pools, keys) {
|
|
const union = new Set();
|
|
for (const key of keys) for (const login of pools.get(key) || []) union.add(login);
|
|
return union;
|
|
}
|
|
|
|
/** Domain areas touched by the PR (used to pick the domain reviewer pool). */
|
|
export function touchedDomains(files, domains = {}) {
|
|
const names = fileNames(files);
|
|
return Object.entries(domains)
|
|
.filter(([, domain]) => names.some((name) => matchesAny(name, domain.paths || [])))
|
|
.map(([id, domain]) => ({ id, pools: domain.pools || [] }));
|
|
}
|
|
|
|
/**
|
|
* Evaluate reviews against the approval policy of a tier.
|
|
* `permissions` maps lowercase login -> repository permission (admin|maintain|write|triage|read|none).
|
|
*/
|
|
export function evaluateApprovals({ tier, tiers, reviews = [], author, permissions = new Map(), routing = null, files = [] }) {
|
|
const policy = tiers[tier]?.approvals || {};
|
|
const required = Number(policy.required || 1);
|
|
const pools = normalizeRouting(routing);
|
|
const allPoolMembers = unionPools(pools, [...pools.keys()]);
|
|
const authorLogin = String(author || "").toLowerCase();
|
|
|
|
const qualified = (login) => WRITE_PERMISSIONS.has(permissions.get(login)) || allPoolMembers.has(login);
|
|
|
|
const latest = new Map();
|
|
const reviewed = new Set();
|
|
const sorted = [...reviews].sort((a, b) => new Date(a.submitted_at || 0) - new Date(b.submitted_at || 0));
|
|
for (const review of sorted) {
|
|
const login = String(review.user?.login || "").toLowerCase();
|
|
if (!login || login === authorLogin || review.user?.type === "Bot") continue;
|
|
if (review.state === "PENDING") continue;
|
|
reviewed.add(login);
|
|
if (["APPROVED", "CHANGES_REQUESTED", "DISMISSED"].includes(review.state)) latest.set(login, review.state);
|
|
}
|
|
|
|
const approvers = [...latest].filter(([login, state]) => state === "APPROVED" && qualified(login)).map(([login]) => login);
|
|
const changesRequestedBy = [...latest]
|
|
.filter(([login, state]) => state === "CHANGES_REQUESTED" && qualified(login))
|
|
.map(([login]) => login);
|
|
const reviewers = [...reviewed].filter(qualified);
|
|
|
|
const missing = [];
|
|
const notes = [];
|
|
const requirements = [`${required} approval${required === 1 ? "" : "s"} from reviewers with write access`];
|
|
if (approvers.length < required) missing.push(`${required - approvers.length} more approval(s)`);
|
|
|
|
const coreMembers = unionPools(pools, tiers.core_pools || []);
|
|
if (policy.require_domain || policy.require_owner) {
|
|
const kind = policy.require_owner ? "resource owner" : "domain reviewer";
|
|
const touched = touchedDomains(files, tiers.domains).flatMap((domain) => domain.pools);
|
|
// Files outside every domain (docs, metadata) are owned by the core pools.
|
|
const ownsUntouched = fileNames(files).some(
|
|
(name) => !Object.values(tiers.domains || {}).some((domain) => matchesAny(name, domain.paths || []))
|
|
);
|
|
const domainPoolKeys = [...new Set([...touched, ...(ownsUntouched ? tiers.core_pools || [] : [])])];
|
|
const domainMembers = unionPools(pools, domainPoolKeys);
|
|
if (domainMembers.size > 0) {
|
|
requirements.push(`including a ${kind} (${domainPoolKeys.join(", ")})`);
|
|
if (!approvers.some((login) => domainMembers.has(login) || coreMembers.has(login))) {
|
|
missing.push(`an approval from a ${kind} (${domainPoolKeys.join("/")} reviewer pool)`);
|
|
}
|
|
} else {
|
|
notes.push(`No staffed reviewer pool owns these files yet; any reviewer with write access counts as the ${kind}.`);
|
|
}
|
|
}
|
|
|
|
if (policy.require_core) {
|
|
if (coreMembers.size > 0) {
|
|
requirements.push("including a core maintainer");
|
|
if (!approvers.some((login) => coreMembers.has(login))) missing.push("an approval from a core maintainer");
|
|
} else {
|
|
requirements.push("including a maintainer with admin or maintain permission");
|
|
notes.push("The core-maintainers pool is not staffed yet; an approver with admin or maintain permission is required instead.");
|
|
if (!approvers.some((login) => MAINTAINER_PERMISSIONS.has(permissions.get(login)))) {
|
|
missing.push("an approval from a maintainer with admin or maintain permission");
|
|
}
|
|
}
|
|
}
|
|
|
|
if (changesRequestedBy.length > 0) missing.push(`resolution of changes requested by ${changesRequestedBy.join(", ")}`);
|
|
|
|
return {
|
|
tier,
|
|
required,
|
|
requirement: requirements.join(", "),
|
|
approvers,
|
|
changesRequestedBy,
|
|
reviewers,
|
|
missing,
|
|
notes,
|
|
satisfied: missing.length === 0,
|
|
};
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Check evaluation
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function toRegExps(patterns = []) {
|
|
return patterns.map((pattern) => new RegExp(pattern));
|
|
}
|
|
|
|
/** Decide whether a failed workflow run is an infrastructure or a contribution failure. */
|
|
export function classifyFailure(check, jobs, infrastructureSteps = []) {
|
|
if (check.failure_kind === "infrastructure") {
|
|
return { category: "infrastructure", detail: "The workflow did not complete successfully" };
|
|
}
|
|
if (!Array.isArray(jobs)) {
|
|
return { category: "contribution", detail: "The check reported a failure" };
|
|
}
|
|
const contributionPatterns = toRegExps(check.contribution_steps);
|
|
const infraPatterns = toRegExps(infrastructureSteps);
|
|
const failedJobs = jobs.filter((job) => ["failure", "timed_out", "cancelled", "startup_failure"].includes(job.conclusion));
|
|
if (failedJobs.length === 0) {
|
|
return { category: "infrastructure", detail: "The workflow failed before any job ran" };
|
|
}
|
|
|
|
let infraDetail = null;
|
|
for (const job of failedJobs) {
|
|
if (job.conclusion !== "failure") {
|
|
infraDetail ??= `Job \`${job.name}\` ${job.conclusion.replace("_", " ")}`;
|
|
continue;
|
|
}
|
|
const step = (job.steps || []).find((candidate) => candidate.conclusion === "failure");
|
|
if (!step) {
|
|
infraDetail ??= `Job \`${job.name}\` failed outside of a step`;
|
|
continue;
|
|
}
|
|
if (contributionPatterns.some((pattern) => pattern.test(step.name))) {
|
|
return { category: "contribution", detail: `Failed step: \`${step.name}\`` };
|
|
}
|
|
if (infraPatterns.some((pattern) => pattern.test(step.name))) {
|
|
infraDetail ??= `Failed step: \`${step.name}\``;
|
|
continue;
|
|
}
|
|
return { category: "contribution", detail: `Failed step: \`${step.name}\`` };
|
|
}
|
|
return { category: "infrastructure", detail: infraDetail || "The workflow did not complete successfully" };
|
|
}
|
|
|
|
/**
|
|
* Evaluate one applicable check.
|
|
* `observation` is {found, status, conclusion, url, jobs}. `gaveUp` means the gate stopped waiting.
|
|
*/
|
|
export function evaluateCheck(check, observation, { gaveUp = false, infrastructureSteps = [] } = {}) {
|
|
const base = {
|
|
id: check.id,
|
|
title: check.title || check.id,
|
|
required: check.required !== false,
|
|
url: observation?.url || null,
|
|
hint: check.hint || null,
|
|
category: null,
|
|
};
|
|
|
|
if (!observation || !observation.found) {
|
|
if (!gaveUp) return { ...base, outcome: "pending", detail: "Waiting for the check to start" };
|
|
if (check.optional) return { ...base, outcome: "skipped", detail: "Not reported for this commit" };
|
|
return {
|
|
...base,
|
|
outcome: "failure",
|
|
category: "infrastructure",
|
|
detail: "The check did not report a result for this commit",
|
|
};
|
|
}
|
|
|
|
if (observation.status !== "completed") {
|
|
if (!gaveUp) return { ...base, outcome: "pending", detail: observation.status === "queued" ? "Queued" : "Running" };
|
|
return { ...base, outcome: "failure", category: "infrastructure", detail: "Did not finish before the gate stopped waiting" };
|
|
}
|
|
|
|
switch (observation.conclusion) {
|
|
case "success":
|
|
case "neutral":
|
|
return { ...base, outcome: "pass", detail: "Passed" };
|
|
case "skipped":
|
|
if (check.optional || check.allow_skip || check.required === false) {
|
|
return { ...base, outcome: "skipped", detail: "Skipped by its workflow" };
|
|
}
|
|
return {
|
|
...base,
|
|
outcome: "failure",
|
|
category: "infrastructure",
|
|
detail: "Skipped by its workflow, so it never validated this commit",
|
|
};
|
|
case "action_required":
|
|
return {
|
|
...base,
|
|
outcome: "failure",
|
|
category: "infrastructure",
|
|
detail: "Waiting for a maintainer to approve workflow runs for this PR",
|
|
};
|
|
case "cancelled":
|
|
case "timed_out":
|
|
case "startup_failure":
|
|
case "stale":
|
|
return {
|
|
...base,
|
|
outcome: "failure",
|
|
category: "infrastructure",
|
|
detail: `Workflow ${String(observation.conclusion).replace("_", " ")}`,
|
|
};
|
|
case "failure": {
|
|
const { category, detail } = classifyFailure(check, observation.jobs, infrastructureSteps);
|
|
return { ...base, outcome: "failure", category, detail };
|
|
}
|
|
default:
|
|
return {
|
|
...base,
|
|
outcome: "failure",
|
|
category: "infrastructure",
|
|
detail: `Unexpected conclusion \`${observation.conclusion}\``,
|
|
};
|
|
}
|
|
}
|
|
|
|
/** Group check results into gate-relevant buckets. */
|
|
export function summarizeChecks(results) {
|
|
const failures = results.filter((result) => result.outcome === "failure");
|
|
return {
|
|
results,
|
|
passed: results.filter((result) => result.outcome === "pass" || result.outcome === "skipped"),
|
|
// Only required checks hold the gate; advisory (required: false) checks never block.
|
|
pending: results.filter((result) => result.outcome === "pending" && result.required),
|
|
advisoryPending: results.filter((result) => result.outcome === "pending" && !result.required),
|
|
contributionFailures: failures.filter((result) => result.required && result.category === "contribution"),
|
|
infrastructureFailures: failures.filter((result) => result.required && result.category === "infrastructure"),
|
|
warnings: failures.filter((result) => !result.required),
|
|
};
|
|
}
|
|
|
|
/** PR state machine. Returns one of STATE_LABELS. */
|
|
export function computeState({ automation, approvals }) {
|
|
if (automation.contributionFailures.length > 0 || approvals.changesRequestedBy.length > 0) {
|
|
return "requires-submitter-fixes";
|
|
}
|
|
if (automation.pending.length > 0 || automation.infrastructureFailures.length > 0) return "awaiting-automation";
|
|
if (approvals.satisfied) return "approved";
|
|
if (approvals.reviewers.length > 0) return "review-in-progress";
|
|
return "ready-for-review";
|
|
}
|
|
|
|
/** Human-readable reasons the gate is not passing. */
|
|
export function gateFailureSummary(evaluation) {
|
|
const lines = [];
|
|
const { automation, approvals } = evaluation;
|
|
if (automation.contributionFailures.length > 0) {
|
|
lines.push(`Contribution failures: ${automation.contributionFailures.map((r) => r.title).join(", ")}`);
|
|
}
|
|
if (automation.infrastructureFailures.length > 0) {
|
|
lines.push(
|
|
`Infrastructure failures (not caused by the contribution; comment /rerun-checks): ${automation.infrastructureFailures
|
|
.map((r) => r.title)
|
|
.join(", ")}`
|
|
);
|
|
}
|
|
if (automation.pending.length > 0) lines.push(`Still pending: ${automation.pending.map((r) => r.title).join(", ")}`);
|
|
if (!approvals.satisfied) lines.push(`Waiting on review (merge-risk:${evaluation.risk.tier}): needs ${approvals.missing.join("; ")}`);
|
|
return lines;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Rendering
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/** Neutralize untrusted text for inclusion in markdown (mentions, tables, HTML). */
|
|
export function sanitize(text, maxLength = 300) {
|
|
let value = String(text ?? "")
|
|
.replace(/@/g, "@\u200b")
|
|
.replace(/[<>]/g, (char) => (char === "<" ? "<" : ">"))
|
|
.replace(/\|/g, "\\|")
|
|
.replace(/\r?\n/g, " ");
|
|
if (value.length > maxLength) value = `${value.slice(0, maxLength - 1)}…`;
|
|
return value;
|
|
}
|
|
|
|
function outcomeCell(result) {
|
|
if (result.outcome === "pass") return "✅ Passed";
|
|
if (result.outcome === "skipped") return "⏭️ Skipped";
|
|
if (result.outcome === "pending") return result.required ? "⏳ Pending" : "⏳ Pending (advisory, non-blocking)";
|
|
if (!result.required) return "⚠️ Failed (advisory, non-blocking)";
|
|
return result.category === "contribution" ? "❌ Contribution failure" : "🔧 Infrastructure failure";
|
|
}
|
|
|
|
export function renderStatusComment(evaluation, { gateRunUrl = null } = {}) {
|
|
const { state, risk, automation, approvals, reviewAssignment, headSha } = evaluation;
|
|
const display = STATE_DISPLAY[state];
|
|
const tierDescription = evaluation.tierDescription || "";
|
|
const lines = [
|
|
STATUS_MARKER,
|
|
`## 🚦 Submission status: ${display.icon} ${display.text}`,
|
|
"",
|
|
`**Risk tier:** \`merge-risk:${risk.tier}\`${tierDescription ? ` — ${tierDescription}` : ""}`,
|
|
`**Required to merge:** passing \`${GATE_CHECK_NAME}\` checks plus ${approvals.requirement}.`,
|
|
"",
|
|
"<details><summary>Why this tier</summary>",
|
|
"",
|
|
...risk.reasons.slice(0, 15).map((reason) => `- ${sanitize(reason, 200)}`),
|
|
...(risk.reasons.length > 15 ? [`- …and ${risk.reasons.length - 15} more`] : []),
|
|
"",
|
|
"</details>",
|
|
"",
|
|
"### Automated checks",
|
|
"",
|
|
];
|
|
|
|
if (automation.results.length === 0) {
|
|
lines.push("_No automated checks apply to this PR._");
|
|
} else {
|
|
lines.push("| Check | Status | Details |", "|---|---|---|");
|
|
for (const result of automation.results) {
|
|
const detail = result.url ? `${sanitize(result.detail)} · [logs](${result.url})` : sanitize(result.detail);
|
|
lines.push(`| ${sanitize(result.title)} | ${outcomeCell(result)} | ${detail} |`);
|
|
}
|
|
}
|
|
|
|
const actions = [];
|
|
for (const result of automation.contributionFailures) {
|
|
actions.push(`- **${sanitize(result.title)}** failed. ${result.hint ? sanitize(result.hint, 400) : "See the logs for details."}`);
|
|
}
|
|
if (approvals.changesRequestedBy.length > 0) {
|
|
actions.push(`- Address the changes requested by ${approvals.changesRequestedBy.map((login) => `\`${login}\``).join(", ")}, then push an update.`);
|
|
}
|
|
if (automation.infrastructureFailures.length > 0) {
|
|
actions.push(
|
|
`- 🔧 ${automation.infrastructureFailures.map((r) => sanitize(r.title)).join(", ")} hit an automation problem that is **not** caused by your contribution. Comment \`/rerun-checks\` to retry; maintainers are notified if it keeps failing.`
|
|
);
|
|
}
|
|
if (automation.warnings.length > 0) {
|
|
actions.push(`- ⚠️ Advisory checks did not complete (${automation.warnings.map((r) => sanitize(r.title)).join(", ")}). This does not block the PR.`);
|
|
}
|
|
if (actions.length > 0) lines.push("", "### Action needed", "", ...actions);
|
|
|
|
const reviewerLinks = [
|
|
...reviewAssignment.users.map((login) => `[${sanitize(login, 60)}](https://github.com/${encodeURIComponent(login)})`),
|
|
...reviewAssignment.teams.map((slug) => `team \`${sanitize(slug, 80)}\``),
|
|
];
|
|
lines.push(
|
|
"",
|
|
"### Review",
|
|
"",
|
|
`- **Approvals:** ${approvals.approvers.length}/${approvals.required}${approvals.approvers.length > 0 ? ` (${approvals.approvers.map((login) => `\`${login}\``).join(", ")})` : ""}`,
|
|
`- **Assigned reviewer:** ${reviewerLinks.length > 0 ? reviewerLinks.join(", ") : "not assigned yet — comment `/request-review` to ask for one"}`,
|
|
`- **Review target date:** ${reviewAssignment.due ? sanitize(reviewAssignment.due, 40) : "not set"}`
|
|
);
|
|
if (!approvals.satisfied && approvals.missing.length > 0) lines.push(`- **Still needed:** ${approvals.missing.map((item) => sanitize(item, 200)).join("; ")}`);
|
|
for (const note of approvals.notes) lines.push(`- _${sanitize(note, 300)}_`);
|
|
|
|
lines.push(
|
|
"",
|
|
"### Commands",
|
|
"",
|
|
"| Command | Who | What it does |",
|
|
"|---|---|---|",
|
|
"| `/rerun-checks` | PR author, maintainers | Re-runs failed or incomplete checks and re-evaluates this gate |",
|
|
"| `/request-review` | PR author, maintainers | Asks the review rotation to assign a reviewer (adds `needs-reviewer`) |",
|
|
"",
|
|
`<sub>Updated for ${headSha ? `\`${headSha.slice(0, 7)}\`` : "the latest commit"}${gateRunUrl ? ` · [gate run](${gateRunUrl})` : ""} · This comment is maintained automatically — see [submission gate docs](https://github.com/github/awesome-copilot/blob/main/docs/maintainers/submission-gate.md).</sub>`
|
|
);
|
|
return `${lines.join("\n")}\n`;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Commands
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Parse a PR command. The command must start the comment (matching the workflow's
|
|
* case-insensitive `startsWith` filter); anything after it on the line is ignored.
|
|
*/
|
|
export function parsePrCommand(body) {
|
|
const match = /^\/(rerun-checks|request-review)(?:\s|$)/i.exec(String(body || ""));
|
|
return match ? { command: match[1].toLowerCase() } : null;
|
|
}
|
|
|
|
/** Whether a commenter may run PR commands: the PR author or a user with write access. */
|
|
export function canRunPrCommand({ commenter, prAuthor, permission }) {
|
|
if (!commenter) return false;
|
|
if (String(commenter).toLowerCase() === String(prAuthor || "").toLowerCase()) return true;
|
|
return WRITE_PERMISSIONS.has(permission);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// GitHub API orchestration
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function workflowFile(run) {
|
|
return path.posix.basename(String(run.path || "").split("@")[0]);
|
|
}
|
|
|
|
/** Latest workflow run per workflow file for a commit, limited to PR-triggered runs. */
|
|
export function latestRunsByWorkflow(runs) {
|
|
const latest = new Map();
|
|
for (const run of runs) {
|
|
if (!PR_EVENTS.has(run.event)) continue;
|
|
const file = workflowFile(run);
|
|
const previous = latest.get(file);
|
|
const newer =
|
|
!previous ||
|
|
new Date(run.created_at) > new Date(previous.created_at) ||
|
|
(run.created_at === previous.created_at && run.id > previous.id);
|
|
if (newer) latest.set(file, run);
|
|
}
|
|
return latest;
|
|
}
|
|
|
|
export async function listRunsForSha(github, { owner, repo, headSha }) {
|
|
return github.paginate(github.rest.actions.listWorkflowRunsForRepo, {
|
|
owner,
|
|
repo,
|
|
head_sha: headSha,
|
|
per_page: 100,
|
|
});
|
|
}
|
|
|
|
export async function observeChecks(github, { owner, repo, headSha, checks }) {
|
|
const latest = latestRunsByWorkflow(await listRunsForSha(github, { owner, repo, headSha }));
|
|
const observations = new Map();
|
|
|
|
for (const check of checks) {
|
|
if (check.workflow) {
|
|
const run = latest.get(check.workflow);
|
|
if (!run) {
|
|
observations.set(check.id, { found: false });
|
|
continue;
|
|
}
|
|
const observation = { found: true, status: run.status, conclusion: run.conclusion, url: run.html_url, runId: run.id };
|
|
if (run.status === "completed" && run.conclusion === "failure" && check.failure_kind !== "infrastructure") {
|
|
observation.jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
|
|
owner,
|
|
repo,
|
|
run_id: run.id,
|
|
filter: "latest",
|
|
per_page: 100,
|
|
});
|
|
}
|
|
observations.set(check.id, observation);
|
|
} else if (check.check_name) {
|
|
const { data } = await github.rest.checks.listForRef({
|
|
owner,
|
|
repo,
|
|
ref: headSha,
|
|
check_name: check.check_name,
|
|
filter: "latest",
|
|
per_page: 100,
|
|
});
|
|
const checkRun = [...(data.check_runs || [])].sort(
|
|
(a, b) => new Date(b.started_at || 0) - new Date(a.started_at || 0)
|
|
)[0];
|
|
if (!checkRun) {
|
|
observations.set(check.id, { found: false });
|
|
continue;
|
|
}
|
|
const observation = {
|
|
found: true,
|
|
status: checkRun.status,
|
|
conclusion: checkRun.conclusion,
|
|
url: checkRun.html_url,
|
|
};
|
|
if (checkRun.status === "completed" && checkRun.conclusion === "failure") {
|
|
try {
|
|
const { data: job } = await github.rest.actions.getJobForWorkflowRun({ owner, repo, job_id: checkRun.id });
|
|
observation.jobs = [job];
|
|
} catch {
|
|
observation.jobs = undefined;
|
|
}
|
|
}
|
|
observations.set(check.id, observation);
|
|
} else {
|
|
observations.set(check.id, { found: false });
|
|
}
|
|
}
|
|
return { observations, latestRuns: latest };
|
|
}
|
|
|
|
function evaluateObservations(applicable, observations, { elapsedMs, timeoutMs, graceMs, finalized, infrastructureSteps }) {
|
|
const found = [...observations.values()].filter((observation) => observation.found);
|
|
const othersDone = found.every((observation) => observation.status === "completed");
|
|
const timedOut = elapsedMs >= timeoutMs;
|
|
const graceOver = finalized || (elapsedMs >= graceMs && othersDone);
|
|
return applicable.map((check) => {
|
|
const observation = observations.get(check.id);
|
|
const gaveUp = timedOut || (!observation?.found && graceOver);
|
|
return evaluateCheck(check, observation, { gaveUp, infrastructureSteps });
|
|
});
|
|
}
|
|
|
|
/** Whether a contributor-check run's PR job succeeded, so its result artifact must exist. */
|
|
export async function contributorResultExpected(github, { owner, repo, run }) {
|
|
if (run?.status !== "completed" || run.conclusion !== "success") return false;
|
|
const jobs = await github.paginate(github.rest.actions.listJobsForWorkflowRun, {
|
|
owner,
|
|
repo,
|
|
run_id: run.id,
|
|
filter: "latest",
|
|
per_page: 100,
|
|
});
|
|
return jobs.some((job) => job.name === CONTRIBUTOR_RESULT_JOB && job.conclusion === "success");
|
|
}
|
|
|
|
/** Download the contributor reputation artifact (raise-only signal) with the gh CLI. */
|
|
export function readContributorRiskArtifact({ owner, repo, runId, headSha, token }) {
|
|
if (!runId) return null;
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "contributor-check-"));
|
|
try {
|
|
execFileSync(
|
|
"gh",
|
|
["run", "download", String(runId), "--repo", `${owner}/${repo}`, "--name", "contributor-check-result", "--dir", dir],
|
|
{ env: { ...process.env, GH_TOKEN: token || process.env.GH_TOKEN || process.env.GITHUB_TOKEN }, stdio: "pipe", timeout: 60_000 }
|
|
);
|
|
const resultPath = path.join(dir, "result.json");
|
|
if (!fs.existsSync(resultPath) || fs.statSync(resultPath).size > 64 * 1024) return null;
|
|
const result = JSON.parse(fs.readFileSync(resultPath, "utf8"));
|
|
if (result.schema_version !== "contributor-check-result/v1" || result.head_sha !== headSha) return null;
|
|
const risk = String(result.overall_risk || "").toUpperCase();
|
|
return ["HIGH", "MEDIUM", "LOW", "NONE", "UNKNOWN"].includes(risk) ? risk : null;
|
|
} catch {
|
|
return null;
|
|
} finally {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
|
|
async function lookupPermissions(github, { owner, repo, reviews, author }) {
|
|
const permissions = new Map();
|
|
const authorLogin = String(author || "").toLowerCase();
|
|
for (const review of reviews) {
|
|
const login = String(review.user?.login || "").toLowerCase();
|
|
if (!login || login === authorLogin || permissions.has(login)) continue;
|
|
try {
|
|
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: review.user.login });
|
|
permissions.set(login, data.role_name === "maintain" ? "maintain" : data.permission);
|
|
} catch {
|
|
// Fail closed: author_association (for example COLLABORATOR) does not imply write access.
|
|
permissions.set(login, "none");
|
|
}
|
|
}
|
|
return permissions;
|
|
}
|
|
|
|
/** Requested reviewers plus the review-due label maintained by reviewer routing. */
|
|
export function reviewAssignmentFrom(pr, labels) {
|
|
const dueLabel = labels.find((label) => label.startsWith("review-due:"));
|
|
return {
|
|
users: (pr.requested_reviewers || []).map((user) => user.login),
|
|
teams: (pr.requested_teams || []).map((team) => team.slug),
|
|
due: dueLabel ? dueLabel.slice("review-due:".length) : null,
|
|
overdue: labels.includes("review-overdue"),
|
|
escalated: labels.includes("review-escalated"),
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Evaluate a PR end to end.
|
|
* @param {object} options
|
|
* @param {boolean} [options.wait] poll until applicable checks finish (gate mode)
|
|
* @param {boolean|"auto"} [options.finalized] treat unreported checks as final: true after the gate
|
|
* completed (writer), "auto" when the gate run for the head commit has completed (sweeps)
|
|
*/
|
|
export async function evaluateSubmission(github, options) {
|
|
const {
|
|
owner,
|
|
repo,
|
|
pullNumber,
|
|
config,
|
|
expectedHeadSha = null,
|
|
wait = false,
|
|
finalized = false,
|
|
token = null,
|
|
log = () => {},
|
|
sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
now = () => Date.now(),
|
|
readContributorRisk = readContributorRiskArtifact,
|
|
} = options;
|
|
|
|
const { data: initialPr } = await github.rest.pulls.get({ owner, repo, pull_number: pullNumber });
|
|
const headSha = initialPr.head.sha;
|
|
if (expectedHeadSha && expectedHeadSha !== headSha) {
|
|
log(`PR #${pullNumber} head is ${headSha}, not ${expectedHeadSha}; a newer evaluation will handle it.`);
|
|
return { stale: true, pr: initialPr, headSha };
|
|
}
|
|
const files = await github.paginate(github.rest.pulls.listFiles, { owner, repo, pull_number: pullNumber, per_page: 100 });
|
|
// listFiles stops at 3,000 files; a truncated list can't be trusted for checks or risk.
|
|
const incompleteFiles = Number.isInteger(initialPr.changed_files) && files.length < initialPr.changed_files;
|
|
const applicable = selectApplicableChecks(config.gate.checks, files, initialPr.base.ref);
|
|
|
|
const waitConfig = config.gate.wait || {};
|
|
const timeoutMs = Number(waitConfig.timeout_minutes ?? 40) * 60_000;
|
|
const intervalMs = Number(waitConfig.interval_seconds ?? 30) * 1000;
|
|
const graceMs = Number(waitConfig.report_grace_minutes ?? 8) * 60_000;
|
|
const infrastructureSteps = config.gate.infrastructure_steps || [];
|
|
const start = now();
|
|
|
|
let results;
|
|
let latestRuns;
|
|
for (;;) {
|
|
const observed = await observeChecks(github, { owner, repo, headSha, checks: applicable });
|
|
latestRuns = observed.latestRuns;
|
|
const elapsedMs = now() - start;
|
|
const isFinal =
|
|
finalized === true || (finalized === "auto" && latestRuns.get(GATE_WORKFLOW_FILE)?.status === "completed");
|
|
results = evaluateObservations(applicable, observed.observations, {
|
|
elapsedMs: wait ? elapsedMs : 0,
|
|
timeoutMs: wait ? timeoutMs : Number.POSITIVE_INFINITY,
|
|
graceMs,
|
|
finalized: isFinal,
|
|
infrastructureSteps,
|
|
});
|
|
const pending = results.filter((result) => result.outcome === "pending" && result.required);
|
|
const blockingContribution = results.some((result) => result.required && result.category === "contribution");
|
|
if (!wait || pending.length === 0 || blockingContribution || elapsedMs >= timeoutMs) break;
|
|
log(`Waiting for ${pending.map((result) => result.title).join(", ")}`);
|
|
await sleep(intervalMs);
|
|
}
|
|
|
|
// Re-read PR state after waiting: labels and reviews may have changed. If the head moved,
|
|
// these results describe an old commit and must not be applied to the new one.
|
|
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: pullNumber });
|
|
if (pr.head.sha !== headSha) {
|
|
log(`PR head moved from ${headSha} to ${pr.head.sha} while evaluating; discarding this evaluation.`);
|
|
return { stale: true, pr, headSha };
|
|
}
|
|
// Applicable checks depend on the base branch; a retargeted PR needs a fresh evaluation.
|
|
if (pr.base.ref !== initialPr.base.ref) {
|
|
log(`PR base changed from ${initialPr.base.ref} to ${pr.base.ref} while evaluating; discarding this evaluation.`);
|
|
return { stale: true, pr, headSha };
|
|
}
|
|
const labels = (pr.labels || []).map((label) => label.name);
|
|
const reviews = await github.paginate(github.rest.pulls.listReviews, { owner, repo, pull_number: pullNumber, per_page: 100 });
|
|
|
|
const contributorRun = latestRuns?.get("contributor-check.yml");
|
|
let contributorRisk = null;
|
|
if (contributorRun?.status === "completed") {
|
|
let expected;
|
|
try {
|
|
expected = await contributorResultExpected(github, { owner, repo, run: contributorRun });
|
|
} catch {
|
|
expected = true;
|
|
}
|
|
contributorRisk = readContributorRisk({ owner, repo, runId: contributorRun.id, headSha, token });
|
|
// A skipped run (bot authors) has no signal. A PR job that succeeded must have left a readable
|
|
// result; losing it could hide a HIGH signal, so fail closed until a later sweep reads it.
|
|
if (contributorRisk === null && expected) {
|
|
results.push({
|
|
id: "contributor-risk-signal",
|
|
title: "Contributor risk signal",
|
|
required: true,
|
|
url: contributorRun.html_url || null,
|
|
hint: "The hourly Submission Gate Writer sweep retries this; a maintainer can re-run the contributor check if it persists.",
|
|
outcome: "failure",
|
|
category: "infrastructure",
|
|
detail: "The contributor check succeeded but its result artifact was missing, unreadable, or for another commit",
|
|
});
|
|
}
|
|
}
|
|
|
|
if (incompleteFiles) {
|
|
results.push({
|
|
id: "changed-files",
|
|
title: "Changed file list",
|
|
required: true,
|
|
url: null,
|
|
hint: null,
|
|
outcome: "failure",
|
|
category: "infrastructure",
|
|
detail: `GitHub returned ${files.length} of ${initialPr.changed_files} changed files; split the PR or ask a maintainer to review it manually`,
|
|
});
|
|
}
|
|
const impostors = await findImpostorGateChecks(github, { owner, repo, headSha });
|
|
if (impostors.length > 0) {
|
|
results.push({
|
|
id: "gate-integrity",
|
|
title: "Gate integrity",
|
|
required: true,
|
|
url: impostors[0].html_url || null,
|
|
hint: `Remove the workflow job named \`${GATE_CHECK_NAME}\` from this PR; only the Submission Gate Writer may report that check.`,
|
|
outcome: "failure",
|
|
category: "contribution",
|
|
detail: `${impostors.length} other check run(s) named \`${GATE_CHECK_NAME}\` were reported for this commit`,
|
|
});
|
|
}
|
|
|
|
const risk = classifyRisk({ files, labels, contributorRisk, tiers: config.tiers, incompleteFiles });
|
|
if (impostors.length > 0) {
|
|
risk.tier = "high";
|
|
risk.reasons.unshift(`Another workflow reports a \`${GATE_CHECK_NAME}\` check for this commit`);
|
|
}
|
|
const permissions = await lookupPermissions(github, { owner, repo, reviews, author: pr.user?.login });
|
|
const approvals = evaluateApprovals({
|
|
tier: risk.tier,
|
|
tiers: config.tiers,
|
|
reviews,
|
|
author: pr.user?.login,
|
|
permissions,
|
|
routing: config.routing,
|
|
files,
|
|
});
|
|
const automation = summarizeChecks(results);
|
|
const state = computeState({ automation, approvals });
|
|
|
|
return {
|
|
stale: false,
|
|
pr,
|
|
headSha,
|
|
baseRef: pr.base.ref,
|
|
files,
|
|
labels,
|
|
reviewsSignature: reviewsSignature(reviews),
|
|
riskLabelInputs: [...(config.tiers.high?.labels || [])],
|
|
riskLabelSignature: riskLabelSignature(labels, config.tiers.high?.labels),
|
|
risk,
|
|
tierDescription: config.tiers[risk.tier]?.description || "",
|
|
automation,
|
|
approvals,
|
|
state,
|
|
passed: state === "approved",
|
|
reviewAssignment: reviewAssignmentFrom(pr, labels),
|
|
gateRun: latestRuns?.get(GATE_WORKFLOW_FILE) || null,
|
|
};
|
|
}
|
|
|
|
/** Apply exactly one risk label and one state label, and upsert the status comment. */
|
|
export async function syncPullRequestStatus(
|
|
github,
|
|
{ owner, repo, evaluation, gateRunUrl = null, publishCheck = false, log = () => {} }
|
|
) {
|
|
const issueNumber = evaluation.pr.number;
|
|
|
|
// Revalidate right before writing so a slow run can't overwrite a newer head or review state.
|
|
const { data: fresh } = await github.rest.pulls.get({ owner, repo, pull_number: issueNumber });
|
|
if (fresh.state !== "open") {
|
|
log(`PR #${issueNumber} is no longer open; not updating.`);
|
|
return { updated: false, reason: "closed" };
|
|
}
|
|
if (fresh.head.sha !== evaluation.headSha) {
|
|
log(`PR #${issueNumber} head moved to ${fresh.head.sha}; not applying the evaluation of ${evaluation.headSha}.`);
|
|
return { updated: false, reason: "head-changed" };
|
|
}
|
|
if (evaluation.baseRef !== undefined && fresh.base.ref !== evaluation.baseRef) {
|
|
log(`PR #${issueNumber} base changed to ${fresh.base.ref}; not applying the evaluation for ${evaluation.baseRef}.`);
|
|
return { updated: false, reason: "base-changed" };
|
|
}
|
|
if (evaluation.riskLabelSignature !== undefined) {
|
|
const freshLabels = (fresh.labels || []).map((label) => label.name);
|
|
if (riskLabelSignature(freshLabels, evaluation.riskLabelInputs) !== evaluation.riskLabelSignature) {
|
|
log(`PR #${issueNumber} risk labels changed during evaluation; a newer evaluation will update it.`);
|
|
return { updated: false, reason: "labels-changed" };
|
|
}
|
|
}
|
|
if (evaluation.reviewsSignature !== undefined) {
|
|
const reviews = await github.paginate(github.rest.pulls.listReviews, { owner, repo, pull_number: issueNumber, per_page: 100 });
|
|
if (reviewsSignature(reviews) !== evaluation.reviewsSignature) {
|
|
log(`PR #${issueNumber} reviews changed during evaluation; a newer evaluation will update it.`);
|
|
return { updated: false, reason: "reviews-changed" };
|
|
}
|
|
}
|
|
|
|
// Publish the authoritative check first so enforcement never waits on labels or the comment.
|
|
if (publishCheck) await publishGateCheck(github, { owner, repo, evaluation, detailsUrl: gateRunUrl, log });
|
|
|
|
const current = new Set((fresh.labels || []).map((label) => label.name));
|
|
// External plugin intake (external-plugin-pr-quality-gates-writer.yml) owns the shared
|
|
// state labels on its PRs; only the risk label and comment are managed there.
|
|
const manageState = !STATE_LABEL_OWNERS.some((label) => current.has(label));
|
|
const desired = new Set([`merge-risk:${evaluation.risk.tier}`, ...(manageState ? [evaluation.state] : [])]);
|
|
const managed = new Set([...RISK_LABELS, ...(manageState ? STATE_LABELS : [])]);
|
|
|
|
const toAdd = [...desired].filter((label) => !current.has(label));
|
|
const toRemove = [...current].filter((label) => managed.has(label) && !desired.has(label));
|
|
const errors = [];
|
|
try {
|
|
if (toAdd.length > 0) await github.rest.issues.addLabels({ owner, repo, issue_number: issueNumber, labels: toAdd });
|
|
for (const name of toRemove) {
|
|
try {
|
|
await github.rest.issues.removeLabel({ owner, repo, issue_number: issueNumber, name });
|
|
} catch (error) {
|
|
if (error.status !== 404) throw error;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
errors.push(`labels: ${error.message}`);
|
|
}
|
|
|
|
try {
|
|
const body = renderStatusComment(evaluation, { gateRunUrl });
|
|
const comments = await github.paginate(github.rest.issues.listComments, {
|
|
owner,
|
|
repo,
|
|
issue_number: issueNumber,
|
|
per_page: 100,
|
|
});
|
|
const existing = comments.find(
|
|
(comment) => comment.user?.login === "github-actions[bot]" && String(comment.body || "").includes(STATUS_MARKER)
|
|
);
|
|
if (existing) {
|
|
if (existing.body !== body) await github.rest.issues.updateComment({ owner, repo, comment_id: existing.id, body });
|
|
} else {
|
|
await github.rest.issues.createComment({ owner, repo, issue_number: issueNumber, body });
|
|
}
|
|
} catch (error) {
|
|
errors.push(`status comment: ${error.message}`);
|
|
}
|
|
|
|
log(`PR #${issueNumber}: state=${evaluation.state} risk=${evaluation.risk.tier} (+${toAdd.join(",") || "none"} -${toRemove.join(",") || "none"})`);
|
|
if (errors.length > 0) throw new Error(`Published the gate check but could not sync ${errors.join("; ")}`);
|
|
return { updated: true };
|
|
}
|
|
|
|
/** Fingerprint of the labels that feed risk classification (`high.labels`, e.g. `needs-review:HIGH`). */
|
|
export function riskLabelSignature(labels, inputs = []) {
|
|
const watched = new Set(inputs);
|
|
return [...new Set(labels)].filter((label) => watched.has(label)).sort().join(",");
|
|
}
|
|
|
|
/** Stable fingerprint of the review list, used to detect reviews that arrive mid-evaluation. */
|
|
export function reviewsSignature(reviews) {
|
|
return reviews.map((review) => `${review.id}:${review.state}`).join(",");
|
|
}
|
|
|
|
async function listGateCheckRuns(github, { owner, repo, headSha }) {
|
|
const { data } = await github.rest.checks.listForRef({
|
|
owner,
|
|
repo,
|
|
ref: headSha,
|
|
check_name: GATE_CHECK_NAME,
|
|
filter: "all",
|
|
per_page: 100,
|
|
});
|
|
return data.check_runs || [];
|
|
}
|
|
|
|
/** Check runs named `submission-gate` on the commit that were not published by the gate writer. */
|
|
export async function findImpostorGateChecks(github, { owner, repo, headSha }) {
|
|
const runs = await listGateCheckRuns(github, { owner, repo, headSha });
|
|
return runs.filter((run) => run.external_id !== GATE_CHECK_EXTERNAL_ID);
|
|
}
|
|
|
|
/**
|
|
* A PR that is only waiting for a review is not broken: report `action_required`, which
|
|
* still blocks the required check but reads as "needs a reviewer" rather than a failure.
|
|
*/
|
|
export function gateConclusion(evaluation) {
|
|
if (evaluation.passed) return "success";
|
|
const { automation, approvals } = evaluation;
|
|
const onlyAwaitingReview =
|
|
automation.contributionFailures.length === 0 &&
|
|
automation.infrastructureFailures.length === 0 &&
|
|
automation.pending.length === 0 &&
|
|
(approvals.changesRequestedBy ?? []).length === 0 &&
|
|
!approvals.satisfied;
|
|
return onlyAwaitingReview ? "action_required" : "failure";
|
|
}
|
|
|
|
/**
|
|
* Publish the required `submission-gate` check on the PR head commit. Only the trusted
|
|
* writer calls this, so the check can't be satisfied by editing a PR-controlled workflow.
|
|
*/
|
|
export async function publishGateCheck(github, { owner, repo, evaluation, detailsUrl = null, log = () => {} }) {
|
|
const pending = evaluation.automation.pending.length > 0;
|
|
const display = STATE_DISPLAY[evaluation.state];
|
|
const reasons = gateFailureSummary(evaluation);
|
|
const output = {
|
|
title: `${display.text} · merge-risk:${evaluation.risk.tier}`,
|
|
summary: (evaluation.passed
|
|
? "All required checks passed and the approvals required by this risk tier are present."
|
|
: reasons.map((line) => `- ${sanitize(line, 1000)}`).join("\n") || `State: ${evaluation.state}`
|
|
).slice(0, 60_000),
|
|
};
|
|
const fields = pending
|
|
? { status: "in_progress", output }
|
|
: { status: "completed", conclusion: gateConclusion(evaluation), completed_at: new Date().toISOString(), output };
|
|
if (detailsUrl) fields.details_url = detailsUrl;
|
|
|
|
const runs = await listGateCheckRuns(github, { owner, repo, headSha: evaluation.headSha });
|
|
const ours = runs.filter((run) => run.external_id === GATE_CHECK_EXTERNAL_ID).sort((a, b) => b.id - a.id);
|
|
const impostors = runs.length > ours.length;
|
|
// external_id is not proof of origin: anyone who can run a workflow with `checks: write`
|
|
// can set it. Correct any copy that claims success for a failing evaluation.
|
|
const disagreeing = evaluation.passed ? [] : ours.filter((run) => run.conclusion === "success");
|
|
if (disagreeing.length > 0) {
|
|
log(`Correcting ${disagreeing.length} \`${GATE_CHECK_NAME}\` run(s) that reported success for a failing evaluation.`);
|
|
}
|
|
// Update the newest run in place normally; when another source reports the same name,
|
|
// create a newer run so the writer's result is the most recent one.
|
|
if (ours.length > 0 && !impostors) {
|
|
await github.rest.checks.update({ owner, repo, check_run_id: ours[0].id, ...fields });
|
|
for (const run of disagreeing.filter((candidate) => candidate.id !== ours[0].id)) {
|
|
await github.rest.checks.update({ owner, repo, check_run_id: run.id, ...fields });
|
|
}
|
|
} else {
|
|
await github.rest.checks.create({
|
|
owner,
|
|
repo,
|
|
name: GATE_CHECK_NAME,
|
|
head_sha: evaluation.headSha,
|
|
external_id: GATE_CHECK_EXTERNAL_ID,
|
|
...fields,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Resolve the open PR for a workflow_run without trusting any artifact content,
|
|
* mirroring label-pr-intent-writer.yml. Returns the PR number or null.
|
|
*/
|
|
export async function resolvePullRequestForWorkflowRun(github, { owner, repo, workflowRun }) {
|
|
const expectedBase = `${owner}/${repo}`.toLowerCase();
|
|
const headRepository = String(workflowRun.head_repository?.full_name || "");
|
|
const headBranch = String(workflowRun.head_branch || "");
|
|
const [headOwner] = headRepository.split("/");
|
|
if (!headOwner || !headBranch) return null;
|
|
|
|
const candidates = [];
|
|
for (const pullRequest of workflowRun.pull_requests || []) candidates.push(pullRequest.number);
|
|
if (candidates.length === 0) {
|
|
const open = await github.paginate(github.rest.pulls.list, {
|
|
owner,
|
|
repo,
|
|
state: "open",
|
|
head: `${headOwner}:${headBranch}`,
|
|
per_page: 100,
|
|
});
|
|
for (const pullRequest of open) candidates.push(pullRequest.number);
|
|
}
|
|
|
|
const matches = [];
|
|
for (const number of new Set(candidates)) {
|
|
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: number });
|
|
if (
|
|
pr.state === "open" &&
|
|
pr.head.sha === workflowRun.head_sha &&
|
|
String(pr.head?.ref || "") === headBranch &&
|
|
String(pr.head?.repo?.full_name || "").toLowerCase() === headRepository.toLowerCase() &&
|
|
String(pr.base?.repo?.full_name || "").toLowerCase() === expectedBase
|
|
) {
|
|
matches.push(number);
|
|
}
|
|
}
|
|
return matches.length === 1 ? matches[0] : null;
|
|
}
|
|
|
|
/** Re-run failed or incomplete PR workflows for the head commit, then re-evaluate the gate. */
|
|
export async function rerunChecks(github, { owner, repo, headSha }) {
|
|
const latest = latestRunsByWorkflow(await listRunsForSha(github, { owner, repo, headSha }));
|
|
const rerun = [];
|
|
const skipped = [];
|
|
for (const [file, run] of latest) {
|
|
const label = run.name || file;
|
|
if (file === GATE_WORKFLOW_FILE) continue;
|
|
if (run.status !== "completed") continue;
|
|
if (run.conclusion === "action_required") {
|
|
skipped.push({ name: label, reason: "waiting for a maintainer to approve workflow runs" });
|
|
continue;
|
|
}
|
|
if (!RERUNNABLE_CONCLUSIONS.has(run.conclusion)) continue;
|
|
try {
|
|
await github.rest.actions.reRunWorkflowFailedJobs({ owner, repo, run_id: run.id });
|
|
rerun.push(label);
|
|
} catch {
|
|
try {
|
|
await github.rest.actions.reRunWorkflow({ owner, repo, run_id: run.id });
|
|
rerun.push(label);
|
|
} catch (error) {
|
|
skipped.push({ name: label, reason: `could not be re-run (${error.status || error.message})` });
|
|
}
|
|
}
|
|
}
|
|
|
|
const gateRun = latest.get(GATE_WORKFLOW_FILE);
|
|
if (!gateRun) {
|
|
skipped.push({ name: "Submission Gate", reason: "has not run for this commit yet" });
|
|
} else if (gateRun.status !== "completed") {
|
|
skipped.push({ name: gateRun.name || "Submission Gate", reason: "is already running and will pick up the re-runs" });
|
|
} else if (gateRun.conclusion === "action_required") {
|
|
skipped.push({ name: gateRun.name || "Submission Gate", reason: "waiting for a maintainer to approve workflow runs" });
|
|
} else {
|
|
try {
|
|
await github.rest.actions.reRunWorkflow({ owner, repo, run_id: gateRun.id });
|
|
rerun.push(gateRun.name || "Submission Gate");
|
|
} catch (error) {
|
|
skipped.push({ name: gateRun.name || "Submission Gate", reason: `could not be re-run (${error.status || error.message})` });
|
|
}
|
|
}
|
|
return { rerun, skipped };
|
|
}
|
|
|
|
export const PR_COMMAND_SCHEMA = "pr-command-request/v1";
|
|
export const COMMAND_CLAIM_REACTION = "eyes";
|
|
export const COMMAND_DONE_REACTION = "rocket";
|
|
|
|
/**
|
|
* Validate the untrusted artifact uploaded by the read-only PR Commands workflow. Only the
|
|
* comment and PR numbers are taken from it; everything else is re-read from the API.
|
|
*/
|
|
export function validatePrCommandRequest(request, { workflowRunId }) {
|
|
const fail = (message) => {
|
|
throw new Error(`Invalid PR command request: ${message}`);
|
|
};
|
|
if (!request || typeof request !== "object") fail("not an object");
|
|
if (request.schema_version !== PR_COMMAND_SCHEMA) fail("unexpected schema_version");
|
|
if (!Number.isSafeInteger(request.pr_number) || request.pr_number < 1) fail("invalid pr_number");
|
|
if (!Number.isSafeInteger(request.comment_id) || request.comment_id < 1) fail("invalid comment_id");
|
|
if (String(request.run_id ?? "") !== String(workflowRunId)) fail("run_id did not match workflow_run");
|
|
return { prNumber: request.pr_number, commentId: request.comment_id };
|
|
}
|
|
|
|
/**
|
|
* Run a PR command on behalf of the trusted PR Commands Writer. Re-reads the comment, the PR,
|
|
* and the commenter's permission from the API, so a forged artifact can at most point at a
|
|
* real comment that already asked for the command.
|
|
*/
|
|
export async function runPrCommand(
|
|
github,
|
|
{ owner, repo, prNumber, commentId, config, defaultBranch = "main", log = () => {} }
|
|
) {
|
|
const { data: comment } = await github.rest.issues.getComment({ owner, repo, comment_id: commentId });
|
|
const issueUrlSuffix = `/repos/${owner}/${repo}/issues/${prNumber}`.toLowerCase();
|
|
if (!String(comment.issue_url || "").toLowerCase().endsWith(issueUrlSuffix)) {
|
|
throw new Error(`Comment ${commentId} does not belong to #${prNumber}`);
|
|
}
|
|
if (comment.user?.type === "Bot") return { status: "ignored", reason: "bot comment" };
|
|
const parsed = parsePrCommand(comment.body);
|
|
if (!parsed) return { status: "ignored", reason: "no supported command" };
|
|
|
|
// 👀 = claimed (written before acting), 🚀 = completed (written only after the reply is posted).
|
|
// Only the completed marker suppresses replays, so a run that failed midway can be retried.
|
|
const reactions = await github.paginate(github.rest.reactions.listForIssueComment, {
|
|
owner,
|
|
repo,
|
|
comment_id: commentId,
|
|
per_page: 100,
|
|
});
|
|
if (
|
|
reactions.some(
|
|
(reaction) => reaction.content === COMMAND_DONE_REACTION && reaction.user?.login === "github-actions[bot]"
|
|
)
|
|
) {
|
|
return { status: "ignored", reason: "already handled" };
|
|
}
|
|
|
|
const { data: pr } = await github.rest.pulls.get({ owner, repo, pull_number: prNumber });
|
|
if (pr.state !== "open") return { status: "ignored", reason: "PR is not open" };
|
|
if (String(pr.base?.repo?.full_name || "").toLowerCase() !== `${owner}/${repo}`.toLowerCase()) {
|
|
throw new Error(`PR #${prNumber} does not target ${owner}/${repo}`);
|
|
}
|
|
|
|
const commenter = comment.user?.login;
|
|
let permission = "none";
|
|
try {
|
|
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({ owner, repo, username: commenter });
|
|
permission = data.permission;
|
|
} catch (error) {
|
|
log(`Could not read permission for ${commenter}: ${error.status || error.message}`);
|
|
}
|
|
if (!canRunPrCommand({ commenter, prAuthor: pr.user?.login, permission })) {
|
|
return { status: "ignored", reason: `${commenter} is not the PR author or a maintainer` };
|
|
}
|
|
|
|
await github.rest.reactions.createForIssueComment({ owner, repo, comment_id: commentId, content: COMMAND_CLAIM_REACTION });
|
|
|
|
const dispatch = async (workflowId, ref, inputs) => {
|
|
try {
|
|
await github.rest.actions.createWorkflowDispatch({ owner, repo, workflow_id: workflowId, ref, inputs });
|
|
return true;
|
|
} catch (error) {
|
|
log(`Could not dispatch ${workflowId}: ${error.status || error.message}`);
|
|
return false;
|
|
}
|
|
};
|
|
const list = (items) => items.map((item) => `\`${sanitize(item, 80)}\``).join(", ");
|
|
|
|
const lines = [];
|
|
if (parsed.command === "rerun-checks") {
|
|
const result = await rerunChecks(github, { owner, repo, headSha: pr.head.sha });
|
|
await dispatch("submission-gate-writer.yml", defaultBranch, { pr_number: String(pr.number) });
|
|
lines.push(`🔁 \`/rerun-checks\` for \`${pr.head.sha.slice(0, 7)}\``, "");
|
|
lines.push(result.rerun.length > 0 ? `Re-running: ${list(result.rerun)}.` : "No failed or incomplete checks to re-run.");
|
|
for (const skipped of result.skipped) lines.push(`- ${sanitize(skipped.name, 80)} ${sanitize(skipped.reason, 200)}.`);
|
|
lines.push("", "The status comment updates when the checks finish.");
|
|
} else {
|
|
const settings = config.gate.commands?.request_review || {};
|
|
const label = settings.label || "needs-reviewer";
|
|
await github.rest.issues.addLabels({ owner, repo, issue_number: pr.number, labels: [label] });
|
|
const dispatched = settings.dispatch_workflow
|
|
? await dispatch(settings.dispatch_workflow, settings.dispatch_ref || defaultBranch, { pr_number: String(pr.number) })
|
|
: false;
|
|
const requested = [
|
|
...(pr.requested_reviewers || []).map((user) => user.login),
|
|
...(pr.requested_teams || []).map((team) => team.slug),
|
|
];
|
|
lines.push(
|
|
`🙋 Added \`${label}\`. ${dispatched ? "Reviewer routing is assigning a reviewer now." : "Reviewer routing picks this up on its next run."}`
|
|
);
|
|
if (requested.length > 0) lines.push("", `Already requested: ${list(requested)}.`);
|
|
}
|
|
|
|
await github.rest.issues.createComment({ owner, repo, issue_number: pr.number, body: lines.join("\n") });
|
|
await github.rest.reactions.createForIssueComment({ owner, repo, comment_id: commentId, content: COMMAND_DONE_REACTION });
|
|
return { status: "handled", command: parsed.command };
|
|
} |