diff --git a/apps/web/e2e/github.spec.ts b/apps/web/e2e/github.spec.ts
new file mode 100644
index 0000000..22545b7
--- /dev/null
+++ b/apps/web/e2e/github.spec.ts
@@ -0,0 +1,137 @@
+import type { Locator } from '@playwright/test';
+
+import type { Item } from '../src/github/github';
+import { chipLooks } from './chips';
+import { home, pulls, reviewRequests } from './demo';
+import { expect, test } from './fixtures';
+import { answerAllGitHub, answerGitHub, githubIssues, githubMine, githubReviews } from './github';
+import { group } from './groups';
+import { answerPulls, answerReviews, mine } from './pulls';
+
+/** How a row and its label, title, first detail line and chip look; null for a part it lacks. */
+function rowLooks(row: Locator) {
+ return row.evaluate((element) => {
+ const parts = ['.list-label', '.list-title', '.list-detail', '.chip'].map((part) => element.querySelector(part));
+ const looks = [
+ 'display',
+ 'grid-template-columns',
+ 'padding-top',
+ 'padding-left',
+ 'column-gap',
+ 'color',
+ 'font-family',
+ 'font-size',
+ 'font-weight',
+ 'line-height',
+ 'letter-spacing',
+ 'text-transform',
+ ];
+ return [element, ...parts].map(
+ (part) => part && looks.map((name) => getComputedStyle(part).getPropertyValue(name))
+ );
+ });
+}
+
+test.beforeEach(async ({ page }) => {
+ await page.route('/api/home', (route) => route.fulfill({ json: home }));
+});
+
+test('the GitHub page lists my PRs, the review requests and my issues on GitHub', async ({ page }, testInfo) => {
+ await answerAllGitHub(page);
+ await page.goto('/github');
+
+ await expect(page).toHaveTitle('GitHub · Bakhyard');
+ await expect(page.getByRole('heading', { level: 1 })).toHaveText('GitHub');
+ // The tab bar, the phone layout, has no tab for GitHub.
+ const [name, current] = testInfo.project.name === 'phone' ? ['More', 'true'] : ['GitHub', 'page'];
+ await expect(page.getByRole('link', { name, exact: true })).toHaveAttribute('aria-current', current);
+ await expect(page.getByRole('main').getByRole('heading', { level: 2 })).toHaveText([
+ 'Your PRs',
+ 'Review requests',
+ 'Assigned issues',
+ ]);
+ const lists = [
+ { head: 'Your PRs', count: '2 PRs', snapshot: githubMine },
+ { head: 'Review requests', count: '1 PR', snapshot: githubReviews },
+ { head: 'Assigned issues', count: '2 issues', snapshot: githubIssues },
+ ];
+ for (const { head, count, snapshot } of lists) {
+ const items = snapshot.items ?? [];
+ await expect(group(page, head).getByText(count)).toBeVisible();
+ const rows = group(page, head).getByRole('link');
+ await expect(rows).toHaveText(
+ items.map((item) => `#${item.id} ${item.title} ${item.repo}${item.draft ? ' Draft' : ''}`)
+ );
+ for (const [index, item] of items.entries()) {
+ await expect(rows.nth(index)).toHaveAttribute('href', item.url ?? '');
+ }
+ }
+ // A draft's chip keeps only its outline, as on the PRs page.
+ expect(await chipLooks(page.getByRole('main').locator('.chip'))).toEqual(['outlined']);
+});
+
+test('a row takes me to the issue on GitHub', async ({ page }) => {
+ await answerAllGitHub(page);
+ await page.route('https://github.com/**', (route) =>
+ route.fulfill({ contentType: 'text/html', body: '
acme/cli #88' })
+ );
+ await page.goto('/github');
+ await group(page, 'Assigned issues').getByRole('link', { name: /^#88 / }).click();
+
+ await expect(page).toHaveURL('https://github.com/acme/cli/issues/88');
+ await expect(page).toHaveTitle('acme/cli #88');
+});
+
+test('the GitHub page loads its lists again each time it shows', async ({ page }) => {
+ let issues = githubIssues;
+ await answerGitHub(page, 'mine', githubMine);
+ await answerGitHub(page, 'reviews', githubReviews);
+ await page.route('/api/github/issues', (route) => route.fulfill({ json: issues }));
+ await page.goto('/github');
+ await expect(group(page, 'Assigned issues').getByText('2 issues')).toBeVisible();
+
+ await page.getByRole('link', { name: 'Home', exact: true }).click();
+ await expect(page.getByRole('heading', { level: 1, name: 'Good morning, Alex.' })).toBeVisible();
+ // The github service finds an issue closed while the page is away.
+ issues = { ...githubIssues, items: githubIssues.items?.slice(1) };
+ await page.goBack();
+
+ await expect(group(page, 'Assigned issues').getByText('1 issue')).toBeVisible();
+ await expect(group(page, 'Assigned issues').getByRole('link')).toHaveCount(1);
+});
+
+test("a draft PR's row looks as one on the PRs page", async ({ page }) => {
+ await answerPulls(page, pulls);
+ await answerReviews(page, reviewRequests);
+ await page.goto('/pulls');
+ const pull = await rowLooks(mine(page).getByRole('link', { name: /^#41 / }));
+ expect(pull).not.toContain(null);
+
+ await answerAllGitHub(page);
+ await page.goto('/github');
+ expect(await rowLooks(group(page, 'Your PRs').getByRole('link', { name: /^#12 / }))).toEqual(pull);
+});
+
+test('a long title, repo or failure wraps on a narrow screen', async ({ page }) => {
+ await page.setViewportSize({ width: 320, height: 640 });
+ const issue: Item = {
+ id: 1234567,
+ title: `Read ${'x'.repeat(200)}`,
+ repo: `acme/${'y'.repeat(60)}`,
+ url: 'https://github.com/acme/web/issues/1234567',
+ draft: false,
+ };
+ const failure = `search issues: Get "https://api.github.com/search/issues?q=${'z'.repeat(200)}": EOF`;
+ await answerGitHub(page, 'mine', githubMine);
+ await answerGitHub(page, 'reviews', githubReviews);
+ await answerGitHub(page, 'issues', { items: [issue], searched: '2026-10-21T00:39:00Z', failure });
+ await page.goto('/github');
+
+ const issues = group(page, 'Assigned issues');
+ await expect(issues.getByRole('link')).toHaveText(`#${issue.id} ${issue.title} ${issue.repo}`);
+ await expect(issues.getByText(/^The last search failed/)).toHaveText(
+ `The last search failed: ${failure}. These issues are from 21 October at 09:39.`
+ );
+ const scroll = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
+ expect(scroll).toBe(0);
+});
diff --git a/apps/web/e2e/github.ts b/apps/web/e2e/github.ts
new file mode 100644
index 0000000..3df53bd
--- /dev/null
+++ b/apps/web/e2e/github.ts
@@ -0,0 +1,76 @@
+import type { Page } from '@playwright/test';
+
+import type { List, Snapshot } from '../src/github/github';
+
+/**
+ * A made-up user's open PRs on GitHub, the latest updated first, as the github service found them two minutes before
+ * the tests' time.
+ */
+export const githubMine: Snapshot = {
+ items: [
+ {
+ id: 214,
+ title: 'Add a dark theme to the docs site',
+ repo: 'acme/docs',
+ url: 'https://github.com/acme/docs/pull/214',
+ draft: false,
+ },
+ {
+ id: 12,
+ title: 'Share the shell config across machines',
+ repo: 'mira/dotfiles',
+ url: 'https://github.com/mira/dotfiles/pull/12',
+ draft: true,
+ },
+ ],
+ searched: '2026-10-21T00:39:00Z',
+};
+
+/** The open PRs on GitHub that wait for a made-up user's review, as the github service found them with githubMine. */
+export const githubReviews: Snapshot = {
+ items: [
+ {
+ id: 14,
+ title: 'Speed up the prompt in large repos',
+ repo: 'kai/dotfiles',
+ url: 'https://github.com/kai/dotfiles/pull/14',
+ draft: false,
+ },
+ ],
+ searched: '2026-10-21T00:39:00Z',
+};
+
+/** The open issues on GitHub assigned to a made-up user, as the github service found them with githubMine. */
+export const githubIssues: Snapshot = {
+ items: [
+ {
+ id: 88,
+ title: 'The CLI crashes on an empty config',
+ repo: 'acme/cli',
+ url: 'https://github.com/acme/cli/issues/88',
+ draft: false,
+ },
+ {
+ id: 5,
+ title: 'Document the release steps',
+ repo: 'acme/docs',
+ url: 'https://github.com/acme/docs/issues/5',
+ draft: false,
+ },
+ ],
+ searched: '2026-10-21T00:39:00Z',
+};
+
+/** Answers the GitHub page's call for the list with the snapshot, or with the status. */
+export async function answerGitHub(page: Page, list: List, snapshot: Snapshot | number) {
+ await page.route(`/api/github/${list}`, (route) =>
+ typeof snapshot === 'number' ? route.fulfill({ status: snapshot }) : route.fulfill({ json: snapshot })
+ );
+}
+
+/** Answers each of the GitHub page's calls with the made-up user's list. */
+export async function answerAllGitHub(page: Page) {
+ await answerGitHub(page, 'mine', githubMine);
+ await answerGitHub(page, 'reviews', githubReviews);
+ await answerGitHub(page, 'issues', githubIssues);
+}
diff --git a/apps/web/e2e/page-status.spec.ts b/apps/web/e2e/page-status.spec.ts
index 0448700..01ab624 100644
--- a/apps/web/e2e/page-status.spec.ts
+++ b/apps/web/e2e/page-status.spec.ts
@@ -2,6 +2,7 @@ import type { Page } from '@playwright/test';
import { home, tickets } from './demo';
import { expect, test } from './fixtures';
+import { answerGitHub } from './github';
import { inkMuted } from './ink';
import { answerDismissals, answerTickets } from './jira';
import { answerPulls, answerReviews } from './pulls';
@@ -26,6 +27,16 @@ const lines: { name: string; path: string; says: string; answer: (page: Page) =>
await page.route('/api/time/report', (route) => route.fulfill({ status: 404 }));
},
},
+ {
+ name: 'GitHub',
+ path: '/github',
+ says: "GitHub isn't set up.",
+ answer: async (page) => {
+ for (const list of ['mine', 'reviews', 'issues'] as const) {
+ await answerGitHub(page, list, 404);
+ }
+ },
+ },
{
name: 'Games',
path: '/games',
diff --git a/apps/web/src/App.pages.test.ts b/apps/web/src/App.pages.test.ts
index 5518732..21e6492 100644
--- a/apps/web/src/App.pages.test.ts
+++ b/apps/web/src/App.pages.test.ts
@@ -15,6 +15,10 @@ function answer(url: string): Response {
case '/api/pulls/mine':
case '/api/pulls/reviews':
return Response.json({ pulls: [], searched });
+ case '/api/github/mine':
+ case '/api/github/reviews':
+ case '/api/github/issues':
+ return Response.json({ items: [], searched });
case '/api/games/recent':
return Response.json({ games: [], searched });
case '/api/planner/plans':
@@ -42,12 +46,12 @@ afterEach(() => {
history.replaceState(null, '', '/');
});
-test('shows the page at the path it opens on', () => {
+test('shows the page at the path it opens on', async () => {
history.replaceState(null, '', '/github');
render(App);
expect(screen.getByRole('heading', { level: 1, name: 'GitHub' })).toBeInTheDocument();
- expect(screen.getByText('Coming soon')).toBeInTheDocument();
+ expect(await screen.findByRole('heading', { level: 2, name: 'Your PRs' })).toBeInTheDocument();
expect(document.title).toBe('GitHub · Bakhyard');
expect(screen.getByRole('link', { name: 'GitHub', current: 'page' })).toBeInTheDocument();
expect(screen.getByRole('link', { name: 'More', current: true })).toBeInTheDocument();
@@ -57,6 +61,7 @@ test.each([
['/todos', '1 todo on the list.', 'Todos · Bakhyard'],
['/pulls', 'No open pull requests.', 'Pull requests · Bakhyard'],
['/time', '7h 30m / 40h', 'Time · Bakhyard'],
+ ['/github', 'No open issues.', 'GitHub · Bakhyard'],
['/games', 'No games played in the last 14 days.', 'Games · Bakhyard'],
['/planner', 'No plans yet.', 'Planner · Bakhyard'],
])('shows the page of the app at %s, with what it loads', async (path, loaded, title) => {
diff --git a/apps/web/src/App.svelte b/apps/web/src/App.svelte
index f32ef23..b818b78 100644
--- a/apps/web/src/App.svelte
+++ b/apps/web/src/App.svelte
@@ -2,6 +2,7 @@
import { tick } from 'svelte';
import GamesPage from './games/GamesPage.svelte';
+import GitHubPage from './github/GitHubPage.svelte';
import HomePage from './home/HomePage.svelte';
import { type Feed, pollHome } from './home/poll';
import { unavailableSources } from './home/sources';
@@ -78,6 +79,8 @@ $effect(() =>
{:else if page.kind === 'time'}
+ {:else if page.kind === 'github'}
+
{:else if page.kind === 'games'}
{:else if page.kind === 'planner'}
diff --git a/apps/web/src/github/GitHubPage.svelte b/apps/web/src/github/GitHubPage.svelte
new file mode 100644
index 0000000..498de20
--- /dev/null
+++ b/apps/web/src/github/GitHubPage.svelte
@@ -0,0 +1,74 @@
+
+
+{#snippet itemRow(item: Item)}
+
+
+ #{item.id}
+
+ {item.title}
+ {item.repo}
+
+
+ {#if item.draft}
+ Draft
+ {/if}
+
+
+{/snippet}
+
+GitHub
+
+{#if Object.values(lists).every((items) => items.kind === 'off')}
+ GitHub isn't set up.
+{:else}
+
+
+
+{/if}
diff --git a/apps/web/src/github/GitHubPage.test.ts b/apps/web/src/github/GitHubPage.test.ts
new file mode 100644
index 0000000..dc62de1
--- /dev/null
+++ b/apps/web/src/github/GitHubPage.test.ts
@@ -0,0 +1,166 @@
+import { render, screen, within } from '@testing-library/svelte';
+import { afterEach, expect, test, vi } from 'vitest';
+
+import GitHubPage from './GitHubPage.svelte';
+import type { List, Snapshot } from './github';
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+type Answer = () => Promise;
+
+// Answers each list's call with its answer, or leaves it loading.
+function answer(answers: Partial>) {
+ const fetchMock = vi.fn(async (url) => {
+ const list = String(url).replace('/api/github/', '') as List;
+ return (answers[list] ?? loading)();
+ });
+ vi.stubGlobal('fetch', fetchMock);
+ return fetchMock;
+}
+
+const searched = '2026-10-26T00:41:00Z';
+const mine: Snapshot = {
+ items: [
+ {
+ id: 42,
+ title: 'Show the order history',
+ repo: 'acme/web',
+ url: 'https://github.com/acme/web/pull/42',
+ draft: false,
+ },
+ {
+ id: 9,
+ title: 'Try the new cache',
+ repo: 'me/dotfiles',
+ url: 'https://github.com/me/dotfiles/pull/9',
+ draft: true,
+ },
+ ],
+ searched,
+};
+const reviews: Snapshot = {
+ items: [
+ {
+ id: 7,
+ title: 'Rotate the certificates',
+ repo: 'acme/ops',
+ url: 'https://github.com/acme/ops/pull/7',
+ draft: false,
+ },
+ ],
+ searched,
+};
+const issues: Snapshot = {
+ items: [{ id: 3, title: 'The build fails on Windows', repo: 'acme/cli', draft: false }],
+ searched,
+};
+
+const loaded = (snapshot: Snapshot) => async () => Response.json(snapshot);
+const loading = () => Promise.withResolvers().promise;
+const off = async () => new Response('GitHub is not set up\n', { status: 404 });
+const failed = async () => new Response('Bad Gateway\n', { status: 502 });
+
+const heads = () => screen.queryAllByRole('heading', { level: 2 }).map((head) => head.textContent);
+const section = (head: string) =>
+ screen.getByRole('heading', { level: 2, name: head }).closest('section') as HTMLElement;
+
+test('loads my PRs, the review requests and my issues as it shows, in that order', async () => {
+ const fetchMock = answer({ mine: loaded(mine), reviews: loaded(reviews), issues: loaded(issues) });
+ render(GitHubPage);
+
+ expect(screen.getByRole('heading', { level: 1, name: 'GitHub' })).toBeInTheDocument();
+ expect(await screen.findByRole('link', { name: /^#42 Show the order history acme\/web$/ })).toHaveAttribute(
+ 'href',
+ 'https://github.com/acme/web/pull/42'
+ );
+ expect(await screen.findByRole('link', { name: /^#7 Rotate the certificates acme\/ops$/ })).toBeInTheDocument();
+ expect(await screen.findByText('The build fails on Windows')).toBeInTheDocument();
+ expect(heads()).toEqual(['Your PRs', 'Review requests', 'Assigned issues']);
+ expect(fetchMock.mock.calls).toEqual([['/api/github/mine'], ['/api/github/reviews'], ['/api/github/issues']]);
+});
+
+test('counts each list, marks a draft, and shows an item without a link in a row that is not one', async () => {
+ answer({ mine: loaded(mine), reviews: loaded(reviews), issues: loaded(issues) });
+ render(GitHubPage);
+
+ expect(await screen.findByRole('link', { name: /^#9 Try the new cache me\/dotfiles Draft$/ })).toBeInTheDocument();
+ expect(within(section('Your PRs')).getByText('2 PRs')).toBeInTheDocument();
+ expect(within(section('Review requests')).getByText('1 PR')).toBeInTheDocument();
+ expect(within(section('Assigned issues')).getByText('1 issue')).toBeInTheDocument();
+ expect(screen.getAllByText('Draft')).toHaveLength(1);
+ expect(screen.getByText('The build fails on Windows').closest('a')).toBeNull();
+ expect(screen.getAllByRole('link')).toHaveLength(3);
+});
+
+test('shows nothing under its head while the lists load', () => {
+ answer({});
+ render(GitHubPage);
+
+ expect(screen.getByRole('heading', { level: 1, name: 'GitHub' })).toBeInTheDocument();
+ expect(heads()).toEqual([]);
+ expect(screen.queryByText(/GitHub isn't|load/)).not.toBeInTheDocument();
+});
+
+test.each<{ list: List; snapshot: Snapshot; head: string }>([
+ { list: 'mine', snapshot: mine, head: 'Your PRs' },
+ { list: 'reviews', snapshot: reviews, head: 'Review requests' },
+ { list: 'issues', snapshot: issues, head: 'Assigned issues' },
+])('shows the $list list once it loads, without waiting for the others', async ({ list, snapshot, head }) => {
+ answer({ [list]: loaded(snapshot) });
+ render(GitHubPage);
+
+ expect(await screen.findByText(snapshot.items?.[0].title ?? '')).toBeInTheDocument();
+ expect(heads()).toEqual([head]);
+});
+
+test("says that GitHub isn't set up when every list answers 404, with no section", async () => {
+ answer({ mine: off, reviews: off, issues: off });
+ render(GitHubPage);
+
+ expect(await screen.findByText("GitHub isn't set up.")).toHaveClass('page-status');
+ expect(heads()).toEqual([]);
+});
+
+test('leaves out only the section of a list that answers 404 when the others load', async () => {
+ answer({ mine: off, reviews: loaded(reviews), issues: loaded(issues) });
+ render(GitHubPage);
+
+ expect(await screen.findByText('The build fails on Windows')).toBeInTheDocument();
+ await vi.waitFor(() => expect(heads()).toEqual(['Review requests', 'Assigned issues']));
+ expect(screen.queryByText("GitHub isn't set up.")).not.toBeInTheDocument();
+});
+
+test.each<{ list: List; head: string; emptyText: string }>([
+ { list: 'mine', head: 'Your PRs', emptyText: 'No open pull requests.' },
+ { list: 'reviews', head: 'Review requests', emptyText: 'No pull requests to review.' },
+ { list: 'issues', head: 'Assigned issues', emptyText: 'No open issues.' },
+])('says when the $list list is empty, in its section', async ({ list, head, emptyText }) => {
+ answer({
+ mine: loaded(mine),
+ reviews: loaded(reviews),
+ issues: loaded(issues),
+ [list]: loaded({ items: [], searched }),
+ });
+ render(GitHubPage);
+
+ expect(await screen.findByText(emptyText)).toBeInTheDocument();
+ expect(within(section(head)).getByText(emptyText)).toBeInTheDocument();
+ expect(screen.getAllByText(/^No /)).toHaveLength(1);
+});
+
+test.each<{ list: List; head: string; failedText: string }>([
+ { list: 'mine', head: 'Your PRs', failedText: "Couldn't load the pull requests." },
+ { list: 'reviews', head: 'Review requests', failedText: "Couldn't load the review requests." },
+ { list: 'issues', head: 'Assigned issues', failedText: "Couldn't load the issues." },
+])('says when the $list list could not load, in its section, beside the others', async ({ list, head, failedText }) => {
+ answer({ mine: loaded(mine), reviews: loaded(reviews), issues: loaded(issues), [list]: failed });
+ render(GitHubPage);
+
+ expect(await screen.findByText(failedText)).toBeInTheDocument();
+ expect(within(section(head)).getByText(failedText)).toBeInTheDocument();
+ await vi.waitFor(() => expect(heads()).toEqual(['Your PRs', 'Review requests', 'Assigned issues']));
+ expect(screen.getAllByText(/^Couldn't load/)).toHaveLength(1);
+ expect(screen.queryByText("GitHub isn't set up.")).not.toBeInTheDocument();
+});
diff --git a/apps/web/src/github/github.test.ts b/apps/web/src/github/github.test.ts
new file mode 100644
index 0000000..dd68629
--- /dev/null
+++ b/apps/web/src/github/github.test.ts
@@ -0,0 +1,32 @@
+import { afterEach, expect, test, vi } from 'vitest';
+
+import { type List, loadItems, type Snapshot } from './github';
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+});
+
+test.each<{ list: List; url: string }>([
+ { list: 'mine', url: '/api/github/mine' },
+ { list: 'reviews', url: '/api/github/reviews' },
+ { list: 'issues', url: '/api/github/issues' },
+])('loads the $list list from the github service', async ({ list, url }) => {
+ const snapshot: Snapshot = {
+ items: [{ id: 42, title: 'Show the order history', repo: 'acme/web', draft: false }],
+ searched: '2026-10-26T00:41:00Z',
+ };
+ const fetchMock = vi.fn().mockResolvedValue(Response.json(snapshot));
+ vi.stubGlobal('fetch', fetchMock);
+
+ expect(await loadItems(list)).toEqual({ kind: 'loaded', snapshot });
+ expect(fetchMock).toHaveBeenCalledExactlyOnceWith(url);
+});
+
+test('says when GitHub is off, since the github service answers 404 without it', async () => {
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue(new Response('GitHub is not set up\n', { status: 404 }))
+ );
+
+ expect(await loadItems('issues')).toEqual({ kind: 'off' });
+});
diff --git a/apps/web/src/github/github.ts b/apps/web/src/github/github.ts
new file mode 100644
index 0000000..c11d402
--- /dev/null
+++ b/apps/web/src/github/github.ts
@@ -0,0 +1,34 @@
+import { loadSnapshot, type Polled, type Polls } from '../shell/snapshot';
+
+/** An open pull request or issue on GitHub, as the github service lists it (decision 204). */
+export interface Item {
+ /** The PR's or the issue's number, unique only within its repository. */
+ id: number;
+ title: string;
+ /** The full name of the repository, such as acme/web. */
+ repo: string;
+ /** Its page on GitHub; none when GitHub's link to it isn't https. */
+ url?: string;
+ /** Whether it's a draft PR; an issue never is. */
+ draft: boolean;
+}
+
+/**
+ * The body of GET /api/github/mine, /api/github/reviews and /api/github/issues: what the github service last found
+ * on GitHub (decision 204).
+ */
+export interface Snapshot extends Polls {
+ /** The open PRs or issues from the last search that succeeded, the latest updated first; none before the first. */
+ items?: Item[];
+}
+
+/** A list of the GitHub page: loading, off when GitHub isn't set up, failed to load, or loaded. */
+export type Items = Polled;
+
+/** The GitHub page's lists: my open PRs, the open PRs that wait for my review, and the open issues assigned to me. */
+export type List = 'mine' | 'reviews' | 'issues';
+
+/** Loads one of the lists; never fails, since a failed load is one of the outcomes. */
+export function loadItems(list: List): Promise {
+ return loadSnapshot(`/api/github/${list}`);
+}
diff --git a/apps/web/src/shell/page.test.ts b/apps/web/src/shell/page.test.ts
index 8b66384..80000f6 100644
--- a/apps/web/src/shell/page.test.ts
+++ b/apps/web/src/shell/page.test.ts
@@ -37,7 +37,10 @@ test.each([
{ path: '/time/', want: 'time' },
{ path: '/time/2026-10-21', want: 'time' },
{ path: '/time-archive', want: 'missing' },
- { path: '/github', want: 'app github' },
+ { path: '/github', want: 'github' },
+ { path: '/github/', want: 'github' },
+ { path: '/github/acme/web', want: 'github' },
+ { path: '/github-archive', want: 'missing' },
{ path: '/games', want: 'games' },
{ path: '/games/', want: 'games' },
{ path: '/games/zelda', want: 'games' },
@@ -65,6 +68,7 @@ test.each([
{ page: pageAt('/notes/Daily/To%20do'), want: 'To do · Bakhyard' },
{ page: pageAt('/todos'), want: 'Todos · Bakhyard' },
{ page: pageAt('/time'), want: 'Time · Bakhyard' },
+ { page: pageAt('/github'), want: 'GitHub · Bakhyard' },
{ page: pageAt('/games'), want: 'Games · Bakhyard' },
{ page: pageAt('/planner'), want: 'Planner · Bakhyard' },
{ page: pageAt('/pulls/42'), want: 'Pull requests · Bakhyard' },
diff --git a/apps/web/src/shell/page.ts b/apps/web/src/shell/page.ts
index 53d28e2..1ddf2d7 100644
--- a/apps/web/src/shell/page.ts
+++ b/apps/web/src/shell/page.ts
@@ -10,6 +10,7 @@ export type Page =
| { kind: 'todos' }
| { kind: 'pulls' }
| { kind: 'time' }
+ | { kind: 'github' }
| { kind: 'games' }
| { kind: 'planner' }
| { kind: 'nudge' }
@@ -21,15 +22,16 @@ const notesHref = appHref('notes');
const todosHref = appHref('todos');
const pullsHref = appHref('pulls');
const timeHref = appHref('time');
+const githubHref = appHref('github');
const gamesHref = appHref('games');
const plannerHref = appHref('planner');
const nudgeHref = appHref('nudge');
/**
* The page at path. The Notes app's path shows the notes, and the paths under it their notes; the Todos app's paths
- * show the todos, the PRs app's my PRs, the Time app's the time report, the Games app's the games I played lately,
- * the Planner app's the plans, and the Nudge app's the agents' questions; another app's paths show its page, as the nav
- * marks it.
+ * show the todos, the PRs app's my PRs, the Time app's the time report, the GitHub app's my PRs and issues on GitHub,
+ * the Games app's the games I played lately, the Planner app's the plans, and the Nudge app's the agents' questions;
+ * another app's paths show its page, as the nav marks it.
*/
export function pageAt(path: string): Page {
if (path === '/') {
@@ -50,6 +52,9 @@ export function pageAt(path: string): Page {
if (isCurrent(timeHref, path)) {
return { kind: 'time' };
}
+ if (isCurrent(githubHref, path)) {
+ return { kind: 'github' };
+ }
if (isCurrent(gamesHref, path)) {
return { kind: 'games' };
}
@@ -92,6 +97,8 @@ export function pageTitle(page: Page): string {
return `Pull requests · ${siteName}`;
case 'time':
return `Time · ${siteName}`;
+ case 'github':
+ return `GitHub · ${siteName}`;
case 'games':
return `Games · ${siteName}`;
case 'planner':
diff --git a/docs/plan.md b/docs/plan.md
index e71afb1..560e86e 100644
--- a/docs/plan.md
+++ b/docs/plan.md
@@ -189,7 +189,7 @@ The v1.2 design in §4 to §6 stays as reference. A piece of it is built only wh
| `github-gateway` | The gateway forwards `/api/github` to the `github` service, behind both of its locks. The fifth of `github-widgets`' seven steps (decision 200) | Pending |
| `shared-polled-section` | The PRs page's lists and the Todos page's Jira tickets show a poller's list through one component in the shell, `PolledSection`, for the GitHub page's lists, its third user; nothing changes on screen. The sixth of `github-widgets`' eight steps (decision 205) | Done (#184) |
| `shared-page-status` | The PRs, Time, Nudge and Todos pages draw the line that takes their sections' place, such as "Bitbucket isn't set up.", from one global class, for the GitHub page's, its fifth user; on phones, the Nudge page's line now leaves a section's 48 px below it. The seventh of `github-widgets`' nine steps (decision 206) | Done (#187) |
-| `github-page` | The app shows the three lists, on a page of their own at `/github` or beside Bitbucket's on the PRs page, as the user chooses. The eighth of `github-widgets`' nine steps (decisions 200, 205 and 206) | Pending |
+| `github-page` | The app shows the three lists on a page of their own at `/github`, as the user chose. The eighth of `github-widgets`' nine steps (decision 207) | Done (#191) |
| `github-attention` | The `github` service answers the attention contract, so the Home's "Needs you" lists what the user chooses of the review requests, the issues assigned to me and my PRs. The last of `github-widgets`' nine steps (decisions 200, 205 and 206) | Pending |
| `games-exophase` | The games widgets from Exophase (§8.2 Phase 5), by a session of their own in `../bakhyard-games/`, which adds its steps' rows under this one (decision 195) | In progress |
| `games-plan` | `games-exophase` splits in eleven steps: the Exophase client, the cache, the service, the Twitch token, the IGDB search, the covers, the laptop, the gateway, the page and its covers; the games list, the covers' matches and the Twitch token stay in memory. The first of `games-exophase`'s eleven steps (decision 300) | Done (#168) |
@@ -1022,6 +1022,7 @@ Cards are solid `--surface`, so they sit on the grain like paper on a desk. Thes
- My open Jira tickets have a section of their own after the priority groups, or after "Couldn't load the todos.", once the todos show or fail to load (built in `jira-list`, decision 124): "Jira *tickets*", with the count as the note, "1 ticket" or "3 tickets". Its card holds a list row per ticket, in the service's order, that links to the ticket in Jira: the key as the row's mono label, the summary as its title, and the status at its end in a chip, filled for the In Progress category and outlined for the others, at most 160 px wide and wrapping inside. With no ticket, the card says "No open tickets." in `--ink-muted` and the note is left out. When the last search failed, a muted line 20 px above the card says "The last search failed: .", then "These tickets are from 21 October at 09:38." when it has tickets from an earlier search. Any other failure to load: "Couldn't load the tickets." under the head. Without Jira, no section.
- Pull requests, built in `pulls-page` (decision 149): "Pull requests", with no summary. Under the head, my open PRs sit in a section, "Your *PRs*", with the count as the note, "1 PR" or "3 PRs". Its card holds a list row per PR, in the service's order, the latest updated first, that links to the PR on Bitbucket: the number as the row's mono label ("#128"), the title as its title, the repository's full name ("acme/web") under it as its detail, and, on a draft, a Draft chip at its end, outlined as the Jira section's chips that aren't In Progress. Under the repository, built in `approvals-page` (decision 174), a second detail line says where the PR stands with its reviewers, a group for each verdict that someone has given, the most urgent first, split by " · ": "Changes requested by Noor Aziz · Waiting for Tom Berg and Leo Hart · Approved by Mia Chen"; with no reviewers, "No reviewers". A PR without a link shows in a row that isn't a link. With no PR, the card says "No open pull requests." in `--ink-muted` and the note is left out. When the last search failed, a muted line 20 px above the card says "The last search failed: .", then "These PRs are from 21 October at 09:38." when it has PRs from an earlier search. Any other failure to load: "Couldn't load the pull requests." under the section's head. Under it, built in `reviews-page` (decision 172), the PRs that wait for my review sit in a section of their own, "Review *requests*", 64 px below my PRs' card (48 px on a phone), with the same rows, without the review line, and the same note and failed-search lines. With none, its card says "No pull requests to review."; any other failure to load: "Couldn't load the review requests." under its head. Each section shows once its own list loads, so neither waits for the other, and nothing shows for a list while it loads. Without Bitbucket, when both lists answer 404, "Bitbucket isn't set up." in `--ink-muted` under the page head, with no section; when only one does, the page leaves its section out.
- Time, built in `time-page` (decision 184) and `fill-day` (decision 194): "Time", with no summary. Under the head, a form fills a day (below). Under it, this week's hours sit in a section, "This *week*", on the card that the Home's "This week" shows (below), without its link. Under it, 64 px below the card (48 px on a phone), the last 7 days sit in a section, "Last 7 *days*", with the missing days' count as the note, "1 day missing" or "3 days missing", left out when none is. Its card holds a list row per day, in the report's order, the earliest first, and none is a link: the date as the row's mono label ("15 Oct"), the weekday as its title ("Thursday"), or "Today" for the last day, and at its end the hours logged ("7h 30m", "0h"), in the end's mono capitals, in `--ink` when any are logged and `--ink-muted` when none are. A weekday before today with no hours has a Missing chip before them, filled as a late item's chip. Nothing shows under the head while the report loads, which it does each time the page shows. When the last search failed, a muted line under the head says "The last search failed: .", then "These hours are from 21 October at 09:38." when it has days from an earlier search, 30 px above the form or the first section (24 px on a phone), as the Todos page's form; with no days yet, the line alone. Without Jira, "Jira isn't set up." in `--ink-muted` under the head, with no section; any other failure to load: "Couldn't load the time report.". The form shows once the report's days and my open tickets have loaded, 30 px above the first section (24 px on a phone): three selects in the ticket select's look, with our chevron, Day ("Friday 16 Oct", or "Today"), Ticket ("Pick a ticket", then "WEB-9 · Fix the date picker in Safari") and Hours ("8h"), then a Log button, in a row that wraps with its lines 12 px apart, and the status line under it ("Logging…", "Logged", the service's reason, "Log failed"). The day select is as wide as its longest day, the ticket select takes most of the room to spare, and the hours and Log wrap together: one line from about 720 px wide, Day and Ticket over Hours and Log down to about 500 px, a line each for Day and Ticket below that; the page never scrolls sideways, down to 320 px wide. Log opens a modal dialog in the delete dialog's look: "Log these hours?", then "8h on WEB-9 · Fix the date picker in Safari, from 09:00 on Friday 16 Oct.", with Cancel and the primary Log. In the form's place, in `--ink-muted`: "Couldn't load your Jira tickets." or "No open Jira tickets to log hours on.".
+- GitHub, built in `github-page` (decision 207): "GitHub", with no summary. Under the head, three sections, each as the PRs page's: my open PRs, "Your *PRs*"; the open PRs that wait for my review or my teams', "Review *requests*"; and the open issues assigned to me, "Assigned *issues*", with the count as the note, "1 PR", "3 PRs" or "2 issues". Each card holds a list row per item, in the service's order, the latest updated first, that links to it on GitHub: the number as the row's mono label ("#214"), the title as its title, the repository's full name ("acme/docs") under it as its detail, and, on a draft PR, an outlined Draft chip at its end. An item without a link shows in a row that isn't a link. The empty cards say "No open pull requests.", "No pull requests to review." and "No open issues."; any other failure to load says "Couldn't load the pull requests.", "Couldn't load the review requests." or "Couldn't load the issues." under the section's head. The last search's failure reads as on the PRs page, with "These issues are from 21 October at 09:39." for the issues. Each section shows once its own list loads, and nothing shows for a list while it loads. Without GitHub, when every list answers 404, "GitHub isn't set up." in the status line under the page head, with no section.
- Games, built in `games-page` (decision 307) and `games-page-covers` (decision 308): "Games", with no summary. Under the head, the games I played in the last 14 days sit in a section, "Recently *played*", with the count as the note, "1 game" or "3 games". Its card holds a list row per game, in the service's order, the last played first, and none is a link: the day I last played it as the row's mono label, in the device's time zone ("Today", "Yesterday", "19 Oct"), then the game's cover from IGDB, 48 by 68 px in a 1 px `--rule` frame, cropped to fill it, or a `--fill` blank when the game has none, while the cover loads and when it fails to load, then the title as its title, the platforms under it as its detail ("PS4 · PS5"), then, when the game has trophies, a second detail line ("31 of 52 trophies · 59%", "1 of 1 trophy · 100%"), with the percentage rounded down, so only all of them make 100%, and at its end the playtime ("12h 34m", "45m", "3h") in the end's mono capitals, left out when Exophase doesn't know it. The cover makes every row as high as one with trophies; on a phone it starts the row's second line, under the day, beside the title. Screen readers skip the cover, since the title names the game. Trophies are "trophies" on every platform, since the service doesn't say which call them achievements. With no game, the card says "No games played in the last 14 days." in `--ink-muted` and the note is left out. When the last search failed, a muted line 20 px above the card says "The last search failed: .", then "These games are from 21 October at 09:38." when it has games from an earlier search, and a Stale chip, outlined, follows the count in the section's head (decision 67); before any search has succeeded, the line alone, with no card, count or chip. Nothing shows under the head while the games load, which they do each time the page shows. Without Exophase, when the games answer 404, "Exophase isn't set up." in `--ink-muted` under the page head, with no section; any other failure to load: "Couldn't load the games." under the section's head. A long title, platform or failure wraps, and the page never scrolls sideways, down to 320 px wide.
- More: "More", with the summary "The apps without a tab of their own.", then the cards of Time, GitHub, Games and Planner as in "Your apps", with their numbers (04 to 07) and counts. The cards' names are `h2`s, under the page's `h1`. The grid ends 64 px above the page's end (48 on phones), as a section does.
- Any other path: "Page not found", with "There's no page at /elsewhere." as the summary.
@@ -1284,7 +1285,7 @@ Every item is pending unless it says done. The service an item lands in is in br
**Phase 4: GitHub**
-- `github-widgets` [github]. Needs `frontend-shell`. Nine steps (decisions 200 to 206):
+- `github-widgets` [github]. Needs `frontend-shell`. Nine steps (decisions 200 to 207):
- `shared-web-link`: the Bitbucket client's check that a PR's page is an https URL moves to the root `internal/weblink`, for the GitHub client; nothing changes in behaviour (decision 201). Done (#171).
- `github-client`: a client of GitHub's search finds my open PRs, the PRs that wait for my review or my teams', and the open issues assigned to me, across every owner, through a classic PAT with `repo` and `read:org` (decisions 202 and 203). Done (#173).
- `github-service`: the `github` service polls the three searches every 5 minutes, within search's 30 requests a minute, and answers them at `GET /github/mine`, `/github/reviews` and `/github/issues`, or 404 without its setting (decision 204). Done (#176).
@@ -1292,7 +1293,7 @@ Every item is pending unless it says done. The service an item lands in is in br
- `github-gateway`: the gateway forwards `/api/github` to the service.
- `shared-polled-section`: the PRs page's lists and the Todos page's Jira tickets show a poller's list through one component in the shell, for the GitHub page's lists; nothing changes on screen (decision 205). Done (#184).
- `shared-page-status`: the PRs, Time, Nudge and Todos pages draw the line that takes their sections' place from one global class, for the GitHub page's (decision 206). Done (#187).
- - `github-page`: the lists in the app, where the user chooses: a page of their own at `/github`, or beside Bitbucket's on the PRs page.
+ - `github-page`: the lists in the app, on a page of their own at `/github`, as the user chose (decision 207). Done (#191).
- `github-attention`: what the user chooses of the lists reaches the Home's "Needs you", over the attention contract.
**Phase 5: games**
@@ -1801,6 +1802,14 @@ Decisions 200 to 299 are the `github-widgets` session's.
- **The Time page's failure line**, when the line sits over the page's sections, keeps its own smaller space below it, 30 px (24 on phones), as the Todos page's form leaves over its first section.
- **`github-widgets` has nine steps** (decision 200): this one comes seventh, before `github-page`.
- **Rejected**: a fifth copy for the GitHub page; a component, which would wrap a paragraph and its text for no gain; the polled sections' muted lines (decision 205) and the Notes pages' failure lines, which sit in a block that gives them their space: a section, under its head, or a Notes page's content, under its tools.
+207. **The GitHub page** (`github-page`): the lead's choices (2026-10-30), for the user to review. Asked where the lists live (decision 200), the user chose a page of their own at `/github`, the lead's pick (2026-10-30).
+ - **A page of its own**: at `/github` and the paths below it, the GitHub app's path, so "Your apps" and More link to it rather than to its "Coming soon" page. Its head is "GitHub", with no summary, as the PRs page's (decision 149), and its title "GitHub · Bakhyard". Rather than beside Bitbucket's lists on the PRs page, which would hold five sections from two services, with two "Your *PRs*" and two "Review *requests*" heads to tell apart.
+ - **Three sections**, in the service's order: "Your *PRs*" from `GET /api/github/mine`, "Review *requests*" from `/api/github/reviews` and "Assigned *issues*" from `/api/github/issues` (decision 204), each the shell's `PolledSection` (decision 205), with "PR" or "issue" as its noun. They load at once, each time the page shows, so none waits for another, as on the PRs page (decision 172).
+ - **A row** is the PRs page's, without the review line, which GitHub's search doesn't give: the number as the label, the title, the repository's full name as the detail, a Draft chip on a draft PR, and a link to its page on GitHub; one without a link (decision 203) gets a row that isn't one. The page writes it once, as a snippet that its three sections share.
+ - **Without GitHub**: the service answers 404 for every list, and the page says "GitHub isn't set up." once under its head, in the shared status line (decision 206). The service answers every list or none; should one list alone answer 404, the page leaves out only its section.
+ - **Ahead of `github-gateway`**: the page lands before the gateway forwards `/api/github` (#179), which waits for the laptop to run the service. Until then, the gateway's 404 for a path that no service claims makes the page say "GitHub isn't set up.", as the Planner page does while the gateway doesn't forward to the planner (#205).
+ - **The last "Coming soon"**: with this page, every app in the list has a page of its own, so no path shows an app's "Coming soon" page any more. The page stays, as §7 keeps it for an app until the app is built, and its own test still renders it; the App's test of the page a path opens on moves to the GitHub page.
+ - **Rejected**: when each item was opened or last updated, a summary and a refresh button, as on the PRs page (decision 149); a section per owner, since the repository's full name names it; a row component shared with the PRs page, for a second copy of a dozen lines of markup whose look the global row classes already share (decision 123).
Decisions 300 to 399 are the `games-exophase` session's.