bugpatrol 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -1
- package/dist/bin.js +741 -365
- package/package.json +1 -1
- package/src/ui/app.js +147 -30
- package/src/ui/style.css +17 -0
package/package.json
CHANGED
package/src/ui/app.js
CHANGED
|
@@ -44,7 +44,8 @@ try {
|
|
|
44
44
|
}
|
|
45
45
|
const graphCamera = { box: null, key: '' };
|
|
46
46
|
let showBackLinks = false;
|
|
47
|
-
let
|
|
47
|
+
let renderedState = '';
|
|
48
|
+
let renderedPage = '';
|
|
48
49
|
|
|
49
50
|
// ---------------------------------------------------------------- data
|
|
50
51
|
|
|
@@ -62,6 +63,12 @@ async function getJson(url) {
|
|
|
62
63
|
|
|
63
64
|
const artifact = (path) => `/api/artifact?path=${encodeURIComponent(path)}`;
|
|
64
65
|
|
|
66
|
+
async function fetchText(path) {
|
|
67
|
+
const response = await fetch(artifact(path));
|
|
68
|
+
if (!response.ok) throw new Error(String(response.status));
|
|
69
|
+
return response.text();
|
|
70
|
+
}
|
|
71
|
+
|
|
65
72
|
async function refresh({ keepSelection = true } = {}) {
|
|
66
73
|
const snapshot = await getJson('/api/state');
|
|
67
74
|
state.runs = snapshot.runs;
|
|
@@ -134,13 +141,13 @@ async function refresh({ keepSelection = true } = {}) {
|
|
|
134
141
|
state.selectedReview = reviews[0]?.pr.number ?? null;
|
|
135
142
|
}
|
|
136
143
|
const detail = state.selectedReview ? await getJson(`/api/reviews/${state.selectedReview}`) : null;
|
|
137
|
-
// A rebuilt page restarts a playing video, so skip the render when the review did not change.
|
|
138
|
-
const key = JSON.stringify([reviews, detail]);
|
|
139
|
-
if (key === renderedReviews && view.querySelector('.review-page')) return;
|
|
140
|
-
renderedReviews = key;
|
|
141
144
|
state.reviews = reviews;
|
|
142
145
|
state.reviewDetail = detail;
|
|
143
146
|
}
|
|
147
|
+
// A rebuilt page closes what the reader opened and restarts a playing video: skip it when nothing changed.
|
|
148
|
+
const key = JSON.stringify(state);
|
|
149
|
+
if (key === renderedState && view.childElementCount) return;
|
|
150
|
+
renderedState = key;
|
|
144
151
|
render();
|
|
145
152
|
}
|
|
146
153
|
|
|
@@ -162,6 +169,18 @@ function render() {
|
|
|
162
169
|
button.classList.toggle('active', button.dataset.view === state.view);
|
|
163
170
|
if (button.dataset.view === 'checks') button.hidden = !hasChecks;
|
|
164
171
|
}
|
|
172
|
+
// The same page, drawn again with new data, keeps what the reader opened and where they were.
|
|
173
|
+
const page = JSON.stringify([
|
|
174
|
+
state.view,
|
|
175
|
+
state.selectedIssueId,
|
|
176
|
+
state.selectedSessionId,
|
|
177
|
+
state.selectedReview,
|
|
178
|
+
state.selectedRunId,
|
|
179
|
+
state.selectedScreenId,
|
|
180
|
+
]);
|
|
181
|
+
const opened = page === renderedPage ? detailsOpen() : undefined;
|
|
182
|
+
const scroll = window.scrollY;
|
|
183
|
+
renderedPage = page;
|
|
165
184
|
view.innerHTML = '';
|
|
166
185
|
if (state.view === 'checks') {
|
|
167
186
|
const banner = renderLiveBanner();
|
|
@@ -178,6 +197,25 @@ function render() {
|
|
|
178
197
|
checks: renderRuns,
|
|
179
198
|
};
|
|
180
199
|
view.append(renderers[state.view]());
|
|
200
|
+
if (opened) {
|
|
201
|
+
for (const [key, details] of detailsByKey()) if (opened.has(key)) details.open = opened.get(key);
|
|
202
|
+
window.scrollTo(0, scroll);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** Each <details> on the page by its summary and its place among the ones with the same summary. */
|
|
207
|
+
function detailsByKey() {
|
|
208
|
+
const seen = new Map();
|
|
209
|
+
return [...view.querySelectorAll('details')].map((details) => {
|
|
210
|
+
const summary = details.querySelector('summary')?.textContent ?? '';
|
|
211
|
+
const nth = (seen.get(summary) ?? 0) + 1;
|
|
212
|
+
seen.set(summary, nth);
|
|
213
|
+
return [`${summary}#${nth}`, details];
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function detailsOpen() {
|
|
218
|
+
return new Map(detailsByKey().map(([key, details]) => [key, details.open]));
|
|
181
219
|
}
|
|
182
220
|
|
|
183
221
|
function el(tag, props = {}, children = []) {
|
|
@@ -212,7 +250,7 @@ function renderLiveBanner() {
|
|
|
212
250
|
const total = live.plannedCaptures || done;
|
|
213
251
|
return el('div', { class: 'runbanner' }, [
|
|
214
252
|
el('span', { class: 'spinner' }),
|
|
215
|
-
el('span', { text: `Run in progress
|
|
253
|
+
el('span', { text: `Run in progress: ${done}/${total} captures` }),
|
|
216
254
|
live.currentStep ? el('span', { class: 'muted', text: live.currentStep }) : null,
|
|
217
255
|
]);
|
|
218
256
|
}
|
|
@@ -329,7 +367,7 @@ function renderDetail() {
|
|
|
329
367
|
...trace.suppressed.map((s) =>
|
|
330
368
|
el('div', { class: 'finding' }, [
|
|
331
369
|
el('div', { text: `${s.ruleId} on ${s.screenId}` }),
|
|
332
|
-
el('div', { class: 'why', text: `"${s.reason}"
|
|
370
|
+
el('div', { class: 'why', text: `"${s.reason}": ${s.decidedBy}` }),
|
|
333
371
|
]),
|
|
334
372
|
),
|
|
335
373
|
]),
|
|
@@ -462,7 +500,7 @@ function renderReasoning(screen) {
|
|
|
462
500
|
'Pixel diff',
|
|
463
501
|
d.identical
|
|
464
502
|
? `identical (${d.engine}, ${Math.round(d.durationMs)}ms)`
|
|
465
|
-
: `${d.changedPixels} px across ${d.regionCount} region(s)
|
|
503
|
+
: `${d.changedPixels} px across ${d.regionCount} region(s): ` +
|
|
466
504
|
`${(d.changedFraction * 100).toFixed(3)}% of compared area (${d.engine})`,
|
|
467
505
|
);
|
|
468
506
|
add('Masked', `${(d.maskedFraction * 100).toFixed(1)}% of the screen, ` + `${d.maskedRegionCount} region(s)`);
|
|
@@ -485,7 +523,7 @@ function renderReasoning(screen) {
|
|
|
485
523
|
const d = screen.decision;
|
|
486
524
|
add(
|
|
487
525
|
'Decision layer',
|
|
488
|
-
d.decider === 'none' ? `not consulted
|
|
526
|
+
d.decider === 'none' ? `not consulted: ${d.skippedReason}` : `${d.decider}, $${(d.costUsd ?? 0).toFixed(6)}`,
|
|
489
527
|
);
|
|
490
528
|
if (d.stateChars) add('State digest', `${d.stateChars} chars (hash ${String(d.stateHash ?? '').slice(0, 12)})`);
|
|
491
529
|
for (const [key, answer] of Object.entries(d.answers ?? {})) {
|
|
@@ -532,7 +570,7 @@ function roleIcon(role) {
|
|
|
532
570
|
}
|
|
533
571
|
|
|
534
572
|
function relativeTime(value) {
|
|
535
|
-
if (!value) return '
|
|
573
|
+
if (!value) return '-';
|
|
536
574
|
const seconds = Math.max(0, Math.round((Date.now() - new Date(value).getTime()) / 1000));
|
|
537
575
|
if (!Number.isFinite(seconds)) return value;
|
|
538
576
|
if (seconds < 60) return 'just now';
|
|
@@ -590,10 +628,10 @@ function renderUsage(usage) {
|
|
|
590
628
|
el('td', { text: row.model }),
|
|
591
629
|
el('td', { text: String(row.sessions) }),
|
|
592
630
|
el('td', { text: count(row.tokens.input) }),
|
|
593
|
-
el('td', { text: row.tokens.cacheRead ? count(row.tokens.cacheRead) : '
|
|
631
|
+
el('td', { text: row.tokens.cacheRead ? count(row.tokens.cacheRead) : '-' }),
|
|
594
632
|
el('td', { text: count(row.tokens.output) }),
|
|
595
633
|
el('td', { text: count(Math.round((row.tokens.input + row.tokens.output) / Math.max(1, row.sessions))) }),
|
|
596
|
-
el('td', { text: row.tokens.listCostUsd ? money(row.tokens.listCostUsd) : '
|
|
634
|
+
el('td', { text: row.tokens.listCostUsd ? money(row.tokens.listCostUsd) : '-' }),
|
|
597
635
|
]),
|
|
598
636
|
),
|
|
599
637
|
])
|
|
@@ -826,7 +864,7 @@ function visibleIssues() {
|
|
|
826
864
|
function renderIssueDetail() {
|
|
827
865
|
const detail = state.issueDetail;
|
|
828
866
|
if (!detail) return el('section', { class: 'card panel empty', text: 'Select an issue.' });
|
|
829
|
-
const { issue, fix, candidates } = detail;
|
|
867
|
+
const { issue, fix, candidates, gaveUpAfter, attemptDiffs = {} } = detail;
|
|
830
868
|
const evidence = issue.evidence ?? {};
|
|
831
869
|
const shots = [
|
|
832
870
|
['Screenshot', evidence.screenshot],
|
|
@@ -888,21 +926,26 @@ function renderIssueDetail() {
|
|
|
888
926
|
fix
|
|
889
927
|
? el('section', { class: 'fix' }, [
|
|
890
928
|
el('div', { class: 'section-title' }, [el('h2', { text: 'Proposed fix' }), fixBadge(fix)]),
|
|
929
|
+
gaveUpAfter
|
|
930
|
+
? el('p', { class: 'gave-up', text: `The fixer stopped after ${gaveUpAfter} fix attempts.` })
|
|
931
|
+
: null,
|
|
891
932
|
markdown(fix.summary || 'No summary yet.'),
|
|
892
|
-
...(fix.
|
|
893
|
-
? [
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
.
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
933
|
+
...(fix.attempts?.length
|
|
934
|
+
? []
|
|
935
|
+
: fix.retests?.length
|
|
936
|
+
? [
|
|
937
|
+
retestBlock(fix.retests.at(-1), issue),
|
|
938
|
+
fix.retests.length > 1
|
|
939
|
+
? el('details', { class: 'earlier-retests' }, [
|
|
940
|
+
el('summary', { text: `Earlier retests (${fix.retests.length - 1})` }),
|
|
941
|
+
...fix.retests
|
|
942
|
+
.slice(0, -1)
|
|
943
|
+
.reverse()
|
|
944
|
+
.map((retest) => retestBlock(retest, issue)),
|
|
945
|
+
])
|
|
946
|
+
: null,
|
|
947
|
+
]
|
|
948
|
+
: []),
|
|
906
949
|
title('Code change'),
|
|
907
950
|
fix.diffStat ? el('pre', { text: fix.diffStat }) : null,
|
|
908
951
|
// Collapsed: the verdict and the screenshots come first; the diff is
|
|
@@ -919,6 +962,9 @@ function renderIssueDetail() {
|
|
|
919
962
|
? el('span', { text: `Worktree removed ${new Date(fix.worktreeRemovedAt).toLocaleString()}` })
|
|
920
963
|
: el('code', { text: fix.worktree }),
|
|
921
964
|
]),
|
|
965
|
+
...(fix.attempts?.length
|
|
966
|
+
? [title('Fix attempts', 'oldest first'), attemptTimeline(fix, issue, attemptDiffs)]
|
|
967
|
+
: []),
|
|
922
968
|
])
|
|
923
969
|
: null,
|
|
924
970
|
]);
|
|
@@ -1015,6 +1061,78 @@ function fixBadge(fix) {
|
|
|
1015
1061
|
return el('span', { class: `badge ${kind}`, text: label });
|
|
1016
1062
|
}
|
|
1017
1063
|
|
|
1064
|
+
const ATTEMPT_KINDS = { first: 'First fix', rerun: 'Rerun', refix: 'Refix', ci: 'CI fix' };
|
|
1065
|
+
const ATTEMPT_OUTCOMES = {
|
|
1066
|
+
proposed: ['Proposed', 'pass'],
|
|
1067
|
+
'no-change': ['No change', 'warn'],
|
|
1068
|
+
declined: ['Declined', 'warn'],
|
|
1069
|
+
'verify-failed': ['Verify failed', 'fail'],
|
|
1070
|
+
error: ['Error', 'fail'],
|
|
1071
|
+
timeout: ['Timed out', 'fail'],
|
|
1072
|
+
abandoned: ['Abandoned', 'fail'],
|
|
1073
|
+
};
|
|
1074
|
+
|
|
1075
|
+
/** Each fix attempt, oldest first, with the retests that judged it. */
|
|
1076
|
+
function attemptTimeline(fix, issue, attemptDiffs) {
|
|
1077
|
+
const last = fix.attempts.at(-1).n;
|
|
1078
|
+
const judged = (n) =>
|
|
1079
|
+
(fix.retests ?? []).filter((retest) =>
|
|
1080
|
+
// A retest from before attempts were recorded, or one naming an unknown attempt, judged the last one.
|
|
1081
|
+
fix.attempts.some((attempt) => attempt.n === retest.fixAttempt) ? retest.fixAttempt === n : n === last,
|
|
1082
|
+
);
|
|
1083
|
+
return el(
|
|
1084
|
+
'ol',
|
|
1085
|
+
{ class: 'attempt-timeline' },
|
|
1086
|
+
fix.attempts.map((attempt) => {
|
|
1087
|
+
const [outcome, kind] = attempt.outcome
|
|
1088
|
+
? (ATTEMPT_OUTCOMES[attempt.outcome] ?? [attempt.outcome, 'info'])
|
|
1089
|
+
: ['Running…', 'info'];
|
|
1090
|
+
return el('li', { class: 'attempt' }, [
|
|
1091
|
+
el('div', { class: 'detail-heading' }, [
|
|
1092
|
+
el('h3', { text: `Attempt ${attempt.n}: ${ATTEMPT_KINDS[attempt.kind] ?? attempt.kind}` }),
|
|
1093
|
+
el('span', { class: `badge ${kind}`, text: outcome }),
|
|
1094
|
+
]),
|
|
1095
|
+
el('div', { class: 'kv' }, [
|
|
1096
|
+
el('span', { text: relativeTime(attempt.startedAt) }),
|
|
1097
|
+
attempt.costUsd !== undefined ? el('span', { text: money(attempt.costUsd) }) : null,
|
|
1098
|
+
]),
|
|
1099
|
+
attempt.reason ? markdown(attempt.reason) : null,
|
|
1100
|
+
attempt.diffStat ? el('pre', { text: attempt.diffStat }) : null,
|
|
1101
|
+
attemptDiffs[attempt.n] ? lazyDiff(attemptDiffs[attempt.n]) : null,
|
|
1102
|
+
attempt.verifyOutput
|
|
1103
|
+
? el('details', { class: 'diff-details' }, [
|
|
1104
|
+
el('summary', { text: 'Show the verify output' }),
|
|
1105
|
+
el('pre', { text: attempt.verifyOutput }),
|
|
1106
|
+
])
|
|
1107
|
+
: null,
|
|
1108
|
+
...judged(attempt.n).map((retest) => retestBlock(retest, issue)),
|
|
1109
|
+
]);
|
|
1110
|
+
}),
|
|
1111
|
+
);
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** A diff file that loads the first time the reader opens it. */
|
|
1115
|
+
function lazyDiff(path) {
|
|
1116
|
+
const body = el('div', { class: 'muted', text: 'Loading…' });
|
|
1117
|
+
let loaded = false;
|
|
1118
|
+
return el(
|
|
1119
|
+
'details',
|
|
1120
|
+
{
|
|
1121
|
+
class: 'diff-details',
|
|
1122
|
+
ontoggle: (event) => {
|
|
1123
|
+
if (!event.target.open || loaded) return;
|
|
1124
|
+
loaded = true;
|
|
1125
|
+
fetchText(path)
|
|
1126
|
+
.then((diff) => body.replaceWith(diffBlock(diff)))
|
|
1127
|
+
.catch((error) => {
|
|
1128
|
+
body.textContent = `Could not read the diff: ${error.message}`;
|
|
1129
|
+
});
|
|
1130
|
+
},
|
|
1131
|
+
},
|
|
1132
|
+
[el('summary', { text: 'Show the diff' }), body],
|
|
1133
|
+
);
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1018
1136
|
function retestBlock(retest, issue) {
|
|
1019
1137
|
const before = retest.before ?? issue.evidence?.screenshot;
|
|
1020
1138
|
const pair = (shot) =>
|
|
@@ -1884,8 +2002,7 @@ function castPlayer(path) {
|
|
|
1884
2002
|
}, at * 1000);
|
|
1885
2003
|
});
|
|
1886
2004
|
});
|
|
1887
|
-
|
|
1888
|
-
.then((response) => (response.ok ? response.text() : Promise.reject(new Error(String(response.status)))))
|
|
2005
|
+
fetchText(path)
|
|
1889
2006
|
.then((text) => {
|
|
1890
2007
|
cast = parseCast(text);
|
|
1891
2008
|
if (!cast) throw new Error('not an asciicast file');
|
|
@@ -1958,7 +2075,7 @@ const lightboxCaption = document.getElementById('lightbox-caption');
|
|
|
1958
2075
|
|
|
1959
2076
|
function openLightbox(path, caption) {
|
|
1960
2077
|
lightboxImg.src = artifact(path);
|
|
1961
|
-
lightboxCaption.textContent = `${caption}
|
|
2078
|
+
lightboxCaption.textContent = `${caption}: ${path}`;
|
|
1962
2079
|
lightbox.hidden = false;
|
|
1963
2080
|
}
|
|
1964
2081
|
lightbox.addEventListener('click', () => {
|
package/src/ui/style.css
CHANGED
|
@@ -957,6 +957,23 @@ h1 {
|
|
|
957
957
|
color: var(--muted);
|
|
958
958
|
font-size: 0.75rem;
|
|
959
959
|
}
|
|
960
|
+
.attempt-timeline {
|
|
961
|
+
list-style: none;
|
|
962
|
+
margin: 0;
|
|
963
|
+
padding: 0;
|
|
964
|
+
}
|
|
965
|
+
.attempt {
|
|
966
|
+
border-left: 2px solid var(--line);
|
|
967
|
+
padding: 0.25rem 0 0.75rem 0.9rem;
|
|
968
|
+
margin-bottom: 0.5rem;
|
|
969
|
+
}
|
|
970
|
+
.attempt h3 {
|
|
971
|
+
margin: 0;
|
|
972
|
+
}
|
|
973
|
+
.gave-up {
|
|
974
|
+
color: var(--critical);
|
|
975
|
+
font-weight: 600;
|
|
976
|
+
}
|
|
960
977
|
.earlier-retests summary {
|
|
961
978
|
cursor: pointer;
|
|
962
979
|
color: var(--muted);
|