bugpatrol 0.1.5 → 0.2.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bugpatrol",
3
- "version": "0.1.5",
3
+ "version": "0.2.1",
4
4
  "description": "AI agents that explore your app, find bugs, and fix them.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
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');
@@ -27,6 +28,9 @@ const state = {
27
28
  memory: { lessons: [] },
28
29
  runs: [],
29
30
  selectedRunId: null,
31
+ reviews: [],
32
+ selectedReview: null,
33
+ reviewDetail: null,
30
34
  detail: null,
31
35
  live: null,
32
36
  root: '',
@@ -40,6 +44,7 @@ try {
40
44
  }
41
45
  const graphCamera = { box: null, key: '' };
42
46
  let showBackLinks = false;
47
+ let renderedReviews = '';
43
48
 
44
49
  // ---------------------------------------------------------------- data
45
50
 
@@ -123,6 +128,19 @@ async function refresh({ keepSelection = true } = {}) {
123
128
  }
124
129
  }
125
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
+ }
126
144
  render();
127
145
  }
128
146
 
@@ -155,6 +173,7 @@ function render() {
155
173
  activity: renderActivity,
156
174
  flow: renderFlow,
157
175
  screens: renderScreens,
176
+ reviews: renderReviews,
158
177
  memory: renderMemory,
159
178
  checks: renderRuns,
160
179
  };
@@ -1610,6 +1629,303 @@ function renderScreenGraph(screens, allEdges) {
1610
1629
  ]);
1611
1630
  }
1612
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
+
1613
1929
  function renderMemory() {
1614
1930
  const lessons = state.memory?.lessons ?? [];
1615
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,6 +15,7 @@
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>
19
20
  <button type="button" data-view="flow">Flow</button>
20
21
  <button type="button" data-view="screens">Screens</button>
package/src/ui/style.css CHANGED
@@ -1251,3 +1251,77 @@ h1 {
1251
1251
  gap: 0.35rem 0.5rem;
1252
1252
  margin-top: 0.2rem;
1253
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
+ }