Files
awesome-copilot/extensions/pr-review/github.mjs
T
Bruce MoeandCopilot App 82701c24b9 Add read-only PR review canvas 🤖🤖🤖 (#4629)
* feat: add read-only PR review canvas

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

Copilot-Session: 1027c68f-7b97-422e-a18e-4e9e984ed372

* fix: load complete PR review diffs

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

Copilot-Session: 1027c68f-7b97-422e-a18e-4e9e984ed372

* fix: parse PR number from URL correctly

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1027c68f-7b97-422e-a18e-4e9e984ed372

* feat: ask about selected diff lines

Show selected lines separately from question text and verify their scope against the saved patch. Remove Git header clutter and the hunk question button.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1027c68f-7b97-422e-a18e-4e9e984ed372

* docs: refresh PR review canvas preview

Show selected-line context separately from question text using synthetic PR content.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1027c68f-7b97-422e-a18e-4e9e984ed372

---------

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: 1027c68f-7b97-422e-a18e-4e9e984ed372
2026-10-09 16:48:57 +11:00

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,
}));
},
};
}