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/NOTICE +8 -0
- package/dist/bin.js +20448 -12598
- package/package.json +1 -1
- package/src/ui/app.js +316 -0
- package/src/ui/cast.js +32 -0
- package/src/ui/index.html +1 -0
- package/src/ui/style.css +74 -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');
|
|
@@ -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
|
+
}
|