Files
awesome-copilot/eng/lib/review-automation-github.mjs
d82df27473 Phase 3: canvas smoke-test evidence and weekly review metrics (#4184) (#4191)
* Phase 3: canvas smoke-test evidence, safe auto-merge, and weekly review metrics (#4184)

Adds the canvas-smoke-test check with review evidence, a config-gated safe auto-merge workflow (disabled by default), and a weekly review operating metrics workflow, plus maintainer docs.

Refs github/awesome-copilot#4184

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Trigger safe auto-merge sweep after Submission Gate Writer completes

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Rename thirdParty identifier to satisfy codespell

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Address Copilot review on canvas smoke test, auto-merge, and metrics

- Bind canvas report writer to the triggering workflow_run PR (base/head repo, ref, SHA)
- Bound PNG inflation by IHDR-derived size and a decoded-bytes budget
- Classify file: URLs as unsafe manifest paths
- Follow CommonJS require() and literal dynamic imports; error on missing
  reachable targets and devDependency-only runtime imports
- Validate removed extension paths and orphaned plugins instead of skipping
- Warn on .py/.rb/.pl scripts
- Auto-merge: support CODEOWNERS teams (fail closed), front matter authors,
  list PRs by head SHA, only fall back to direct merge on clean status,
  drop ineffective check_run trigger
- Metrics: ignore reviews before the PR was ready for review

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Docs: note microsoft/azure-dev-tools as a portable marketplace for the review model

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Auto-merge: only trust submission-gate runs from the Submission Gate Writer

Adds trusted_checks (default submission-gate -> external_id
submission-gate-writer). Any other check run or status with that name on the
head SHA blocks arming, so a PR-defined job cannot satisfy the gate.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Harden canvas smoke-test checker

- String-, template-, and regex-aware comment stripping so literals containing // cannot hide imports
- Never follow symlinks or read non-regular files; bounded, contained reads
- Validate plugins whose plugin.json was deleted
- Reject unsafe extension IDs in plugin manifests before materialization

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Phase 3: remove auto-merge, isolate canvas smoke test, harden checker

- Remove safe auto-merge (config, workflow, script, tests, labels) per
  maintainer review; documented as deferred pending a security review.
- Restore validate-canvas-extensions.yml to main and move the smoke test
  to its own canvas-smoke-test.yml workflow scoped to extensions/**.
- Reject duplicate IHDR chunks in preview PNGs.
- Mask string/template/regex literal contents before extracting dynamic
  import() and require() specifiers.
- Resolve package.json imports aliases through the same classifier and
  module graph; fail closed on alias shapes that cannot be analyzed.
- Rename docs to canvas-evidence-and-metrics.md and update references.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Fix canvas smoke and review metrics findings

- Trigger canvas smoke tests for plugin changes while skipping expensive setup when target detection finds no canvas coverage.
- Fail reachable data: imports while preserving warnings for unreachable module data: imports.
- Use base plugin manifests for deleted bundle plugin detection and fail closed when base manifests cannot be read.
- Paginate GraphQL PR review pages before computing weekly review metrics.
- Report workflow run API collection errors as incomplete automation health instead of not found.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

* Address follow-up canvas review findings

Fail closed on non-literal runtime imports, require canonical PNG termination, restrict comment updates to github-actions[bot], and paginate external-plugin label events.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Aaron Powell <me@aaron-powell.com>
2026-10-07 16:36:13 +11:00

148 lines
5.6 KiB
JavaScript

// Minimal GitHub REST/GraphQL client for maintainer automation scripts.
//
// In workflows it uses GITHUB_TOKEN (or GH_TOKEN) with the global fetch API.
// For local dry runs without a token it falls back to the authenticated `gh`
// CLI (`gh api`), so maintainers never need to export credentials.
import { spawn } from "node:child_process";
const DEFAULT_API_URL = "https://api.github.com";
export function createGitHubClient({
token = process.env.GITHUB_TOKEN || process.env.GH_TOKEN,
apiUrl = process.env.GITHUB_API_URL || DEFAULT_API_URL,
userAgent = "awesome-copilot-maintainer-automation",
transport,
} = {}) {
const baseUrl = apiUrl.replace(/\/$/, "");
const send = transport ?? (token ? createFetchTransport({ token, userAgent }) : createGhTransport(baseUrl));
async function request(method, route, { body, query, allowStatuses = [] } = {}) {
const url = new URL(route.startsWith("http") ? route : `${baseUrl}${route}`);
for (const [key, value] of Object.entries(query ?? {})) {
if (value !== undefined && value !== null) url.searchParams.set(key, String(value));
}
for (let attempt = 0; ; attempt++) {
const response = await send(method, url, body);
if ((response.status === 502 || response.status === 503) && attempt < 2) {
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
continue;
}
const data = response.text ? safeJson(response.text) : null;
if ((response.status < 200 || response.status >= 300) && !allowStatuses.includes(response.status)) {
const message = data?.message || response.text || `HTTP ${response.status}`;
const error = new Error(`${method} ${url.pathname} failed: ${response.status} ${message}`);
error.status = response.status;
throw error;
}
return { status: response.status, data, headers: response.headers };
}
}
async function paginate(route, { query = {}, itemsKey, maxPages = 20 } = {}) {
const items = [];
let next = null;
let page = 0;
do {
const response = next ? await request("GET", next) : await request("GET", route, { query: { per_page: 100, ...query } });
const pageItems = itemsKey ? response.data?.[itemsKey] ?? [] : response.data ?? [];
items.push(...pageItems);
next = parseLink(response.headers.get("link"), "next");
page++;
} while (next && page < maxPages);
return items;
}
async function graphql(query, variables = {}) {
const response = await request("POST", "/graphql", { body: { query, variables } });
if (response.data?.errors?.length) {
const error = new Error(`GraphQL request failed: ${response.data.errors.map((item) => item.message).join("; ")}`);
error.errors = response.data.errors;
throw error;
}
return response.data?.data;
}
return { request, paginate, graphql };
}
function createFetchTransport({ token, userAgent }) {
const headers = {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"User-Agent": userAgent,
"X-GitHub-Api-Version": "2022-11-28",
};
return async (method, url, body) => {
const response = await fetch(url, {
method,
headers: body ? { ...headers, "Content-Type": "application/json" } : headers,
body: body ? JSON.stringify(body) : undefined,
});
return { status: response.status, text: await response.text(), headers: response.headers };
};
}
function createGhTransport(baseUrl) {
return (method, url, body) =>
new Promise((resolve, reject) => {
const endpoint = url.href.startsWith(baseUrl) ? url.href.slice(baseUrl.length + 1) : url.href;
const args = ["api", "--include", "--method", method, endpoint, "-H", "X-GitHub-Api-Version: 2022-11-28"];
if (body) args.push("--input", "-");
const child = spawn("gh", args, { stdio: ["pipe", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8").on("data", (chunk) => (stdout += chunk));
child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk));
child.on("error", (error) =>
reject(new Error(`No GITHUB_TOKEN/GH_TOKEN set and the gh CLI could not be started: ${error.message}`)),
);
child.on("close", () => {
const parsed = parseIncludedResponse(stdout);
if (!parsed) {
reject(new Error(`gh api ${method} ${endpoint} failed: ${stderr.trim() || "no response"}`));
return;
}
resolve(parsed);
});
child.stdin.end(body ? JSON.stringify(body) : undefined);
});
}
export function parseIncludedResponse(output) {
const normalized = output.replace(/\r\n/g, "\n");
const match = normalized.match(/^HTTP\/[\d.]+ (\d{3})[^\n]*\n([\s\S]*?)\n\n([\s\S]*)$/);
if (!match) return null;
const headers = new Headers();
for (const line of match[2].split("\n")) {
const index = line.indexOf(":");
if (index > 0) headers.append(line.slice(0, index).trim(), line.slice(index + 1).trim());
}
return { status: Number(match[1]), text: match[3], headers };
}
export function parseLink(linkHeader, rel) {
if (!linkHeader) return null;
for (const part of linkHeader.split(",")) {
const match = part.match(/<([^>]+)>\s*;\s*rel="([^"]+)"/);
if (match && match[2] === rel) return match[1];
}
return null;
}
export function parseRepository(value = process.env.GITHUB_REPOSITORY) {
const [owner, repo] = String(value ?? "").split("/");
if (!owner || !repo) {
throw new Error("Repository must be provided as owner/repo (use --repo or GITHUB_REPOSITORY).");
}
return { owner, repo };
}
function safeJson(text) {
try {
return JSON.parse(text);
} catch {
return null;
}
}