bugpatrol 0.1.4 → 0.2.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/NOTICE +8 -0
- package/dist/bin.js +18568 -8384
- package/package.json +1 -1
- package/src/ui/app.js +399 -0
- package/src/ui/cast.js +32 -0
- package/src/ui/index.html +2 -0
- package/src/ui/style.css +97 -0
package/package.json
CHANGED
package/src/ui/app.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Bugpatrol dashboard. Vanilla ES modules on purpose: no build step means the UI
|
|
2
2
|
// is served straight from source, so `bugpatrol dashboard` works from a clone with
|
|
3
3
|
// nothing compiled but the CLI itself.
|
|
4
|
+
import { parseCast } from './cast.js';
|
|
4
5
|
import { layoutGraph } from './graph-layout.js';
|
|
5
6
|
|
|
6
7
|
const view = document.getElementById('view');
|
|
@@ -20,12 +21,16 @@ const state = {
|
|
|
20
21
|
sessions: [],
|
|
21
22
|
selectedSessionId: null,
|
|
22
23
|
sessionDetail: null,
|
|
24
|
+
flow: null,
|
|
23
25
|
appmap: null,
|
|
24
26
|
selectedScreenId: null,
|
|
25
27
|
routines: [],
|
|
26
28
|
memory: { lessons: [] },
|
|
27
29
|
runs: [],
|
|
28
30
|
selectedRunId: null,
|
|
31
|
+
reviews: [],
|
|
32
|
+
selectedReview: null,
|
|
33
|
+
reviewDetail: null,
|
|
29
34
|
detail: null,
|
|
30
35
|
live: null,
|
|
31
36
|
root: '',
|
|
@@ -39,6 +44,7 @@ try {
|
|
|
39
44
|
}
|
|
40
45
|
const graphCamera = { box: null, key: '' };
|
|
41
46
|
let showBackLinks = false;
|
|
47
|
+
let renderedReviews = '';
|
|
42
48
|
|
|
43
49
|
// ---------------------------------------------------------------- data
|
|
44
50
|
|
|
@@ -103,6 +109,15 @@ async function refresh({ keepSelection = true } = {}) {
|
|
|
103
109
|
state.sessionDetail = null;
|
|
104
110
|
}
|
|
105
111
|
}
|
|
112
|
+
if (state.view === 'flow') {
|
|
113
|
+
state.sessions = await getJson('/api/sessions');
|
|
114
|
+
if (!state.sessions.some((session) => session.id === state.selectedSessionId)) {
|
|
115
|
+
state.selectedSessionId = state.sessions[0]?.id ?? null;
|
|
116
|
+
}
|
|
117
|
+
state.flow = state.selectedSessionId
|
|
118
|
+
? await getJson(`/api/flow/${encodeURIComponent(state.selectedSessionId)}`).catch(() => null)
|
|
119
|
+
: null;
|
|
120
|
+
}
|
|
106
121
|
if (state.view === 'screens') {
|
|
107
122
|
state.appmap = await getJson('/api/appmap');
|
|
108
123
|
state.issues = await getJson('/api/issues');
|
|
@@ -113,6 +128,19 @@ async function refresh({ keepSelection = true } = {}) {
|
|
|
113
128
|
}
|
|
114
129
|
}
|
|
115
130
|
if (state.view === 'memory') state.memory = await getJson('/api/memory');
|
|
131
|
+
if (state.view === 'reviews') {
|
|
132
|
+
const reviews = await getJson('/api/reviews');
|
|
133
|
+
if (!reviews.some((review) => review.pr.number === state.selectedReview)) {
|
|
134
|
+
state.selectedReview = reviews[0]?.pr.number ?? null;
|
|
135
|
+
}
|
|
136
|
+
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
|
+
state.reviews = reviews;
|
|
142
|
+
state.reviewDetail = detail;
|
|
143
|
+
}
|
|
116
144
|
render();
|
|
117
145
|
}
|
|
118
146
|
|
|
@@ -143,7 +171,9 @@ function render() {
|
|
|
143
171
|
overview: renderOverview,
|
|
144
172
|
issues: renderIssues,
|
|
145
173
|
activity: renderActivity,
|
|
174
|
+
flow: renderFlow,
|
|
146
175
|
screens: renderScreens,
|
|
176
|
+
reviews: renderReviews,
|
|
147
177
|
memory: renderMemory,
|
|
148
178
|
checks: renderRuns,
|
|
149
179
|
};
|
|
@@ -1159,6 +1189,78 @@ function renderEventRow(event) {
|
|
|
1159
1189
|
]);
|
|
1160
1190
|
}
|
|
1161
1191
|
|
|
1192
|
+
// The flow view is what a human reads when triaging: the agent's actions, the
|
|
1193
|
+
// failed requests the app reported, and the backend logs that followed, merged
|
|
1194
|
+
// in time order. A log close behind a failure is marked correlated.
|
|
1195
|
+
function renderFlow() {
|
|
1196
|
+
const list = el('section', { class: 'card panel' }, [
|
|
1197
|
+
title('Flow'),
|
|
1198
|
+
...state.sessions.map((session) =>
|
|
1199
|
+
el(
|
|
1200
|
+
'button',
|
|
1201
|
+
{
|
|
1202
|
+
class: `list-row ${session.id === state.selectedSessionId ? 'active' : ''}`,
|
|
1203
|
+
onclick: async () => {
|
|
1204
|
+
state.selectedSessionId = session.id;
|
|
1205
|
+
await refresh();
|
|
1206
|
+
},
|
|
1207
|
+
},
|
|
1208
|
+
[
|
|
1209
|
+
roleIcon(session.role),
|
|
1210
|
+
el('span', { class: 'grow' }, [
|
|
1211
|
+
el('strong', { text: firstSentence(session.summary) || `${capital(session.role)} session` }),
|
|
1212
|
+
el('small', {
|
|
1213
|
+
class: 'muted',
|
|
1214
|
+
text: `${capital(session.role)} · ${relativeTime(session.startedAt)} · ${duration(session)}`,
|
|
1215
|
+
}),
|
|
1216
|
+
]),
|
|
1217
|
+
],
|
|
1218
|
+
),
|
|
1219
|
+
),
|
|
1220
|
+
]);
|
|
1221
|
+
const flow = state.flow;
|
|
1222
|
+
if (!flow) {
|
|
1223
|
+
return el('div', { class: 'master-detail' }, [
|
|
1224
|
+
list,
|
|
1225
|
+
el('section', {
|
|
1226
|
+
class: 'card panel empty',
|
|
1227
|
+
text: 'No flow for this session. Add a `logs:` source to bugpatrol.yml and run a patrol.',
|
|
1228
|
+
}),
|
|
1229
|
+
]);
|
|
1230
|
+
}
|
|
1231
|
+
const sources = flow.sources.length
|
|
1232
|
+
? flow.sources.map((source) =>
|
|
1233
|
+
el('div', {
|
|
1234
|
+
text: `${source.name} (${source.kind}): ${source.collected} line${source.collected === 1 ? '' : 's'}`,
|
|
1235
|
+
}),
|
|
1236
|
+
)
|
|
1237
|
+
: [
|
|
1238
|
+
el('div', {
|
|
1239
|
+
text: 'No log sources configured. Add `logs:` to bugpatrol.yml to see backend logs beside each action.',
|
|
1240
|
+
}),
|
|
1241
|
+
];
|
|
1242
|
+
return el('div', { class: 'master-detail' }, [
|
|
1243
|
+
list,
|
|
1244
|
+
el('section', { class: 'card panel' }, [
|
|
1245
|
+
title('Flow', `${capital(flow.role)} · ${relativeTime(flow.startedAt)}`),
|
|
1246
|
+
el('div', { class: 'muted session-usage' }, sources),
|
|
1247
|
+
flow.entries.length
|
|
1248
|
+
? el('div', { class: 'event-list' }, flow.entries.map(renderFlowRow))
|
|
1249
|
+
: el('div', { class: 'muted', text: 'This session recorded nothing.' }),
|
|
1250
|
+
]),
|
|
1251
|
+
]);
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
function renderFlowRow(entry) {
|
|
1255
|
+
return el('div', { class: `event-row flow-row flow-${entry.kind}${entry.correlated ? ' correlated' : ''}` }, [
|
|
1256
|
+
el('time', { text: new Date(entry.at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }),
|
|
1257
|
+
el('span', { class: 'flow-kind', text: entry.kind }),
|
|
1258
|
+
el('span', { class: 'grow truncate', text: entry.summary }),
|
|
1259
|
+
entry.level ? el('small', { class: 'muted', text: entry.level }) : null,
|
|
1260
|
+
entry.screenshot ? image(entry.screenshot, entry.summary, 'event-thumb') : null,
|
|
1261
|
+
]);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1162
1264
|
async function openScreen(id) {
|
|
1163
1265
|
state.view = 'screens';
|
|
1164
1266
|
state.selectedScreenId = id;
|
|
@@ -1527,6 +1629,303 @@ function renderScreenGraph(screens, allEdges) {
|
|
|
1527
1629
|
]);
|
|
1528
1630
|
}
|
|
1529
1631
|
|
|
1632
|
+
// ---------------------------------------------------------------- reviews
|
|
1633
|
+
|
|
1634
|
+
const VERDICT_BADGE = { proven: 'pass', 'partly-proven': 'warn', 'not-proven': 'fail', untested: 'info' };
|
|
1635
|
+
const VERDICT_WORDS = {
|
|
1636
|
+
proven: 'Proven',
|
|
1637
|
+
'partly-proven': 'Partly proven',
|
|
1638
|
+
'not-proven': 'Not proven',
|
|
1639
|
+
untested: 'Untested',
|
|
1640
|
+
};
|
|
1641
|
+
const EVIDENCE_WORDS = { replay: 'replay', assertion: 'assertion', explored: 'explored', bench: 'benchmark' };
|
|
1642
|
+
|
|
1643
|
+
async function openReview(number) {
|
|
1644
|
+
state.selectedReview = number;
|
|
1645
|
+
await refresh();
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
function renderReviews() {
|
|
1649
|
+
const list = el('div', { class: 'card panel' }, [
|
|
1650
|
+
title('Pull request reviews', `${state.reviews.length}`),
|
|
1651
|
+
state.reviews.length
|
|
1652
|
+
? null
|
|
1653
|
+
: el('p', { class: 'empty', text: 'No reviews yet. Run bugpatrol review <pr> to review a pull request.' }),
|
|
1654
|
+
...state.reviews.map((review) =>
|
|
1655
|
+
el(
|
|
1656
|
+
'button',
|
|
1657
|
+
{
|
|
1658
|
+
class: `list-row ${review.pr.number === state.selectedReview ? 'active' : ''}`,
|
|
1659
|
+
onclick: () => openReview(review.pr.number),
|
|
1660
|
+
},
|
|
1661
|
+
[
|
|
1662
|
+
el('span', { class: 'grow' }, [
|
|
1663
|
+
el('strong', { text: `#${review.pr.number} ${review.pr.title ?? ''}` }),
|
|
1664
|
+
el('span', { class: 'row-meta' }, [
|
|
1665
|
+
el('small', {
|
|
1666
|
+
class: 'muted',
|
|
1667
|
+
text: `${reviewStatus(review)} · ${relativeTime(review.endedAt ?? review.startedAt)}`,
|
|
1668
|
+
}),
|
|
1669
|
+
...Object.entries(review.verdicts ?? {})
|
|
1670
|
+
.filter(([, count]) => count)
|
|
1671
|
+
.map(([verdict, count]) =>
|
|
1672
|
+
el('span', { class: `badge ${VERDICT_BADGE[verdict]}`, text: `${count} ${verdict}` }),
|
|
1673
|
+
),
|
|
1674
|
+
review.introduced ? el('span', { class: 'badge fail', text: `${review.introduced} introduced` }) : null,
|
|
1675
|
+
]),
|
|
1676
|
+
]),
|
|
1677
|
+
],
|
|
1678
|
+
),
|
|
1679
|
+
),
|
|
1680
|
+
]);
|
|
1681
|
+
return el('div', { class: 'master-detail review-page' }, [list, renderReviewDetail()]);
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
function reviewStatus(review) {
|
|
1685
|
+
if (review.status === 'running') return 'Running';
|
|
1686
|
+
if (review.status === 'failed') return 'Failed';
|
|
1687
|
+
return review.posted ? 'Posted' : 'Finished';
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
function link(href, text) {
|
|
1691
|
+
return el('a', { href, target: '_blank', rel: 'noopener noreferrer', text });
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
function renderReviewDetail() {
|
|
1695
|
+
const detail = state.reviewDetail;
|
|
1696
|
+
if (!detail) return el('section', { class: 'card panel empty', text: 'Select a review.' });
|
|
1697
|
+
const { review, recordings } = detail;
|
|
1698
|
+
const ids = new Set((review.claims ?? []).map((finding) => finding.claim.id));
|
|
1699
|
+
const loose = recordings.filter((rec) => !rec.claimId || !ids.has(rec.claimId));
|
|
1700
|
+
const introduced = review.findings.filter((finding) => finding.verdict === 'introduced');
|
|
1701
|
+
const others = ['pre-existing', 'unclear', 'not-a-bug']
|
|
1702
|
+
.map((verdict) => [verdict, review.findings.filter((finding) => finding.verdict === verdict)])
|
|
1703
|
+
.filter(([, findings]) => findings.length);
|
|
1704
|
+
return el('article', { class: 'card panel review-detail' }, [
|
|
1705
|
+
el('div', { class: 'detail-heading' }, [
|
|
1706
|
+
el('h1', { text: `#${review.pr.number} ${review.pr.title ?? ''}` }),
|
|
1707
|
+
el('span', {
|
|
1708
|
+
class: `badge ${review.status === 'failed' ? 'fail' : review.status === 'running' ? 'warn' : 'info'}`,
|
|
1709
|
+
text: reviewStatus(review),
|
|
1710
|
+
}),
|
|
1711
|
+
]),
|
|
1712
|
+
el('div', { class: 'kv' }, [
|
|
1713
|
+
review.pr.url ? link(review.pr.url, 'Pull request') : null,
|
|
1714
|
+
review.posted ? link(review.posted.url, 'Review on GitHub') : null,
|
|
1715
|
+
el('span', { class: 'mono', title: review.head, text: `head ${shortCommit(review.head)}` }),
|
|
1716
|
+
el('span', {
|
|
1717
|
+
class: 'mono',
|
|
1718
|
+
title: review.base,
|
|
1719
|
+
text: `base ${shortCommit(review.base)}${review.baseRef ? ` (${review.baseRef})` : ''}`,
|
|
1720
|
+
}),
|
|
1721
|
+
el('span', { text: `Started ${relativeTime(review.startedAt)}` }),
|
|
1722
|
+
review.costUsd ? el('span', { text: `$${review.costUsd.toFixed(3)}` }) : null,
|
|
1723
|
+
]),
|
|
1724
|
+
review.error ? el('p', { class: 'review-error', text: review.error }) : null,
|
|
1725
|
+
review.claims
|
|
1726
|
+
? el('section', {}, [
|
|
1727
|
+
title('Claims', `${review.claims.length}`),
|
|
1728
|
+
review.claims.length
|
|
1729
|
+
? null
|
|
1730
|
+
: el('p', { class: 'muted', text: 'Bugpatrol found no claim in the pull request.' }),
|
|
1731
|
+
...review.claims.map((finding) =>
|
|
1732
|
+
claimBlock(
|
|
1733
|
+
finding,
|
|
1734
|
+
recordings.filter((rec) => rec.claimId === finding.claim.id),
|
|
1735
|
+
),
|
|
1736
|
+
),
|
|
1737
|
+
])
|
|
1738
|
+
: null,
|
|
1739
|
+
el('section', {}, [
|
|
1740
|
+
title('Introduced problems', `${introduced.length}`),
|
|
1741
|
+
introduced.length
|
|
1742
|
+
? null
|
|
1743
|
+
: el('p', { class: 'muted', text: 'Bugpatrol found no problem that this pull request introduced.' }),
|
|
1744
|
+
...introduced.map(findingBlock),
|
|
1745
|
+
]),
|
|
1746
|
+
...others.map(([verdict, findings]) =>
|
|
1747
|
+
el('details', { class: 'review-others' }, [
|
|
1748
|
+
el('summary', { text: `${capital(verdict.replace(/-/g, ' '))} (${findings.length})` }),
|
|
1749
|
+
...findings.map(findingBlock),
|
|
1750
|
+
]),
|
|
1751
|
+
),
|
|
1752
|
+
loose.length
|
|
1753
|
+
? el('section', {}, [
|
|
1754
|
+
title('Other recordings', `${loose.length}`),
|
|
1755
|
+
el(
|
|
1756
|
+
'div',
|
|
1757
|
+
{ class: 'retest-shots' },
|
|
1758
|
+
loose.map((rec) => recordingView(rec)),
|
|
1759
|
+
),
|
|
1760
|
+
])
|
|
1761
|
+
: null,
|
|
1762
|
+
review.tested ? el('section', {}, [title('What Bugpatrol tested'), markdown(review.tested)]) : null,
|
|
1763
|
+
]);
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
function claimSource(source) {
|
|
1767
|
+
if (source.kind === 'section') return 'the claims section';
|
|
1768
|
+
if (source.kind === 'commit') return `commit ${shortCommit(source.commit)}`;
|
|
1769
|
+
if (source.kind === 'issue') return `issue #${source.number}`;
|
|
1770
|
+
return `the ${source.kind}`;
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
function claimBlock(finding, recordings) {
|
|
1774
|
+
const { claim } = finding;
|
|
1775
|
+
const tested = finding.head || finding.base || recordings.length;
|
|
1776
|
+
return el('div', { class: 'claim-block' }, [
|
|
1777
|
+
el('div', { class: 'detail-heading' }, [
|
|
1778
|
+
el('h3', { text: claim.text }),
|
|
1779
|
+
el('span', {
|
|
1780
|
+
class: `badge ${VERDICT_BADGE[finding.verdict] ?? 'info'}`,
|
|
1781
|
+
text: VERDICT_WORDS[finding.verdict] ?? finding.verdict,
|
|
1782
|
+
}),
|
|
1783
|
+
]),
|
|
1784
|
+
el('div', { class: 'kv' }, [
|
|
1785
|
+
el('span', {
|
|
1786
|
+
text: `Evidence: ${finding.evidence ? (EVIDENCE_WORDS[finding.evidence] ?? finding.evidence) : 'none'}`,
|
|
1787
|
+
}),
|
|
1788
|
+
el('span', { text: `From ${claimSource(claim.source)}` }),
|
|
1789
|
+
el('span', { text: claim.platform }),
|
|
1790
|
+
]),
|
|
1791
|
+
el('p', { text: finding.reason }),
|
|
1792
|
+
finding.saw ? el('p', {}, [el('strong', { text: 'Saw: ' }), finding.saw]) : null,
|
|
1793
|
+
finding.did ? el('p', { class: 'muted', text: finding.did }) : null,
|
|
1794
|
+
claim.untestable ? el('p', { class: 'muted', text: `Not testable: ${claim.untestable}` }) : null,
|
|
1795
|
+
tested
|
|
1796
|
+
? el('div', { class: 'retest-shots' }, [
|
|
1797
|
+
buildEvidence(
|
|
1798
|
+
'Base',
|
|
1799
|
+
finding.base,
|
|
1800
|
+
recordings.filter((rec) => rec.build === 'base'),
|
|
1801
|
+
),
|
|
1802
|
+
buildEvidence(
|
|
1803
|
+
'This pull request',
|
|
1804
|
+
finding.head,
|
|
1805
|
+
recordings.filter((rec) => rec.build === 'head'),
|
|
1806
|
+
),
|
|
1807
|
+
])
|
|
1808
|
+
: null,
|
|
1809
|
+
finding.steps?.length
|
|
1810
|
+
? el('details', { class: 'recorded-path' }, [
|
|
1811
|
+
el('summary', { text: `Claim routine: ${finding.steps.length} step(s)` }),
|
|
1812
|
+
el(
|
|
1813
|
+
'ol',
|
|
1814
|
+
{},
|
|
1815
|
+
finding.steps.map((step) => el('li', { text: step })),
|
|
1816
|
+
),
|
|
1817
|
+
finding.routine ? el('code', { text: finding.routine }) : null,
|
|
1818
|
+
])
|
|
1819
|
+
: null,
|
|
1820
|
+
]);
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
/**
|
|
1824
|
+
* One build's evidence for a claim. A full video plays when there is one, then
|
|
1825
|
+
* an animated recording, then a terminal cast. Without a recording the step
|
|
1826
|
+
* screenshots show instead.
|
|
1827
|
+
*/
|
|
1828
|
+
function buildEvidence(label, replay, recordings) {
|
|
1829
|
+
const pick =
|
|
1830
|
+
recordings.find((rec) => rec.kind === 'video') ??
|
|
1831
|
+
recordings.find((rec) => rec.kind === 'image') ??
|
|
1832
|
+
recordings.find((rec) => rec.kind === 'cast');
|
|
1833
|
+
const stopped = replay?.failedStep !== undefined ? ` at step ${replay.failedStep + 1}` : '';
|
|
1834
|
+
const note = !replay
|
|
1835
|
+
? 'Not replayed.'
|
|
1836
|
+
: replay.ok
|
|
1837
|
+
? null
|
|
1838
|
+
: `Stopped${stopped}${replay.error ? `: ${replay.error}` : '.'}`;
|
|
1839
|
+
const shots = replay?.shots ?? [];
|
|
1840
|
+
const strip = () =>
|
|
1841
|
+
el(
|
|
1842
|
+
'div',
|
|
1843
|
+
{ class: 'shot-strip' },
|
|
1844
|
+
shots.map((path, index) => image(path, `${label}: ${index ? `after step ${index}` : 'before the first step'}`)),
|
|
1845
|
+
);
|
|
1846
|
+
return el('figure', { class: 'build-evidence' }, [
|
|
1847
|
+
pick ? recordingView(pick) : shots.length ? strip() : el('div', { class: 'image-empty', text: 'No capture' }),
|
|
1848
|
+
el('figcaption', {}, [label, note ? el('span', { class: 'build-note', text: ` · ${note}` }) : null]),
|
|
1849
|
+
pick && shots.length
|
|
1850
|
+
? el('details', { class: 'build-shots' }, [el('summary', { text: `Screenshots (${shots.length})` }), strip()])
|
|
1851
|
+
: null,
|
|
1852
|
+
]);
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
function recordingView(rec) {
|
|
1856
|
+
const name = rec.path.split('/').pop();
|
|
1857
|
+
if (rec.kind === 'video')
|
|
1858
|
+
return el('div', { class: 'recording' }, [
|
|
1859
|
+
el('video', { src: artifact(rec.path), controls: '', preload: 'metadata', playsinline: '' }),
|
|
1860
|
+
el('small', { class: 'muted', text: name }),
|
|
1861
|
+
]);
|
|
1862
|
+
if (rec.kind === 'image')
|
|
1863
|
+
return el('div', { class: 'recording' }, [image(rec.path, name), el('small', { class: 'muted', text: name })]);
|
|
1864
|
+
return castPlayer(rec.path);
|
|
1865
|
+
}
|
|
1866
|
+
|
|
1867
|
+
/** A terminal cast as plain text. It shows the end state; Play replays the output in time. */
|
|
1868
|
+
function castPlayer(path) {
|
|
1869
|
+
const screen = el('pre', { class: 'cast-screen', text: 'Loading the cast…' });
|
|
1870
|
+
const play = el('button', { type: 'button', class: 'filter', text: 'Play', disabled: '' });
|
|
1871
|
+
let cast;
|
|
1872
|
+
let timers = [];
|
|
1873
|
+
play.addEventListener('click', () => {
|
|
1874
|
+
for (const timer of timers) clearTimeout(timer);
|
|
1875
|
+
screen.textContent = '';
|
|
1876
|
+
// A pause longer than a second plays as one second, so a slow command does not stall the replay.
|
|
1877
|
+
let at = 0;
|
|
1878
|
+
let last = 0;
|
|
1879
|
+
timers = cast.events.map((event) => {
|
|
1880
|
+
at += Math.min(event.at - last, 1);
|
|
1881
|
+
last = event.at;
|
|
1882
|
+
return setTimeout(() => {
|
|
1883
|
+
screen.textContent += event.text;
|
|
1884
|
+
}, at * 1000);
|
|
1885
|
+
});
|
|
1886
|
+
});
|
|
1887
|
+
fetch(artifact(path))
|
|
1888
|
+
.then((response) => (response.ok ? response.text() : Promise.reject(new Error(String(response.status)))))
|
|
1889
|
+
.then((text) => {
|
|
1890
|
+
cast = parseCast(text);
|
|
1891
|
+
if (!cast) throw new Error('not an asciicast file');
|
|
1892
|
+
screen.textContent = cast.events.map((event) => event.text).join('');
|
|
1893
|
+
play.removeAttribute('disabled');
|
|
1894
|
+
})
|
|
1895
|
+
.catch((error) => {
|
|
1896
|
+
screen.textContent = `Could not read the cast: ${error.message}`;
|
|
1897
|
+
});
|
|
1898
|
+
return el('div', { class: 'recording' }, [
|
|
1899
|
+
screen,
|
|
1900
|
+
el('div', { class: 'kv' }, [play, el('small', { class: 'muted', text: path.split('/').pop() })]),
|
|
1901
|
+
]);
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
function findingBlock(finding) {
|
|
1905
|
+
return el('div', { class: 'claim-block' }, [
|
|
1906
|
+
el('div', { class: 'detail-heading' }, [el('h3', { text: finding.title }), severityChip(finding.severity)]),
|
|
1907
|
+
finding.file ? el('code', { text: `${finding.file}${finding.line ? `:${finding.line}` : ''}` }) : null,
|
|
1908
|
+
el('p', { text: finding.reason }),
|
|
1909
|
+
finding.head || finding.base
|
|
1910
|
+
? el('div', { class: 'retest-shots' }, [
|
|
1911
|
+
el('figure', {}, [image(finding.base, 'Base'), el('figcaption', { text: 'Base' })]),
|
|
1912
|
+
el('figure', {}, [image(finding.head, 'This pull request'), el('figcaption', { text: 'This pull request' })]),
|
|
1913
|
+
])
|
|
1914
|
+
: null,
|
|
1915
|
+
finding.baseNote ? el('p', { class: 'muted', text: finding.baseNote }) : null,
|
|
1916
|
+
finding.steps?.length
|
|
1917
|
+
? el('details', { class: 'recorded-path' }, [
|
|
1918
|
+
el('summary', { text: `Steps (${finding.steps.length})` }),
|
|
1919
|
+
el(
|
|
1920
|
+
'ol',
|
|
1921
|
+
{},
|
|
1922
|
+
finding.steps.map((step) => el('li', { text: step })),
|
|
1923
|
+
),
|
|
1924
|
+
])
|
|
1925
|
+
: null,
|
|
1926
|
+
]);
|
|
1927
|
+
}
|
|
1928
|
+
|
|
1530
1929
|
function renderMemory() {
|
|
1531
1930
|
const lessons = state.memory?.lessons ?? [];
|
|
1532
1931
|
const row = (lesson) =>
|
package/src/ui/cast.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Asciicast reader for the review page. Pure, so it is tested from Node.
|
|
2
|
+
// The page shows a cast as plain text, so colors and cursor moves are dropped.
|
|
3
|
+
|
|
4
|
+
// CSI sequences (colors, cursor moves), OSC sequences (titles), and other escapes.
|
|
5
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: matching terminal escapes is the point.
|
|
6
|
+
const ESCAPES = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)|\u001b[@-Z\\-_]/g;
|
|
7
|
+
|
|
8
|
+
/** Reads an asciicast v2 or v3 file. v2 times are from the start; v3 times are from the previous event. */
|
|
9
|
+
export function parseCast(source) {
|
|
10
|
+
const rows = source.split('\n').flatMap((line) => {
|
|
11
|
+
try {
|
|
12
|
+
return line.trim() ? [JSON.parse(line)] : [];
|
|
13
|
+
} catch {
|
|
14
|
+
return [];
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
const header = rows[0];
|
|
18
|
+
if (!header || Array.isArray(header) || ![2, 3].includes(header.version)) return undefined;
|
|
19
|
+
let clock = 0;
|
|
20
|
+
const events = [];
|
|
21
|
+
for (const row of rows.slice(1)) {
|
|
22
|
+
if (!Array.isArray(row) || typeof row[0] !== 'number') continue;
|
|
23
|
+
clock = header.version === 3 ? clock + row[0] : row[0];
|
|
24
|
+
if (row[1] !== 'o' || typeof row[2] !== 'string') continue;
|
|
25
|
+
events.push({ at: Math.round(clock * 1000) / 1000, text: row[2].replace(ESCAPES, '').replace(/\r\n/g, '\n') });
|
|
26
|
+
}
|
|
27
|
+
return {
|
|
28
|
+
width: header.width ?? header.term?.cols ?? 80,
|
|
29
|
+
height: header.height ?? header.term?.rows ?? 24,
|
|
30
|
+
events,
|
|
31
|
+
};
|
|
32
|
+
}
|
package/src/ui/index.html
CHANGED
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
<nav class="tabs" id="tabs">
|
|
16
16
|
<button type="button" data-view="overview" class="active">Overview</button>
|
|
17
17
|
<button type="button" data-view="issues">Issues</button>
|
|
18
|
+
<button type="button" data-view="reviews">Reviews</button>
|
|
18
19
|
<button type="button" data-view="activity">Activity</button>
|
|
20
|
+
<button type="button" data-view="flow">Flow</button>
|
|
19
21
|
<button type="button" data-view="screens">Screens</button>
|
|
20
22
|
<button type="button" data-view="memory">Memory</button>
|
|
21
23
|
<button type="button" data-view="checks">Checks</button>
|
package/src/ui/style.css
CHANGED
|
@@ -749,6 +749,29 @@ h1 {
|
|
|
749
749
|
font-size: 0.75rem;
|
|
750
750
|
white-space: nowrap;
|
|
751
751
|
}
|
|
752
|
+
.flow-row {
|
|
753
|
+
gap: 0.6rem;
|
|
754
|
+
}
|
|
755
|
+
.flow-row.correlated {
|
|
756
|
+
background: color-mix(in srgb, #b93038 12%, transparent);
|
|
757
|
+
}
|
|
758
|
+
.flow-kind {
|
|
759
|
+
flex: none;
|
|
760
|
+
width: 4.2rem;
|
|
761
|
+
font-size: 0.72rem;
|
|
762
|
+
text-transform: uppercase;
|
|
763
|
+
letter-spacing: 0.04em;
|
|
764
|
+
color: var(--muted);
|
|
765
|
+
}
|
|
766
|
+
.flow-action .flow-kind {
|
|
767
|
+
color: #3b6ea5;
|
|
768
|
+
}
|
|
769
|
+
.flow-request .flow-kind {
|
|
770
|
+
color: #ae551c;
|
|
771
|
+
}
|
|
772
|
+
.flow-log .flow-kind {
|
|
773
|
+
color: #4f7a4f;
|
|
774
|
+
}
|
|
752
775
|
.event-row details {
|
|
753
776
|
flex: none;
|
|
754
777
|
font-size: 0.75rem;
|
|
@@ -1228,3 +1251,77 @@ h1 {
|
|
|
1228
1251
|
gap: 0.35rem 0.5rem;
|
|
1229
1252
|
margin-top: 0.2rem;
|
|
1230
1253
|
}
|
|
1254
|
+
|
|
1255
|
+
/* Pull request reviews: each claim, then its evidence on the base and the pull request side by side. */
|
|
1256
|
+
.claim-block {
|
|
1257
|
+
border-top: 1px solid var(--line);
|
|
1258
|
+
padding: 0.75rem 0;
|
|
1259
|
+
}
|
|
1260
|
+
.claim-block h3 {
|
|
1261
|
+
font-size: 0.95rem;
|
|
1262
|
+
margin: 0;
|
|
1263
|
+
}
|
|
1264
|
+
.claim-block p {
|
|
1265
|
+
margin: 0.35rem 0;
|
|
1266
|
+
}
|
|
1267
|
+
.build-evidence {
|
|
1268
|
+
margin: 0;
|
|
1269
|
+
min-width: 0;
|
|
1270
|
+
}
|
|
1271
|
+
.build-evidence figcaption {
|
|
1272
|
+
text-align: center;
|
|
1273
|
+
color: var(--muted);
|
|
1274
|
+
font-size: 0.75rem;
|
|
1275
|
+
}
|
|
1276
|
+
.build-note {
|
|
1277
|
+
color: var(--major);
|
|
1278
|
+
}
|
|
1279
|
+
.build-shots summary {
|
|
1280
|
+
cursor: pointer;
|
|
1281
|
+
color: var(--muted);
|
|
1282
|
+
font-size: 0.75rem;
|
|
1283
|
+
}
|
|
1284
|
+
.shot-strip {
|
|
1285
|
+
display: flex;
|
|
1286
|
+
gap: 0.4rem;
|
|
1287
|
+
overflow-x: auto;
|
|
1288
|
+
}
|
|
1289
|
+
.retest-shots .shot-strip img {
|
|
1290
|
+
flex: 0 0 auto;
|
|
1291
|
+
width: auto;
|
|
1292
|
+
max-width: 100%;
|
|
1293
|
+
height: 220px;
|
|
1294
|
+
}
|
|
1295
|
+
.recording {
|
|
1296
|
+
display: flex;
|
|
1297
|
+
flex-direction: column;
|
|
1298
|
+
gap: 0.25rem;
|
|
1299
|
+
min-width: 0;
|
|
1300
|
+
}
|
|
1301
|
+
.recording video {
|
|
1302
|
+
width: 100%;
|
|
1303
|
+
max-height: 360px;
|
|
1304
|
+
background: var(--panel-2);
|
|
1305
|
+
}
|
|
1306
|
+
.recording small {
|
|
1307
|
+
text-align: center;
|
|
1308
|
+
}
|
|
1309
|
+
.cast-screen {
|
|
1310
|
+
margin: 0;
|
|
1311
|
+
height: 220px;
|
|
1312
|
+
overflow: auto;
|
|
1313
|
+
padding: 0.5rem;
|
|
1314
|
+
background: #0b0d10;
|
|
1315
|
+
color: #e7e9ee;
|
|
1316
|
+
font-size: 0.75rem;
|
|
1317
|
+
white-space: pre-wrap;
|
|
1318
|
+
}
|
|
1319
|
+
.review-error {
|
|
1320
|
+
color: var(--critical);
|
|
1321
|
+
}
|
|
1322
|
+
.review-others summary {
|
|
1323
|
+
cursor: pointer;
|
|
1324
|
+
color: var(--muted);
|
|
1325
|
+
font-weight: 600;
|
|
1326
|
+
margin: 0.5rem 0;
|
|
1327
|
+
}
|