Add the build link bisect tool to the website (#3902)
* Add the bisect tool * Add `cargo run explore` to open bisect tool * Website cleanup * Bug fixes * Fix again
This commit is contained in:
parent
4c45c88034
commit
f7815d0cd0
|
|
@ -6,6 +6,7 @@ pub mod requirements;
|
|||
pub enum Action {
|
||||
Run,
|
||||
Build,
|
||||
Explore(Option<String>),
|
||||
}
|
||||
|
||||
pub enum Target {
|
||||
|
|
@ -36,6 +37,7 @@ impl Task {
|
|||
let (action, args) = match args.first() {
|
||||
Some(&"build") => (Action::Build, &args[1..]),
|
||||
Some(&"run") => (Action::Run, &args[1..]),
|
||||
Some(&"explore") => (Action::Explore(args.get(1).map(|s| s.to_string())), &[] as &[&str]),
|
||||
Some(&"help") => return None,
|
||||
_ => (Action::Run, args),
|
||||
};
|
||||
|
|
@ -74,6 +76,34 @@ pub fn npm_run_in_frontend_dir(args: &str) -> Result<(), Error> {
|
|||
run_from(&format!("{npm} run {args}"), Some(&frontend_dir))
|
||||
}
|
||||
|
||||
pub fn open_url(url: &str) -> Result<(), Error> {
|
||||
#[cfg(target_os = "windows")]
|
||||
let mut cmd = process::Command::new("cmd");
|
||||
#[cfg(target_os = "windows")]
|
||||
cmd.args(["/c", "start", url]);
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
let mut cmd = process::Command::new("open");
|
||||
#[cfg(target_os = "macos")]
|
||||
cmd.arg(url);
|
||||
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
let mut cmd = process::Command::new("xdg-open");
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
cmd.arg(url);
|
||||
|
||||
let command_str = format!("{:?}", cmd);
|
||||
let exit_code = cmd
|
||||
.spawn()
|
||||
.map_err(|e| Error::Io(e, format!("Failed to spawn command '{command_str}'")))?
|
||||
.wait()
|
||||
.map_err(|e| Error::Io(e, format!("Failed to wait for command '{command_str}'")))?;
|
||||
if !exit_code.success() {
|
||||
return Err(Error::Command(command_str, exit_code));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_from(command: &str, dir: Option<&PathBuf>) -> Result<(), Error> {
|
||||
let command = command.split_whitespace().collect::<Vec<_>>();
|
||||
let mut cmd = process::Command::new(command[0]);
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ fn usage() {
|
|||
println!("<command>:");
|
||||
println!(" [run] Run the selected target (default)");
|
||||
println!(" build Build the selected target");
|
||||
println!(" explore Open an assortment of tools for exploring the codebase");
|
||||
println!(" help Show this message");
|
||||
println!("<target>:");
|
||||
println!(" [web] Web app (default)");
|
||||
|
|
@ -50,7 +51,35 @@ fn main() -> ExitCode {
|
|||
ExitCode::SUCCESS
|
||||
}
|
||||
|
||||
fn explore_usage() {
|
||||
println!();
|
||||
println!("USAGE:");
|
||||
println!(" cargo run explore <tool>");
|
||||
println!();
|
||||
println!("OPTIONS:");
|
||||
println!("<tool>:");
|
||||
println!(" bisect Binary search through recent commits to find which introduced a bug or feature");
|
||||
println!(" editor View an interactive outline of the editor's message system architecture");
|
||||
println!();
|
||||
}
|
||||
|
||||
fn run_task(task: &Task) -> Result<(), Error> {
|
||||
if let Action::Explore(tool) = &task.action {
|
||||
match tool.as_deref() {
|
||||
Some("bisect") => return open_url("https://graphite.art/volunteer/guide/codebase-overview/debugging-tips/#build-bisect-tool"),
|
||||
Some("editor") => return open_url("https://graphite.art/volunteer/guide/codebase-overview/editor-structure/#editor-outline"),
|
||||
None | Some("--help") => {
|
||||
explore_usage();
|
||||
return Ok(());
|
||||
}
|
||||
Some(other) => {
|
||||
eprintln!("Unknown explore tool: '{other}'");
|
||||
explore_usage();
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
requirements::check(task)?;
|
||||
|
||||
match (&task.action, &task.target, &task.profile) {
|
||||
|
|
@ -65,6 +94,7 @@ fn run_task(task: &Task) -> Result<(), Error> {
|
|||
profile = match action {
|
||||
Action::Run => &Profile::Debug,
|
||||
Action::Build => &Profile::Release,
|
||||
Action::Explore(_) => unreachable!(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -94,6 +124,8 @@ fn run_task(task: &Task) -> Result<(), Error> {
|
|||
|
||||
(Action::Build, Target::Cli, Profile::Debug) => run("cargo build -p graphene-cli")?,
|
||||
(Action::Build, Target::Cli, Profile::Release | Profile::Default) => run("cargo build -r -p graphene-cli")?,
|
||||
|
||||
(Action::Explore(_), _, _) => unreachable!(),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -166,7 +166,7 @@ Adam is a pragmatic problem solver with a talent for simplifying complexity. He
|
|||
</section>
|
||||
|
||||
<section>
|
||||
<div class="triptych">
|
||||
<div class="triptych" id="extras">
|
||||
|
||||
<div class="block">
|
||||
|
||||
|
|
|
|||
|
|
@ -3,31 +3,103 @@ title = "Debugging tips"
|
|||
|
||||
[extra]
|
||||
order = 2 # Page number after chapter intro
|
||||
css = ["/page/contributor-guide/bisect-tool.css"]
|
||||
js = ["/js/page/contributor-guide/bisect-tool.js"]
|
||||
+++
|
||||
|
||||
The Wasm-based editor has some unique limitations about how you are able to debug it. This page offers tips and best practices to get the most out of your problem-solving efforts.
|
||||
|
||||
## Comparing with deployed builds
|
||||
|
||||
When tracking down a bug, first check if the issue you are noticing also exists in `master` or just in your branch. Open up [dev.graphite.art](https://dev.graphite.art) which always deploys the lastest commit, as opposed to [editor.graphite.art](https://editor.graphite.art) which deploys the latest stable release. Build links for any commit may be found by clicking the "comment" icon on the right side of any commit in the [GitHub repo commits list](https://github.com/GraphiteEditor/Graphite/commits/master/).
|
||||
When tracking down a bug, first check if the issue you are noticing also exists in `master` or just in your branch. Open up [dev.graphite.art](https://dev.graphite.art) which always deploys the lastest commit, as opposed to [editor.graphite.art](https://editor.graphite.art) which deploys the latest stable release. Build links for any commit may be found by clicking the "comment" icon on the right side of any commit in the GitHub repo [commits list](https://github.com/GraphiteEditor/Graphite/commits/master/).
|
||||
|
||||
Use *Help* > *About Graphite* in the editor to view any build's Git commit hash.
|
||||
Use *Help* > *About Graphite…* in the editor to view any build's Git commit hash.
|
||||
|
||||
Beware of one potential pitfall: all deploys and build links are built with release optimizations enabled. This means some bugs (like crashes from bounds checks or debug assertions) may exist in `master` and would appear if run locally, but not in the deployed version.
|
||||
Beware of a potential pitfall: all deploys and build links are built with release optimizations enabled. This means some bugs (like crashes from bounds checks or debug assertions) may exist in `master` and would appear if run locally, but not in the deployed version.
|
||||
|
||||
## Build bisect tool
|
||||
|
||||
```sh
|
||||
# Access this quickly in the future:
|
||||
cargo run explore bisect
|
||||
```
|
||||
|
||||
This interactive tool helps you binary search through recent commits, test the build links of each, and pinpoint which change introduced a regression or added a feature.
|
||||
|
||||
<div class="bisect-tool">
|
||||
|
||||
<div class="phase active" data-phase="setup">
|
||||
<div class="setup-section">
|
||||
<div class="section-label">
|
||||
<span><strong>What are you looking for?</strong></span>
|
||||
</div>
|
||||
<label>
|
||||
<input type="radio" name="bisect-mode" value="regression" checked />
|
||||
<span>Find when a regression or bug started</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="bisect-mode" value="feature" />
|
||||
<span>Find when a feature was added or fixed</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="setup-section">
|
||||
<div class="section-label">
|
||||
<span><strong>When do you estimate this changed?</strong></span>
|
||||
</div>
|
||||
<label>
|
||||
<input type="radio" name="start-method" value="date" checked />
|
||||
<span>Date</span>
|
||||
</label>
|
||||
<label>
|
||||
<input type="radio" name="start-method" value="hash" />
|
||||
<span>Commit</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="commit-inputs">
|
||||
<div class="start-input" data-input="date">
|
||||
<input type="date" data-commit-date />
|
||||
</div>
|
||||
<div class="start-input hidden" data-input="hash">
|
||||
<input data-commit-hash placeholder="Commit hash" pattern="[0-9a-fA-F]{7,40}" />
|
||||
</div>
|
||||
<span class="button arrow" data-start-button>Begin bisect</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="phase" data-phase="bisect">
|
||||
<div class="block feature-box-narrow">
|
||||
<div class="step-header">
|
||||
<span class="step-label" data-step-label><strong>Bisect step 1</strong></span>
|
||||
<span class="go-back hidden" data-go-back-button>(<a>go back</a>)</span>
|
||||
</div>
|
||||
<div class="progress-info" data-progress-info></div>
|
||||
<div class="commit-info" data-commit-info></div>
|
||||
<span class="button arrow" data-test-build-button>Test this build</span>
|
||||
<span class="findings">After testing, what have you found?</span>
|
||||
<div class="bisect-actions">
|
||||
<span class="button" data-issue-present-button></span>
|
||||
<span class="button" data-issue-absent-button></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="error-message" data-message-box></div>
|
||||
|
||||
</div>
|
||||
|
||||
## Printing to the console
|
||||
|
||||
Use the browser console (<kbd>F12</kbd>) to check for warnings and errors. Use the Rust macro `debug!("The number is {}", some_number);` to print to the browser console. These statements should be for temporary debugging. Remove them before your code is reviewed. Print-based debugging is necessary because breakpoints are not supported in WebAssembly.
|
||||
Use the browser console (<kbd>F12</kbd>) to check for warnings and errors. In Rust, use `log::debug!("The number is {some_number}");` to print to the browser console. These statements should be for temporary debugging. Remove them before your code is reviewed. Print-based debugging is necessary because breakpoints are not supported in WebAssembly.
|
||||
|
||||
Additional print statements are available that *should* be committed:
|
||||
|
||||
- `error!()` is for descriptive user-facing error messages arising from a bug
|
||||
- `warn!()` is for non-critical problems that likely indicate a bug somewhere
|
||||
- `trace!()` is for verbose logs of ordinary internal activity, hidden by default but viewable by activating *Help* > *Debug: Print Trace Logs*
|
||||
- `log::error!()` is for descriptive user-facing error messages arising from a bug
|
||||
- `log::warn!()` is for non-critical problems that likely indicate a bug somewhere
|
||||
- `log::trace!()` is for verbose logs of ordinary internal activity, hidden by default but viewable by activating *Help* > *Debug: Print Trace Logs*
|
||||
|
||||
## Message system logs
|
||||
|
||||
To also view logs of the messages dispatched by the message system, activate *Help* > *Debug: Print Messages* > *Only Names*. Or use *Full Contents* for a more verbose view containing the actual data being passed. This is an invaluable window into the activity of the message flow and works well together with `debug!()` printouts for tracking down message-related defects.
|
||||
To also view logs of the messages dispatched by the message system, activate *Help* > *Debug: Print Messages* > *Only Names*. Or use *Full Contents* for a more verbose view containing the actual data being passed. This is an invaluable window into the activity of the message flow and works well together with `log::debug!()` printouts for tracking down message-related defects.
|
||||
|
||||
## Node/layer and document IDs
|
||||
|
||||
|
|
|
|||
|
|
@ -17,7 +17,12 @@ The dispatcher lives at the root of the editor hierarchy and acts as the owner o
|
|||
|
||||
## Editor outline
|
||||
|
||||
Click to explore the outline of the editor subsystem hierarchy which forms the structure of the editor's subsystems, state, and interactions. Bookmark this page to reference it later.
|
||||
```sh
|
||||
# Access this quickly in the future:
|
||||
cargo run explore editor
|
||||
```
|
||||
|
||||
Click to explore the outline of the editor subsystem hierarchy which forms the structure of the editor's subsystems, state, and interactions.
|
||||
|
||||
<div class="structure-outline">
|
||||
<!-- replacements::hierarchical_message_system_tree() -->
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ Please ensure Clippy is enabled. This should be set up automatically in VS Code.
|
|||
|
||||
## Naming
|
||||
|
||||
Please use descriptive variable/function/symbol names and keep abbreviations to a minimum. Prefer spelling out full words most of the time, so `gen_doc_fmt` should be written out as `generate_document_format` instead.
|
||||
Please use descriptive variable/function/symbol names and keep abbreviations to a minimum. Prefer spelling out full words most of the time, such as `generate_document_format` instead of `gen_doc_fmt`.
|
||||
|
||||
This avoids the mental burden of expanding abbreviations into semantic meaning. Monitors are wide enough to display long variable/function names, so descriptive is better than cryptic.
|
||||
|
||||
|
|
|
|||
|
|
@ -45,3 +45,7 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
#extras .button {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,139 @@
|
|||
.bisect-tool {
|
||||
margin-top: 20px;
|
||||
|
||||
.phase:not(.active) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.setup-section {
|
||||
margin-bottom: 20px;
|
||||
|
||||
label {
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.commit-inputs {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
|
||||
.start-input {
|
||||
margin: 0;
|
||||
|
||||
input {
|
||||
background: none;
|
||||
height: calc(var(--font-size-link) * 2);
|
||||
font-size: calc(var(--font-size-link) * 0.9);
|
||||
padding: 0 var(--font-size-link);
|
||||
margin: 0;
|
||||
outline: none;
|
||||
color: inherit;
|
||||
border: var(--border-thickness) solid currentColor;
|
||||
border-radius: 0;
|
||||
font-family: inherit;
|
||||
font-weight: inherit;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:focus {
|
||||
border-color: var(--color-ale);
|
||||
}
|
||||
}
|
||||
|
||||
input:not([type="date"]) {
|
||||
font-family: monospace;
|
||||
width: 320px;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.button,
|
||||
.link {
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
+ .button {
|
||||
margin-left: 10px;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
opacity: 0.4;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.feature-box-narrow {
|
||||
padding: calc(var(--feature-box-padding) / 2 * var(--variable-px));
|
||||
background: var(--color-fog);
|
||||
border-radius: 2px;
|
||||
box-sizing: border-box;
|
||||
|
||||
.step-label {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.go-back {
|
||||
margin-left: 4px;
|
||||
|
||||
&.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.progress-info {
|
||||
color: var(--color-storm);
|
||||
}
|
||||
|
||||
.commit-info {
|
||||
margin-top: 40px;
|
||||
|
||||
a {
|
||||
font-family: monospace;
|
||||
color: var(--color-crimson);
|
||||
}
|
||||
}
|
||||
|
||||
.button.arrow {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.findings {
|
||||
margin-top: 40px;
|
||||
}
|
||||
|
||||
.bisect-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
margin-top: 20px;
|
||||
|
||||
.button:empty {
|
||||
visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: none;
|
||||
margin-top: 20px;
|
||||
padding: 10px 20px;
|
||||
background: var(--color-lemon);
|
||||
|
||||
&.visible {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,586 @@
|
|||
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
||||
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 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;
|
||||
} else {
|
||||
// Absent at starting commit. The newest commit should have it (user assumes master has it).
|
||||
goodIndex = currentIndex;
|
||||
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 = 1;
|
||||
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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue