Files
Keavon Chambers 55115d89d5 Fix several cases of errors appearing in the console
One appeared on load (a deprecated meta tag). One appeared when adding an Identity (Passthrough) node. One appeared when exposing the the Weight parameter of the Stroke node. This also fixes the intended behavior of the bisect script, which was involved when finding the prevenance of these errors.
2026-04-03 03:20:04 -07:00

619 lines
20 KiB
JavaScript

document.addEventListener("DOMContentLoaded", () => {
const REPO = "GraphiteEditor/Graphite";
const API = "https://api.github.com";
// =========
// API LAYER
// =========
const cache = new Map();
let rateLimitRemaining = -1;
let rateLimitReset = 0;
async function fetchJSON(/** @type {string} */ url) {
if (cache.has(url)) return cache.get(url);
const response = await fetch(url);
// Track rate limit
const remaining = response.headers.get("X-RateLimit-Remaining");
const reset = response.headers.get("X-RateLimit-Reset");
if (remaining) rateLimitRemaining = parseInt(remaining);
if (reset) rateLimitReset = parseInt(reset);
updateRateLimitWarning();
if (response.status === 404) {
cache.set(url, undefined);
return undefined;
}
if (response.status === 403) {
const resetTime = rateLimitReset ? new Date(rateLimitReset * 1000).toLocaleTimeString() : undefined;
const suffix = resetTime ? ` Resets at ${resetTime}.` : "";
throw new Error(`GitHub API rate limit exceeded.${suffix}`);
}
if (!response.ok) {
const body = await response.json().catch(() => undefined);
throw new Error(body?.message || `GitHub API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
cache.set(url, data);
return data;
}
async function fetchCommitList(/** @type {string | undefined} */ since, /** @type {string | undefined} */ until, /** @type {number | undefined} */ page) {
let url = `${API}/repos/${REPO}/commits?sha=master&per_page=100`;
if (since) url += `&since=${since}`;
if (until) url += `&until=${until}`;
if (page && page > 1) url += `&page=${page}`;
return fetchJSON(url);
}
async function fetchDeployUrl(/** @type {string} */ sha) {
const comments = await fetchJSON(`${API}/repos/${REPO}/commits/${sha}/comments`);
if (!comments || !Array.isArray(comments)) return undefined;
// Find bot comments, use the last one
const botComments = comments.filter((c) => c.user && c.user.login === "github-actions[bot]");
if (botComments.length === 0) return undefined;
const lastComment = botComments[botComments.length - 1];
const match = lastComment.body.match(/\|\s*(https:\/\/[^\s|]+)\s*\|/);
return match ? match[1] : undefined;
}
// ==============
// DOM REFERENCES
// ==============
const tool = document.querySelector(".bisect-tool");
if (!tool) return;
const phases = {
// eslint-disable-next-line quotes
setup: tool.querySelector('[data-phase="setup"]'),
// eslint-disable-next-line quotes
bisect: tool.querySelector('[data-phase="bisect"]'),
};
const elements = {
messageBox: tool.querySelector("[data-message-box]"),
hashInput: tool.querySelector("[data-input='hash']"),
dateInput: tool.querySelector("[data-input='date']"),
commitHash: tool.querySelector("[data-commit-hash]"),
commitDate: tool.querySelector("[data-commit-date]"),
startButton: tool.querySelector("[data-start-button]"),
stepLabel: tool.querySelector("[data-step-label]"),
commitInfo: tool.querySelector("[data-commit-info]"),
progressInfo: tool.querySelector("[data-progress-info]"),
testBuildButton: tool.querySelector("[data-test-build-button]"),
issuePresentButton: tool.querySelector("[data-issue-present-button]"),
issueAbsentButton: tool.querySelector("[data-issue-absent-button]"),
goBackButton: tool.querySelector("[data-go-back-button]"),
findings: tool.querySelector(".findings"),
bisectActions: tool.querySelector(".bisect-actions"),
};
// =====
// STATE
// =====
/**
* @typedef {{ sha: string, date: Date, message: string }} Commit
* @typedef {{ goodIndex: number, badIndex: number, currentIndex: number, stepCount: number, bisectPhase: string, boundaryOffset: number, boundarySearching: boolean }} HistorySnapshot
*/
let mode = "regression"; // "regression" or "feature"
let /** @type {Commit[]} */ commits = []; // Ordered oldest-first
let goodIndex = -1; // Index where issue is absent (older side)
let badIndex = -1; // Index where issue is present (newer side)
let currentIndex = -1;
let /** @type {string | undefined} */ currentDeployUrl;
let stepCount = 0;
let /** @type {HistorySnapshot[]} */ history = []; // Snapshots for undo
let bisectPhase = "boundary"; // "boundary" or "binary"
let boundaryOffset = 1; // For exponential boundary search
let boundarySearching = false; // Whether we're in exponential backward search
let startIndex = -1; // Where user started
// =======
// HELPERS
// =======
function commitToHtml(/** @type {Commit} */ commit) {
const shortHash = (/** @type {string} */ sha) => sha.slice(0, 7);
const commitUrl = (/** @type {string} */ sha) => `https://github.com/${REPO}/commit/${sha}`;
const hash = `<a href="${commitUrl(commit.sha)}" target="_blank" rel="noopener">${shortHash(commit.sha)}</a>`;
const date = commit.date.toISOString().slice(0, 10);
const message = messageToHtml(commit.message);
return `<strong>${hash}</strong> (${date}): ${message}`;
}
function messageToHtml(/** @type {string} */ message) {
if (!message) return "";
const escaped = message.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const prMatch = message.match(/\(#(\d+)\)$/);
if (prMatch) return escaped.replace(`(#${prMatch[1]})`, `(<a href="https://github.com/${REPO}/pull/${prMatch[1]}" target="_blank" rel="noopener">#${prMatch[1]}</a>)`);
return escaped;
}
function parseCommits(/** @type {any[]} */ apiCommits) {
return apiCommits.map((/** @type {any} */ c) => ({
sha: c.sha,
date: new Date(c.commit.committer.date),
message: c.commit.message.split("\n")[0],
}));
}
function setDisabled(/** @type {Element | null} */ element, /** @type {boolean} */ disabled) {
element?.classList.toggle("disabled", disabled);
}
function isDisabled(/** @type {Element | null} */ element) {
return element?.classList.contains("disabled") ?? false;
}
function showPhase(/** @type {string} */ name) {
Object.entries(phases).forEach(([key, phase]) => {
phase?.classList.toggle("active", key === name);
});
}
function showMessage(/** @type {string} */ html) {
if (elements.messageBox) elements.messageBox.innerHTML = html;
elements.messageBox?.classList.add("visible");
}
function hideMessage() {
elements.messageBox?.classList.remove("visible");
}
function updateRateLimitWarning() {
if (rateLimitRemaining >= 0 && rateLimitRemaining < 15) {
const resetTime = rateLimitReset ? new Date(rateLimitReset * 1000).toLocaleTimeString() : "unknown";
const plural = rateLimitRemaining === 1 ? "" : "s";
showMessage(`<strong>API rate limit:</strong> ${rateLimitRemaining} request${plural} remaining. Resets at ${resetTime}.`);
}
}
// ======================
// COMMIT LIST MANAGEMENT
// ======================
async function loadCommitsAroundDate(/** @type {Date} */ targetDate) {
const windowDays = 30;
const since = new Date(targetDate.getTime() - windowDays * 24 * 60 * 60 * 1000).toISOString();
const until = new Date(targetDate.getTime() + windowDays * 24 * 60 * 60 * 1000).toISOString();
// Paginate to load all commits in the window (API returns max 100 per page)
let /** @type {any[]} */ allRaw = [];
let page = 1;
while (true) {
const raw = await fetchCommitList(since, until, page);
if (!raw || raw.length === 0) break;
allRaw = allRaw.concat(raw);
if (raw.length < 100) break;
page++;
}
if (allRaw.length === 0) {
throw new Error("No commits found near that date. Try a different date.");
}
// GitHub returns newest-first, reverse to oldest-first
const fetched = parseCommits(allRaw);
fetched.reverse();
commits = fetched;
}
async function extendCommitsForward() {
if (commits.length === 0) return false;
const newest = commits[commits.length - 1];
const since = new Date(newest.date.getTime() + 1000).toISOString();
let /** @type {any[]} */ allRaw = [];
let page = 1;
while (true) {
const raw = await fetchCommitList(since, undefined, page);
if (!raw || raw.length === 0) break;
allRaw = allRaw.concat(raw);
if (raw.length < 100) break;
page++;
}
if (allRaw.length === 0) return false;
let fetched = parseCommits(allRaw);
fetched.reverse();
const existingShas = new Set(commits.map((c) => c.sha));
fetched = fetched.filter((c) => !existingShas.has(c.sha));
if (fetched.length === 0) return false;
commits = [...commits, ...fetched];
return true;
}
async function extendCommitsBackward() {
if (commits.length === 0) return false;
const oldest = commits[0];
const until = new Date(oldest.date.getTime() - 1000).toISOString();
const raw = await fetchCommitList(undefined, until, undefined);
if (!raw || raw.length === 0) return false;
let fetched = parseCommits(raw);
fetched.reverse();
const existingShas = new Set(commits.map((c) => c.sha));
fetched = fetched.filter((c) => !existingShas.has(c.sha));
if (fetched.length === 0) return false;
commits = [...fetched, ...commits];
// Adjust indices to account for prepended commits
const shift = fetched.length;
if (goodIndex >= 0) goodIndex += shift;
if (badIndex >= 0) badIndex += shift;
if (currentIndex >= 0) currentIndex += shift;
if (startIndex >= 0) startIndex += shift;
return true;
}
function findCommitIndex(/** @type {string} */ sha) {
return commits.findIndex((c) => c.sha.startsWith(sha) || sha.startsWith(c.sha));
}
// ============
// BISECT LOGIC
// ============
function pushHistory() {
history.push({
goodIndex,
badIndex,
currentIndex,
stepCount,
bisectPhase,
boundaryOffset,
boundarySearching,
});
elements.goBackButton?.classList.remove("hidden");
}
function popHistory() {
const snap = history.pop();
if (!snap) return;
goodIndex = snap.goodIndex;
badIndex = snap.badIndex;
currentIndex = snap.currentIndex;
stepCount = snap.stepCount;
bisectPhase = snap.bisectPhase;
boundaryOffset = snap.boundaryOffset;
boundarySearching = snap.boundarySearching;
elements.goBackButton?.classList.remove("hidden");
}
async function presentCommit(/** @type {number} */ index) {
currentIndex = index;
const commit = commits[index];
const deployUrl = await fetchDeployUrl(commit.sha);
currentDeployUrl = deployUrl;
if (elements.stepLabel) elements.stepLabel.innerHTML = `<strong>Bisect step ${stepCount + 1}</strong>`;
if (elements.commitInfo) {
elements.commitInfo.innerHTML = commitToHtml(commit);
}
if (goodIndex >= 0 && badIndex >= 0) {
const remaining = badIndex - goodIndex;
const stepsLeft = Math.max(1, Math.ceil(Math.log2(remaining)));
if (elements.progressInfo) {
elements.progressInfo.innerHTML = `<em>${remaining} commit${remaining === 1 ? "" : "s"} in range, ~${stepsLeft} step${stepsLeft === 1 ? "" : "s"} remaining</em>`;
}
} else {
if (elements.progressInfo) elements.progressInfo.innerHTML = "<em>Locating starting point</em>";
}
if (deployUrl) {
setDisabled(elements.testBuildButton, false);
if (elements.testBuildButton) elements.testBuildButton.textContent = "Test this build";
} else {
setDisabled(elements.testBuildButton, true);
if (elements.testBuildButton) elements.testBuildButton.textContent = "No build available";
}
// Set mode-specific button labels
if (mode === "regression") {
if (elements.issuePresentButton) elements.issuePresentButton.textContent = "Regression is present";
if (elements.issueAbsentButton) elements.issueAbsentButton.textContent = "Regression is absent";
} else {
if (elements.issuePresentButton) elements.issuePresentButton.textContent = "Feature is present";
if (elements.issueAbsentButton) elements.issueAbsentButton.textContent = "Feature is absent";
}
}
async function handleUserResponse(/** @type {boolean} */ issuePresent) {
pushHistory();
stepCount++;
if (bisectPhase === "boundary") {
await handleBoundaryResponse(issuePresent);
return;
}
// Binary search: narrow the range
if (issuePresent) badIndex = currentIndex;
else goodIndex = currentIndex;
if (badIndex - goodIndex <= 1) showResult();
else await doBinaryStep();
}
async function handleBoundaryResponse(/** @type {boolean} */ issuePresent) {
// "present" means the feature/regression exists at this commit (bad/newer side)
if (!boundarySearching) {
// First step: user tested the starting commit
if (issuePresent) {
// Exists at starting commit, so it was introduced earlier. Search backward (doubling).
badIndex = currentIndex;
boundarySearching = true;
boundaryOffset = Math.max(1, commits.length - 1 - startIndex);
} else {
// Absent at starting commit, so the issue was introduced more recently. Extend the commit list forward (towards HEAD) before narrowing.
goodIndex = currentIndex;
await extendCommitsForward();
badIndex = commits.length - 1;
bisectPhase = "binary";
}
} else if (issuePresent) {
badIndex = currentIndex;
boundaryOffset *= 2;
} else {
goodIndex = currentIndex;
bisectPhase = "binary";
}
if (bisectPhase === "binary") {
await doBinaryStep();
return;
}
// Continue boundary search backward
await doBoundaryStep();
}
async function doBoundaryStep() {
let targetIndex = startIndex - boundaryOffset;
while (targetIndex < 0) {
const extended = await extendCommitsBackward();
if (!extended) {
targetIndex = 0;
break;
}
targetIndex = startIndex - boundaryOffset;
}
// If we've hit the oldest commit and it's still marked bad, we've exhausted history
if (targetIndex <= 0 && badIndex === 0) {
showResult();
// Override the result message — we never confirmed a good baseline, so we can't pinpoint the introducing commit
if (elements.progressInfo) {
const label = mode === "regression" ? "regression" : "feature";
elements.progressInfo.innerHTML = `<em>The ${label} was already present in the oldest available commit</em>`;
}
return;
}
await presentCommit(targetIndex);
}
async function doBinaryStep() {
const mid = Math.floor((goodIndex + badIndex) / 2);
// Try to find a testable commit near the midpoint
let testIndex = mid;
let offset = 0;
while (testIndex > goodIndex && testIndex < badIndex) {
const url = await fetchDeployUrl(commits[testIndex].sha);
if (url) break;
// Try alternating sides
offset++;
if (offset % 2 === 1) testIndex = mid + Math.ceil(offset / 2);
else testIndex = mid - Math.ceil(offset / 2);
}
// If no testable commit found in range, show result as a range
if (testIndex <= goodIndex || testIndex >= badIndex) {
showResult();
return;
}
await presentCommit(testIndex);
}
function showResult() {
const heading = "Bisect complete";
// Hide interactive elements, keep the bisect phase visible
if (elements.progressInfo) elements.progressInfo.innerHTML = "";
setDisabled(elements.testBuildButton, true);
if (elements.testBuildButton instanceof HTMLElement) elements.testBuildButton.style.display = "none";
if (elements.findings instanceof HTMLElement) elements.findings.style.display = "none";
if (elements.bisectActions instanceof HTMLElement) elements.bisectActions.style.display = "none";
if (history.length > 0) elements.goBackButton?.classList.remove("hidden");
const label = mode === "regression" ? "regression" : "feature";
const single = badIndex - goodIndex <= 1;
if (elements.stepLabel) elements.stepLabel.innerHTML = `<strong>${heading}</strong>`;
if (elements.progressInfo) {
elements.progressInfo.innerHTML = single
? `<em>The ${label} was introduced in the following commit</em>`
: `<em>The ${label} was introduced in one of the following commits (not all have build links)</em>`;
}
const start = single ? badIndex : goodIndex + 1;
let html = "";
for (let i = start; i <= badIndex; i++) {
const c = commits[i];
html += single ? `${commitToHtml(c)}` : `<div>${commitToHtml(c)}</div>`;
}
if (elements.commitInfo) elements.commitInfo.innerHTML = html;
}
// ==============
// EVENT HANDLERS
// ==============
// Toggle start input visibility
function syncStartInputVisibility() {
// eslint-disable-next-line quotes
const selected = tool?.querySelector('input[name="start-method"]:checked');
const method = selected instanceof HTMLInputElement ? selected.value : "date";
elements.hashInput?.classList.toggle("hidden", method !== "hash");
elements.dateInput?.classList.toggle("hidden", method !== "date");
}
syncStartInputVisibility();
// eslint-disable-next-line quotes
tool.querySelectorAll('input[name="start-method"]').forEach((radio) => {
radio.addEventListener("change", syncStartInputVisibility);
});
// Start bisect
elements.startButton?.addEventListener("click", async () => {
if (isDisabled(elements.startButton)) return;
hideMessage();
// eslint-disable-next-line quotes
const modeInput = tool.querySelector('input[name="bisect-mode"]:checked');
// eslint-disable-next-line quotes
const methodInput = tool.querySelector('input[name="start-method"]:checked');
if (!(modeInput instanceof HTMLInputElement) || !(methodInput instanceof HTMLInputElement)) return;
mode = modeInput.value;
const method = methodInput.value;
try {
setDisabled(elements.startButton, true);
if (method === "hash") {
const hash = elements.commitHash instanceof HTMLInputElement ? elements.commitHash.value.trim() : "";
if (!/^[0-9a-fA-F]{7,40}$/.test(hash)) {
throw new Error("Please enter a valid commit hash (7-40 hex characters).");
}
// Fetch the commit to get its date
const commitData = await fetchJSON(`${API}/repos/${REPO}/commits/${hash}`);
if (!commitData) {
throw new Error("Commit not found. Check the hash and try again.");
}
const commitDate = new Date(commitData.commit.committer.date);
await loadCommitsAroundDate(commitDate);
startIndex = findCommitIndex(hash);
if (startIndex < 0) {
throw new Error("Commit not found in the master branch history.");
}
} else {
const dateStr = elements.commitDate instanceof HTMLInputElement ? elements.commitDate.value : "";
if (!dateStr) {
throw new Error("Please select a date.");
}
const date = new Date(dateStr + "T12:00:00Z");
if (date > new Date()) {
throw new Error("Date cannot be in the future.");
}
await loadCommitsAroundDate(date);
// Find the commit closest to the selected date
let closestIndex = 0;
let closestDiff = Infinity;
for (let i = 0; i < commits.length; i++) {
const diff = Math.abs(commits[i].date.getTime() - date.getTime());
if (diff < closestDiff) {
closestDiff = diff;
closestIndex = i;
}
}
startIndex = closestIndex;
}
// Reset state
goodIndex = -1;
badIndex = -1;
currentIndex = -1;
currentDeployUrl = undefined;
stepCount = 0;
history = [];
bisectPhase = "boundary";
boundaryOffset = 0;
boundarySearching = false;
elements.goBackButton?.classList.remove("hidden");
if (elements.testBuildButton instanceof HTMLElement) elements.testBuildButton.style.display = "";
if (elements.findings instanceof HTMLElement) elements.findings.style.display = "";
if (elements.bisectActions instanceof HTMLElement) elements.bisectActions.style.display = "";
// Show bisect phase and present the starting commit
showPhase("bisect");
await presentCommit(startIndex);
} catch (err) {
if (err instanceof Error) showMessage(err.message);
} finally {
setDisabled(elements.startButton, false);
}
});
// Test build button
elements.testBuildButton?.addEventListener("click", () => {
if (isDisabled(elements.testBuildButton)) return;
if (currentDeployUrl) {
window.open(currentDeployUrl, "_blank", "noopener");
}
});
// Issue response buttons
function onIssueResponse(/** @type {Element | null} */ button, /** @type {boolean} */ issuePresent) {
button?.addEventListener("click", async () => {
if (isDisabled(button)) return;
hideMessage();
try {
await handleUserResponse(issuePresent);
} catch (err) {
if (err instanceof Error) showMessage(err.message);
}
});
}
onIssueResponse(elements.issuePresentButton, true);
onIssueResponse(elements.issueAbsentButton, false);
// Go back
elements.goBackButton?.querySelector("a")?.addEventListener("click", async () => {
hideMessage();
if (history.length === 0) {
showPhase("setup");
return;
}
// Restore interactive elements that may have been hidden by showResult
if (elements.testBuildButton instanceof HTMLElement) elements.testBuildButton.style.display = "";
if (elements.findings instanceof HTMLElement) elements.findings.style.display = "";
if (elements.bisectActions instanceof HTMLElement) elements.bisectActions.style.display = "";
popHistory();
await presentCommit(currentIndex);
});
});