mirror of
https://github.com/github/awesome-copilot.git
synced 2026-10-12 23:04:14 +00:00
300 lines
12 KiB
JavaScript
300 lines
12 KiB
JavaScript
import { execFile } from "node:child_process";
|
|
import { promisify } from "node:util";
|
|
import { normalizeUrl } from "./review.mjs";
|
|
|
|
const execFileAsync = promisify(execFile);
|
|
const MAX_DIFF_BYTES = 20 * 1024 * 1024;
|
|
const MAX_CHANGED_FILES = 3000;
|
|
|
|
function decodeAccountName(value) {
|
|
return value.replace(/_([0-9a-f]{2})_/gi, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
|
|
}
|
|
|
|
function cleanGhEnvironment() {
|
|
const env = { ...process.env };
|
|
delete env.GH_TOKEN;
|
|
delete env.GITHUB_TOKEN;
|
|
return env;
|
|
}
|
|
|
|
async function ghTokens(hostname) {
|
|
const tokens = [];
|
|
const add = (token) => {
|
|
const value = token?.trim();
|
|
if (value && !tokens.includes(value)) tokens.push(value);
|
|
};
|
|
|
|
if (hostname === "github.com") {
|
|
add(process.env.GH_TOKEN);
|
|
add(process.env.GITHUB_TOKEN);
|
|
}
|
|
const prefix = "COPILOT_GH_ACCOUNT_";
|
|
for (const [name, value] of Object.entries(process.env)) {
|
|
if (!name.startsWith(prefix) || !value) continue;
|
|
const account = decodeAccountName(name.slice(prefix.length));
|
|
if (account.toLowerCase().startsWith(`${hostname.toLowerCase()}_`)) add(value);
|
|
}
|
|
|
|
let status = "";
|
|
try {
|
|
const result = await execFileAsync("gh", ["auth", "status"], {
|
|
timeout: 8000, env: cleanGhEnvironment(), windowsHide: true,
|
|
});
|
|
status = `${result.stdout ?? ""}\n${result.stderr ?? ""}`;
|
|
} catch (error) {
|
|
if (error?.code !== "ENOENT") status = `${error?.stdout ?? ""}\n${error?.stderr ?? ""}`;
|
|
}
|
|
const accounts = new Set();
|
|
for (const match of status.matchAll(/Logged in to (\S+) account (\S+)/g)) {
|
|
if (match[1].toLowerCase() === hostname.toLowerCase()) accounts.add(match[2]);
|
|
}
|
|
for (const login of accounts) {
|
|
try {
|
|
const result = await execFileAsync("gh", ["auth", "token", "--hostname", hostname, "--user", login], {
|
|
timeout: 8000, env: cleanGhEnvironment(), windowsHide: true,
|
|
});
|
|
add(result.stdout);
|
|
} catch (error) {
|
|
if (error?.code === "ENOENT") break;
|
|
}
|
|
}
|
|
if (!tokens.length) {
|
|
throw new Error(`No GitHub credentials are available for ${hostname}. Sign in with GitHub CLI or reconnect your GitHub account.`);
|
|
}
|
|
return tokens;
|
|
}
|
|
|
|
function parsePrUrl(input) {
|
|
const canonicalUrl = normalizeUrl(input);
|
|
const parsed = new URL(canonicalUrl);
|
|
const [, owner, repository, , pullNumber] = parsed.pathname.split("/");
|
|
return {
|
|
canonicalUrl,
|
|
hostname: parsed.hostname,
|
|
apiBase: parsed.hostname === "github.com" ? "https://api.github.com" : `${parsed.origin}/api/v3`,
|
|
owner,
|
|
repository,
|
|
pullNumber: Number(pullNumber),
|
|
};
|
|
}
|
|
|
|
async function readBoundedText(response, maxBytes) {
|
|
if (!response.ok) {
|
|
const detail = (await response.text()).slice(0, 1000);
|
|
throw new Error(`GitHub request failed (${response.status})${detail ? `: ${detail}` : "."}`);
|
|
}
|
|
const declaredLength = Number(response.headers.get("content-length"));
|
|
if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
|
|
throw new Error(`GitHub diff exceeds the ${Math.floor(maxBytes / 1024 / 1024)} MB local snapshot limit.`);
|
|
}
|
|
if (!response.body) return "";
|
|
const reader = response.body.getReader();
|
|
const chunks = [];
|
|
let size = 0;
|
|
while (true) {
|
|
const { done, value } = await reader.read();
|
|
if (done) break;
|
|
size += value.byteLength;
|
|
if (size > maxBytes) {
|
|
await reader.cancel();
|
|
throw new Error(`GitHub diff exceeds the ${Math.floor(maxBytes / 1024 / 1024)} MB local snapshot limit.`);
|
|
}
|
|
chunks.push(value);
|
|
}
|
|
const bytes = new Uint8Array(size);
|
|
let offset = 0;
|
|
for (const chunk of chunks) {
|
|
bytes.set(chunk, offset);
|
|
offset += chunk.byteLength;
|
|
}
|
|
return new TextDecoder().decode(bytes);
|
|
}
|
|
|
|
function unquoteGitToken(line, offset) {
|
|
if (line[offset] !== '"') {
|
|
let end = line.indexOf(" ", offset);
|
|
if (end < 0) end = line.length;
|
|
return { value: line.slice(offset, end), next: end };
|
|
}
|
|
const bytes = [];
|
|
let index = offset + 1;
|
|
while (index < line.length && line[index] !== '"') {
|
|
if (line[index] !== "\\") {
|
|
const codePoint = line.codePointAt(index);
|
|
const encoded = new TextEncoder().encode(String.fromCodePoint(codePoint));
|
|
bytes.push(...encoded);
|
|
index += codePoint > 0xffff ? 2 : 1;
|
|
continue;
|
|
}
|
|
index++;
|
|
const octal = /^[0-7]{3}/.exec(line.slice(index));
|
|
if (octal) {
|
|
bytes.push(Number.parseInt(octal[0], 8));
|
|
index += 3;
|
|
continue;
|
|
}
|
|
const escapes = { t: 9, n: 10, r: 13, b: 8, f: 12, v: 11, "\\": 92, '"': 34 };
|
|
if (!(line[index] in escapes)) throw new Error("GitHub returned an invalid quoted diff path.");
|
|
bytes.push(escapes[line[index++]]);
|
|
}
|
|
if (line[index] !== '"') throw new Error("GitHub returned an unterminated diff path.");
|
|
return { value: new TextDecoder().decode(new Uint8Array(bytes)), next: index + 1 };
|
|
}
|
|
|
|
function parseDiffHeader(line) {
|
|
const start = "diff --git ".length;
|
|
const oldToken = unquoteGitToken(line, start);
|
|
const newPath = unquoteGitToken(line, oldToken.next + 1).value;
|
|
const oldPath = oldToken.value;
|
|
return [oldPath.startsWith("a/") ? oldPath.slice(2) : oldPath, newPath.startsWith("b/") ? newPath.slice(2) : newPath];
|
|
}
|
|
|
|
export function mapUnifiedDiff(diff, files) {
|
|
const sections = diff.split(/(?=^diff --git )/m).filter((section) => section.startsWith("diff --git "));
|
|
const byName = new Map();
|
|
for (const file of files) {
|
|
byName.set(file.filename, file);
|
|
if (file.previousFilename) byName.set(file.previousFilename, file);
|
|
}
|
|
const patches = new Map();
|
|
for (const section of sections) {
|
|
const header = section.split("\n", 1)[0];
|
|
const [oldPath, newPath] = parseDiffHeader(header);
|
|
const file = byName.get(newPath) ?? byName.get(oldPath);
|
|
if (!file) throw new Error(`GitHub diff contains an unrecognized file path: ${oldPath}.`);
|
|
if (patches.has(file.filename)) throw new Error(`GitHub diff contains duplicate sections for ${file.filename}.`);
|
|
patches.set(file.filename, section.replace(/\n+$/, ""));
|
|
}
|
|
return patches;
|
|
}
|
|
|
|
async function jsonRequest(fetchImpl, url, token, signal) {
|
|
const response = await fetchImpl(url, {
|
|
headers: {
|
|
Accept: "application/vnd.github+json",
|
|
Authorization: `Bearer ${token}`,
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
"User-Agent": "github-copilot-pr-review",
|
|
},
|
|
signal,
|
|
});
|
|
if (!response.ok) {
|
|
const body = await response.json().catch(() => null);
|
|
throw Object.assign(new Error(body?.message || `GitHub request failed (${response.status}).`), { status: response.status });
|
|
}
|
|
return response.json();
|
|
}
|
|
|
|
export function createGitHubClient({ fetchImpl = fetch, tokenProvider = ghTokens } = {}) {
|
|
async function withPullRequest(url, signal, operation) {
|
|
const target = parsePrUrl(url);
|
|
const tokens = await tokenProvider(target.hostname);
|
|
let lastError;
|
|
for (const token of tokens) {
|
|
try {
|
|
const endpoint = `${target.apiBase}/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repository)}/pulls/${target.pullNumber}`;
|
|
const pull = await jsonRequest(fetchImpl, endpoint, token, signal);
|
|
return await operation({ target, token, endpoint, pull });
|
|
} catch (error) {
|
|
if (signal?.aborted) throw error;
|
|
lastError = error;
|
|
if (![401, 403, 404].includes(error.status)) throw error;
|
|
}
|
|
}
|
|
throw new Error(`No available GitHub account can read this pull request${lastError ? `: ${lastError.message}` : "."}`);
|
|
}
|
|
|
|
async function listFiles(endpoint, token, pull, signal) {
|
|
if (!Number.isInteger(pull.changed_files) || pull.changed_files < 0 || pull.changed_files > MAX_CHANGED_FILES) {
|
|
throw new Error(`Pull request changed-file count is outside the supported range (0-${MAX_CHANGED_FILES}).`);
|
|
}
|
|
const files = [];
|
|
const pages = Math.max(1, Math.ceil(pull.changed_files / 100));
|
|
for (let page = 1; page <= pages; page++) {
|
|
const batch = await jsonRequest(fetchImpl, `${endpoint}/files?per_page=100&page=${page}`, token, signal);
|
|
if (!Array.isArray(batch)) throw new Error("GitHub returned an invalid changed-file page.");
|
|
files.push(...batch);
|
|
}
|
|
if (files.length !== pull.changed_files) {
|
|
throw new Error(`GitHub reported ${pull.changed_files} changed files but returned ${files.length}. Reload the pull request.`);
|
|
}
|
|
const names = new Set();
|
|
for (const file of files) {
|
|
if (!file || typeof file.filename !== "string" || !file.filename || names.has(file.filename)) {
|
|
throw new Error("GitHub returned an invalid or duplicate changed-file record.");
|
|
}
|
|
if (!["added", "removed", "modified", "renamed", "copied", "changed", "unchanged"].includes(file.status) ||
|
|
!Number.isInteger(file.additions) || file.additions < 0 ||
|
|
!Number.isInteger(file.deletions) || file.deletions < 0) {
|
|
throw new Error(`GitHub returned invalid change metadata for ${file.filename}.`);
|
|
}
|
|
names.add(file.filename);
|
|
}
|
|
return files.map((file) => ({
|
|
filename: file.filename,
|
|
...(file.previous_filename ? { previousFilename: file.previous_filename } : {}),
|
|
status: file.status,
|
|
additions: file.additions,
|
|
deletions: file.deletions,
|
|
patchExpected: typeof file.patch === "string",
|
|
patch: "",
|
|
patchNote: "GitHub did not return a text patch for this file.",
|
|
}));
|
|
}
|
|
|
|
async function getFullDiff(endpoint, token, signal) {
|
|
const response = await fetchImpl(endpoint, {
|
|
headers: {
|
|
Accept: "application/vnd.github.diff",
|
|
Authorization: `Bearer ${token}`,
|
|
"X-GitHub-Api-Version": "2022-11-28",
|
|
"User-Agent": "github-copilot-pr-review",
|
|
},
|
|
signal,
|
|
});
|
|
return readBoundedText(response, MAX_DIFF_BYTES);
|
|
}
|
|
|
|
return {
|
|
async load(input, { signal } = {}) {
|
|
return withPullRequest(input, signal, async ({ target, token, endpoint, pull }) => {
|
|
const files = await listFiles(endpoint, token, pull, signal);
|
|
const diff = await getFullDiff(endpoint, token, signal);
|
|
const patches = mapUnifiedDiff(diff, files);
|
|
const completeFiles = files.map((file) => {
|
|
const patch = patches.get(file.filename);
|
|
if (!patch && file.patchExpected) {
|
|
throw new Error(`The full GitHub diff omitted ${file.filename}, which has a text patch in the file list. Refusing a partial snapshot.`);
|
|
}
|
|
const { patchExpected, ...record } = file;
|
|
if (!patch) return record;
|
|
const binary = patch.includes("\nBinary files ") || patch.includes("\nGIT binary patch");
|
|
return {
|
|
...record,
|
|
patch,
|
|
patchNote: binary ? "GitHub reports a binary-file change; no text patch is available." :
|
|
"Full diff section loaded directly from GitHub.",
|
|
};
|
|
});
|
|
return {
|
|
metadata: {
|
|
url: target.canonicalUrl,
|
|
title: pull.title || "Untitled pull request",
|
|
author: pull.user?.login || "unknown",
|
|
baseSha: pull.base?.sha,
|
|
headSha: pull.head?.sha,
|
|
changedFileCount: pull.changed_files,
|
|
},
|
|
files: completeFiles,
|
|
};
|
|
});
|
|
},
|
|
async revision(input, { signal } = {}) {
|
|
return withPullRequest(input, signal, ({ pull }) => ({
|
|
baseSha: pull.base?.sha,
|
|
headSha: pull.head?.sha,
|
|
}));
|
|
},
|
|
};
|
|
}
|