mirror of
https://github.com/github/awesome-copilot.git
synced 2026-10-13 07:14:24 +00:00
* 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>
148 lines
5.6 KiB
JavaScript
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;
|
|
}
|
|
}
|