Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion BUG_TRACKER.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

The website uses `https://openhd.tech/api/bugs` by default. Set `BUG_REPORT_API_URL` at build time to change it. No credentials belong in the website. This extends PR #12; FleetControl owns the backend, so the unused Python requirements are removed.

`/bugs` supports search, type, status, repository and component filters with server pagination. GitHub search caps results at 1,000; narrow filters for older results. GitHub data is cached for one minute, with rate-limit backoff. `/bugs/detail?repo=OpenHD/OpenHD&number=123` shows descriptions as escaped text, metadata, related pull requests and the original link.
`/bugs` shows open website bug reports by default. The Yes/No GitHub toggle includes or hides public GitHub issues and pull requests; closed and merged entries are always hidden. Search, repository and component filters use server pagination. GitHub search caps results at 1,000; narrow filters for older results. GitHub data is cached for one minute, with rate-limit backoff. `/bugs/detail?repo=OpenHD/OpenHD&number=123` shows descriptions as escaped text, metadata, related pull requests and the original link.

`/bugs/report` requires title, description, reproduction steps, version, component, Air hardware, Ground hardware, and both archives. ZIP, tar.gz and tgz are accepted; 50 MiB per archive is the default. The separately marked incomplete-diagnostics workflow requires an explanation of at least 20 characters, holds reports privately for administrator approval, and accepts any available archive.

Expand Down
18 changes: 9 additions & 9 deletions src/pages/bugs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,30 +8,30 @@ export default function Bugs() {
const endpoint = useBugApi();
const [items, setItems] = useState<BugEntry[]>([]), [total, setTotal] = useState(0), [partial, setPartial] = useState(false);
const [error, setError] = useState(''), [loading, setLoading] = useState(false);
const [query, setQuery] = useState(''), [search, setSearch] = useState(''), [type, setType] = useState('all'), [status, setStatus] = useState('all'), [repo, setRepo] = useState(''), [repository, setRepository] = useState(''), [component, setComponent] = useState(''), [components, setComponents] = useState<string[]>([]), [page, setPage] = useState(1);
const [query, setQuery] = useState(''), [search, setSearch] = useState(''), [showGitHub, setShowGitHub] = useState(false), [repo, setRepo] = useState(''), [repository, setRepository] = useState(''), [component, setComponent] = useState(''), [components, setComponents] = useState<string[]>([]), [page, setPage] = useState(1);
useEffect(() => { if (query === search && repo === repository) return; const timer = setTimeout(() => { setSearch(query); setRepository(repo); setPage(1); }, 450); return () => clearTimeout(timer); }, [query, repo, search, repository]);
useEffect(() => { if (endpoint) bugFetch(`${endpoint}/config`).then(result => setComponents(result.components)).catch(() => {}); }, [endpoint]);
useEffect(() => {
if (!endpoint) return;
const abort = new AbortController(); setLoading(true); setError('');
const params = new URLSearchParams({q: search, type, status, repository, component, page: String(page)});
const abort = new AbortController(); setLoading(true); setError(''); setItems([]);
const params = new URLSearchParams({q: search, type: showGitHub ? 'all' : 'bug', status: 'open', repository, component, page: String(page)});
bugFetch(`${endpoint}?${params}`, {signal: abort.signal}).then(result => { setItems(result.items || []); setTotal(result.total_count); setPartial(Boolean(result.incomplete_results)); }).catch(e => { if (!abort.signal.aborted) { setError(e.message); setItems([]); } }).finally(() => { if (!abort.signal.aborted) setLoading(false); });
return () => abort.abort();
}, [endpoint, search, type, status, repository, component, page]);
}, [endpoint, search, showGitHub, repository, component, page]);
const visibleItems = items.filter(item => item.state === 'open' && !item.merged && !item.pull_request?.merged_at && (showGitHub || entryType(item) === 'Bug'));
const pages = Math.min(34, Math.ceil(Math.min(total, 1000) / 30));
return <Layout title="Bug Tracker"><main className={styles.page}>
<header className={styles.header}><div><h1>OpenHD Bug Tracker</h1><p>Community bug reports, GitHub issues and pull requests in one place.</p></div><Link className="button button--primary" to="/bugs/report">Report a bug</Link></header>
<header className={styles.header}><div><h1>OpenHD Bug Tracker</h1><p>Open bug reports, with optional GitHub issues and pull requests.</p></div><Link className="button button--primary" to="/bugs/report">Report a bug</Link></header>
<div className={styles.controls}>
<input aria-label="Search issues" placeholder="Search title or description…" value={query} maxLength={160} onChange={e => setQuery(e.target.value)}/>
<select aria-label="Type" value={type} onChange={e => {setType(e.target.value); setPage(1);}}><option value="all">All types</option><option value="bug">Bug</option><option value="issue">GitHub Issue</option><option value="pull">Pull Request</option></select>
<select aria-label="Status" value={status} onChange={e => {setStatus(e.target.value); setPage(1);}}><option value="all">All statuses</option><option value="open">Open</option><option value="closed">Closed</option><option value="merged">Merged PRs</option></select>
<label>Show GitHub issues and pull requests <select aria-label="Show GitHub issues and pull requests" value={showGitHub ? 'yes' : 'no'} onChange={e => {setShowGitHub(e.target.value === 'yes'); setPage(1);}}><option value="no">No</option><option value="yes">Yes</option></select></label>
<input aria-label="Repository" placeholder="Repository, e.g. OpenHD/QOpenHD" value={repo} onChange={e => setRepo(e.target.value)}/>
<select aria-label="Component" value={component} onChange={e => {setComponent(e.target.value); setPage(1);}}><option value="">All components</option>{components.map(c => <option key={c}>{c}</option>)}</select>
</div>
{!endpoint && <p role="status">The tracker API is being configured.</p>}
{loading && <p role="status">Loading GitHub activity…</p>}{error && <p className={styles.error} role="alert">{error}</p>}
{!loading && !error && endpoint && !items.length && <p>No entries match these filters.</p>}
{!error && <div className={styles.tableWrap}><table className={styles.table}><thead><tr><th>Number / Title</th><th>Type</th><th>Repository / Component</th><th>Status</th><th>Priority</th><th>Updated</th></tr></thead><tbody>{items.map(item => <tr key={item.id}>
{!loading && !error && endpoint && !visibleItems.length && <p>No open entries match these filters.</p>}
{!error && <div className={styles.tableWrap}><table className={styles.table}><thead><tr><th>Number / Title</th><th>Type</th><th>Repository / Component</th><th>Status</th><th>Priority</th><th>Updated</th></tr></thead><tbody>{visibleItems.map(item => <tr key={item.id}>
<td><Link to={`/bugs/detail?repo=${encodeURIComponent(item.repository_url.split('/repos/')[1])}&number=${item.number}`}>#{item.number} {item.title}</Link></td><td>{entryType(item)}</td><td>{item.repository_url.split('/repos/')[1]}<small className={styles.meta}>{labelValue(item, 'component')}</small></td><td><span className={styles.badge}>{item.pull_request?.merged_at ? 'merged' : item.state}</span></td><td>{labelValue(item, 'priority')}</td><td>{new Date(item.updated_at).toLocaleDateString()}</td>
</tr>)}</tbody></table></div>}
{pages > 0 && <nav className={styles.pagination} aria-label="Tracker pages"><button disabled={loading || page === 1} onClick={() => setPage(page - 1)}>Previous</button><span>Page {page} of {pages}</span><button disabled={loading || page >= pages} onClick={() => setPage(page + 1)}>Next</button></nav>}
Expand Down
29 changes: 25 additions & 4 deletions tests/bugs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@ async function mocks(page: Page, enabled = true) {
result = {id: 'report', number: 42, status: 'created', url: 'https://github.com/OpenHD/OpenHD/issues/42', message: 'Bug report created. Notification queued.'};
} else if (url.pathname.includes('/entry/')) result = {id: 1, number: 42, title: 'Camera fails', body: 'Steps: boot the unit. <script>unsafe()</script>', state: 'open', html_url: 'https://github.com/OpenHD/OpenHD/issues/42', updated_at: '2026-11-02T10:00:00Z', repository_url: 'https://api.github.com/repos/OpenHD/OpenHD', labels: [{name: 'website-report'}, {name: 'component:video'}]};
else result = {total_count: 70, items: [{id: 1, number: 42, title: 'Camera fails', state: 'open', html_url: 'https://github.com/OpenHD/OpenHD/issues/42', updated_at: '2026-11-02T10:00:00Z', repository_url: 'https://api.github.com/repos/OpenHD/OpenHD', labels: [{name: 'website-report'}, {name: 'component:video'}]}, {id: 2, number: 43, title: 'Fix camera', state: 'closed', html_url: 'https://github.com/OpenHD/OpenHD/pull/43', updated_at: '2026-11-02T10:00:00Z', repository_url: 'https://api.github.com/repos/OpenHD/OpenHD', labels: [], pull_request: {merged_at: '2026-11-02T10:00:00Z'}}]};
if (url.pathname === '/api/bugs' && req.method() === 'GET') {
const list = result as {items: unknown[]};
const baseEntry = {updated_at: '2026-11-02T10:00:00Z', repository_url: 'https://api.github.com/repos/OpenHD/OpenHD', labels: []};
list.items.push(
{...baseEntry, id: 3, number: 44, title: 'Radio issue', state: 'open', html_url: 'https://github.com/OpenHD/OpenHD/issues/44'},
{...baseEntry, id: 4, number: 45, title: 'Radio fix', state: 'open', html_url: 'https://github.com/OpenHD/OpenHD/pull/45', pull_request: {}},
{...baseEntry, id: 5, number: 46, title: 'Old website report', state: 'closed', html_url: 'https://github.com/OpenHD/OpenHD/issues/46', labels: [{name: 'website-report'}]},
);
}
return route.fulfill({json: result, headers});
});
}
Expand All @@ -38,13 +47,25 @@ test('normal report requires all fields and both archives before submission', as
await expect(submit).toBeEnabled(); await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); await page.evaluate(() => window.scrollTo(0, 0)); await page.screenshot({path: info.outputPath('report.png'), fullPage: true});
await submit.click(); await expect(page.getByRole('heading', {name: 'Bug #42 created'})).toBeVisible(); await expect(page.getByRole('link', {name: 'View your issue on GitHub'})).toHaveAttribute('href', 'https://github.com/OpenHD/OpenHD/issues/42');
});
test('tracker renders distinct types, filters, pagination and escaped detail content', async ({page}, info) => {
test('tracker shows open reports, optionally includes GitHub activity, and paginates details', async ({page}, info) => {
await mocks(page); await page.goto('/bugs'); await expect(page.getByRole('link', {name: '#42 Camera fails'})).toBeVisible();
await expect(page.getByRole('cell', {name: 'Pull Request', exact: true})).toBeVisible(); await expect(page.getByRole('cell', {name: 'merged', exact: true})).toBeVisible();
const request = page.waitForRequest(r => r.url().includes('/api/bugs?') && r.url().includes('type=pull'));
await page.getByLabel('Type', {exact: true}).selectOption('pull'); await request;
await expect(page.getByLabel('Show GitHub issues and pull requests')).toHaveValue('no');
await expect(page.getByRole('link', {name: '#43 Fix camera'})).toHaveCount(0);
await expect(page.getByRole('link', {name: '#44 Radio issue'})).toHaveCount(0);
await expect(page.getByRole('link', {name: '#45 Radio fix'})).toHaveCount(0);
await expect(page.getByRole('link', {name: '#46 Old website report'})).toHaveCount(0);
const request = page.waitForRequest(r => {const url = new URL(r.url()); return url.pathname === '/api/bugs' && url.searchParams.get('type') === 'all' && url.searchParams.get('status') === 'open';});
await page.getByLabel('Show GitHub issues and pull requests').selectOption('yes'); await request;
await expect(page.getByRole('link', {name: '#44 Radio issue'})).toBeVisible();
await expect(page.getByRole('link', {name: '#45 Radio fix'})).toBeVisible();
await expect(page.getByRole('link', {name: '#43 Fix camera'})).toHaveCount(0);
await expect(page.getByRole('link', {name: '#46 Old website report'})).toHaveCount(0);
await page.getByRole('button', {name: 'Next', exact: true}).click(); await expect(page.getByText('Page 2 of 3')).toBeVisible();
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth)).toBe(true); await page.evaluate(() => window.scrollTo(0, 0)); await page.screenshot({path: info.outputPath('tracker.png'), fullPage: true});
const reportsOnly = page.waitForRequest(r => {const url = new URL(r.url()); return url.pathname === '/api/bugs' && url.searchParams.get('type') === 'bug' && url.searchParams.get('status') === 'open' && url.searchParams.get('page') === '1';});
await page.getByLabel('Show GitHub issues and pull requests').selectOption('no'); await reportsOnly;
await expect(page.getByRole('link', {name: '#44 Radio issue'})).toHaveCount(0);
await expect(page.getByRole('link', {name: '#45 Radio fix'})).toHaveCount(0);
await page.getByRole('link', {name: '#42 Camera fails'}).click(); await expect(page.getByRole('heading', {name: '#42 Camera fails'})).toBeVisible();
await expect(page.getByText('Steps: boot the unit. <script>unsafe()</script>', {exact: true})).toBeVisible(); await expect(page.getByRole('heading', {name: 'Diagnostic files'})).toBeVisible();
});
Expand Down
Loading