mirror of
https://github.com/github/awesome-copilot.git
synced 2026-10-12 14:54:16 +00:00
Add persistent AI-assisted PR triage with labels, checks, review evidence, and confirmed GitHub feedback, close, approval, and auto-merge actions. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 8045e7e0-dfd7-425a-9131-21ea5ca1553e
181 lines
6.5 KiB
JavaScript
181 lines
6.5 KiB
JavaScript
import { createServer } from "node:http";
|
|
import { randomBytes } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
|
|
const STATIC_FILES = {
|
|
"/app.js": { file: "app.js", type: "text/javascript; charset=utf-8" },
|
|
"/styles.css": { file: "styles.css", type: "text/css; charset=utf-8" },
|
|
};
|
|
|
|
const CSP = [
|
|
"default-src 'self'",
|
|
"script-src 'self'",
|
|
"style-src 'self' 'unsafe-inline'",
|
|
"img-src 'self' https: data:",
|
|
"media-src https:",
|
|
"connect-src 'self'",
|
|
"base-uri 'none'",
|
|
"form-action 'none'",
|
|
"object-src 'none'",
|
|
// The Copilot host loads canvases as a top-level webview, so framing is never
|
|
// legitimate and denying it blocks clickjacking of the approve/close controls.
|
|
"frame-ancestors 'none'",
|
|
].join("; ");
|
|
|
|
const MAX_BODY = 64 * 1024;
|
|
|
|
export function expectedError(message, status) {
|
|
return Object.assign(new Error(message), { publicMessage: message, status });
|
|
}
|
|
|
|
function readJson(req) {
|
|
return new Promise((resolve, reject) => {
|
|
let size = 0;
|
|
const chunks = [];
|
|
req.on("data", (chunk) => {
|
|
size += chunk.length;
|
|
if (size > MAX_BODY) {
|
|
reject(expectedError("Request body too large", 413));
|
|
req.destroy();
|
|
return;
|
|
}
|
|
chunks.push(chunk);
|
|
});
|
|
req.on("end", () => {
|
|
if (!chunks.length) return resolve({});
|
|
try {
|
|
resolve(JSON.parse(Buffer.concat(chunks).toString("utf8")));
|
|
} catch {
|
|
reject(expectedError("Invalid JSON body", 400));
|
|
}
|
|
});
|
|
req.on("error", reject);
|
|
});
|
|
}
|
|
|
|
function sendJson(res, status, payload) {
|
|
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" });
|
|
res.end(JSON.stringify(payload));
|
|
}
|
|
|
|
function reportError(api, error) {
|
|
const logger = api.logger ?? console;
|
|
if (typeof logger.error === "function") {
|
|
logger.error("Open PR board server error", error);
|
|
}
|
|
}
|
|
|
|
function clientErrorMessage(error) {
|
|
const status = Number.isInteger(error?.status) ? error.status : 500;
|
|
if (status >= 400 && status < 500) return error?.publicMessage || "Request failed";
|
|
return "Internal server error";
|
|
}
|
|
|
|
/**
|
|
* Starts a loopback server for one canvas instance.
|
|
* `api` provides: snapshot(), subscribe(fn) => unsubscribe, and route handlers keyed by "METHOD /path".
|
|
*/
|
|
export async function startBoardServer({ publicDir, api }) {
|
|
const token = randomBytes(24).toString("hex");
|
|
const sseClients = new Set();
|
|
let port = 0;
|
|
|
|
const unsubscribe = api.subscribe((snapshot) => {
|
|
const frame = `event: board\ndata: ${JSON.stringify(snapshot)}\n\n`;
|
|
for (const client of sseClients) client.write(frame);
|
|
});
|
|
|
|
const server = createServer(async (req, res) => {
|
|
try {
|
|
const host = req.headers.host ?? "";
|
|
if (host !== `127.0.0.1:${port}` && host !== `localhost:${port}`) {
|
|
res.writeHead(421).end("Misdirected request");
|
|
return;
|
|
}
|
|
const url = new URL(req.url ?? "/", `http://${host}`);
|
|
|
|
if (req.method === "GET" && url.pathname === "/") {
|
|
const html = await readFile(path.join(publicDir, "index.html"), "utf8");
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/html; charset=utf-8",
|
|
"Content-Security-Policy": CSP,
|
|
"X-Frame-Options": "DENY",
|
|
"Cache-Control": "no-store",
|
|
});
|
|
res.end(html.replace("__BOARD_TOKEN__", token));
|
|
return;
|
|
}
|
|
|
|
const asset = req.method === "GET" && STATIC_FILES[url.pathname];
|
|
if (asset) {
|
|
const body = await readFile(path.join(publicDir, asset.file));
|
|
res.writeHead(200, { "Content-Type": asset.type, "Cache-Control": "no-store" });
|
|
res.end(body);
|
|
return;
|
|
}
|
|
|
|
if (!url.pathname.startsWith("/api/") && url.pathname !== "/events") {
|
|
res.writeHead(404).end("Not found");
|
|
return;
|
|
}
|
|
|
|
if (url.searchParams.get("token") !== token && req.headers["x-board-token"] !== token) {
|
|
sendJson(res, 403, { error: "Invalid board token" });
|
|
return;
|
|
}
|
|
|
|
if (req.method === "GET" && url.pathname === "/events") {
|
|
res.writeHead(200, {
|
|
"Content-Type": "text/event-stream",
|
|
"Cache-Control": "no-store",
|
|
Connection: "keep-alive",
|
|
});
|
|
res.write(`event: board\ndata: ${JSON.stringify(api.snapshot())}\n\n`);
|
|
sseClients.add(res);
|
|
const keepAlive = setInterval(() => res.write(": ping\n\n"), 25000);
|
|
req.on("close", () => {
|
|
clearInterval(keepAlive);
|
|
sseClients.delete(res);
|
|
});
|
|
return;
|
|
}
|
|
|
|
const numberMatch = url.pathname.match(/\/(\d+)(?=\/|$)/);
|
|
const routeKey = `${req.method} ${url.pathname.replace(/\/\d+(?=\/|$)/, "/:number")}`;
|
|
const handler = api.routes[routeKey];
|
|
if (!handler) {
|
|
sendJson(res, 404, { error: `No route for ${req.method} ${url.pathname}` });
|
|
return;
|
|
}
|
|
const body = req.method === "POST" ? await readJson(req) : {};
|
|
const result = await handler({ body, number: numberMatch ? Number(numberMatch[1]) : undefined });
|
|
sendJson(res, 200, result ?? {});
|
|
} catch (error) {
|
|
if (!res.headersSent) {
|
|
const status = Number.isInteger(error?.status) ? error.status : 500;
|
|
if (status >= 500) reportError(api, error);
|
|
sendJson(res, status, { error: clientErrorMessage(error) });
|
|
} else {
|
|
reportError(api, error);
|
|
try {
|
|
res.end();
|
|
} catch {}
|
|
}
|
|
}
|
|
});
|
|
|
|
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
port = server.address().port;
|
|
|
|
return {
|
|
url: `http://127.0.0.1:${port}/`,
|
|
async close() {
|
|
unsubscribe();
|
|
for (const client of sseClients) client.end();
|
|
sseClients.clear();
|
|
await new Promise((resolve) => server.close(() => resolve()));
|
|
},
|
|
};
|
|
}
|