From b1f749bbbde1681471d6ba418bb6976205415bd6 Mon Sep 17 00:00:00 2001
From: "Fredrik Liljegren (Claude Code Fable 5.1)"
GitHub could not be asked: ${escapeHtml(err instanceof Error ? err.message : String(err))} GitHub could not be asked: ${escapeHtml(err instanceof Error ? err.message : String(err))} Connect GitHub on Settings to see ${connectFor}. Connect GitHub on Settings to see ${connectFor}. ${empty}${hidden} ${empty}${hidden} ${hidden} ${hidden} Agent listening · Create and review hands a pull request to it Ask your agent to
${content}${heading}${count === undefined ? '' : ` (${count})`}
`;
}).join('');
- return section(heading, `
- ${escapeHtml(`${slug}#${request.number}`)}
- ${escapeHtml(request.title)}${request.draft ? ' draft' : ''}
- ${escapeHtml(request.author)}
+ ${escapeHtml(request.title)}${!own && request.draft ? ` ${draftBadge}` : ''}
+ ${own ? ownStatus(request) : escapeHtml(request.author)}
${escapeHtml(request.updatedAt.slice(0, 16).replace('T', ' '))}
${action}
- ${hidden ? ` ${rows}Pull request Title Author Updated
+ ${hidden ? ` ${rows}Pull request Title ${own ? 'Status' : 'Author'} Updated
`, sessions.length)
: section('No review sessions yet', ` ${rows}Repository Change Created create_session through the diffity MCP connector.
- See settings for how to add it.
Updated ${formatAge(now - fetchedAt)}` + + 'Update failed · retrying' + + `
`; } + +/** + * Keeps the sessions page current without reloading it: swaps in `/sessions/body` every minute while the tab + * is visible, and from GitHub rather than the server's cache when the reload button asks. Needs SECTIONS_SCRIPT. + */ +export const SESSIONS_SCRIPT = `(() => { + const AUTO_MS = 60000; + const container = document.getElementById('sessions'); + const age = document.getElementById('sessions-age'); + const error = document.getElementById('sessions-error'); + const reload = document.getElementById('sessions-reload'); + if (!container || !age || !error || !reload) return; + let loadedAt = Date.now(); + let inFlight = false; + let failed = false; + + const formatAge = ms => { + const seconds = Math.max(0, Math.floor(ms / 1000)); + if (seconds < 10) return 'just now'; + if (seconds < 60) return seconds + ' s ago'; + if (seconds < 3600) return Math.floor(seconds / 60) + ' min ago'; + return Math.floor(seconds / 3600) + ' h ago'; + }; + const tick = () => { + const fetchedAt = Date.parse(container.dataset.fetchedAt || ''); + if (!Number.isNaN(fetchedAt)) age.textContent = 'Updated ' + formatAge(Date.now() - fetchedAt); + }; + + const refresh = async fresh => { + if (inFlight) return; + inFlight = true; + reload.setAttribute('aria-busy', 'true'); + try { + const res = await fetch('/sessions/body' + (fresh ? '?fresh=1' : ''), { credentials: 'same-origin', cache: 'no-store' }); + if (!res.ok) throw new Error('HTTP ' + res.status); + const template = document.createElement('template'); + template.innerHTML = await res.text(); + applySections(template.content); + container.replaceChildren(template.content); + const fetchedAt = res.headers.get('X-Fetched-At'); + if (fetchedAt) container.dataset.fetchedAt = fetchedAt; + loadedAt = Date.now(); + failed = false; + } catch { + failed = true; + } finally { + inFlight = false; + reload.removeAttribute('aria-busy'); + error.hidden = !failed; + tick(); + } + }; + + const due = () => failed || Date.now() - loadedAt >= AUTO_MS; + reload.addEventListener('click', () => refresh(true)); + setInterval(() => { + tick(); + if (document.visibilityState === 'visible' && due()) refresh(false); + }, 15000); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible' && due()) refresh(false); + }); +})(); `; export function page(input: { title: string; body: string; user?: { email: string } | null }): string { diff --git a/packages/server/src/service.ts b/packages/server/src/service.ts index b432435f..4c558b70 100644 --- a/packages/server/src/service.ts +++ b/packages/server/src/service.ts @@ -63,6 +63,20 @@ export interface ReviewRequests { requests: ReviewRequestRow[]; /** How many the user's filters left out. */ hidden: number; + /** When GitHub was asked, in epoch milliseconds; the answer is cached for a minute. */ + fetchedAt: number; +} + +export interface OwnPulls { + requests: ReviewRequestRow[]; + /** When GitHub was asked, in epoch milliseconds; the answer is cached for a minute. */ + fetchedAt: number; +} + +export interface PullsQuery { + now?: number; + /** Asks GitHub again instead of answering from the cache. */ + fresh?: boolean; } const MAX_PATCH_BYTES = 10 * 1024 * 1024; @@ -167,27 +181,31 @@ export class ReviewService { } /** Null when the user has no GitHub access to ask with. */ - async reviewRequests(userId: string, now = Date.now()): PromiseUpdated just now<\/span>/);
+ expect(first).toContain('id="sessions-reload"');
+ const at = fetchedAt(first);
+ expect(Date.parse(at)).not.toBeNaN();
+ expect(github.searches.length).toBe(searches + 2);
+
+ await new Promise(resolve => setTimeout(resolve, 5));
+ const body = await api('/sessions/body', { cookie: aliceCookie });
+ expect(body.status).toBe(200);
+ expect(body.headers.get('cache-control')).toBe('no-store');
+ expect(body.headers.get('content-type')).toContain('text/html');
+ expect(body.headers.get('x-fetched-at')).toBe(at);
+ expect(body.body).toContain(' GitHub could not be asked: ${escapeHtml(err instanceof Error ? err.message : String(err))} Ask your agent to Awaiting your review (1)
');
+ expect(body.body).toContain('Your open pull requests (0)
');
+ expect(body.body).not.toContain('');
+ expect(body.body).not.toContain('id="sessions"');
+ expect(fetchedAt((await api('/', { cookie: aliceCookie })).body)).toBe(at);
+ expect(github.searches.length).toBe(searches + 2);
+
+ const fresh = await api('/sessions/body?fresh=1', { cookie: aliceCookie });
+ expect(github.searches.length).toBe(searches + 4);
+ const later = fresh.headers.get('x-fetched-at')!;
+ expect(Date.parse(later)).toBeGreaterThan(Date.parse(at));
+ expect(fetchedAt((await api('/', { cookie: aliceCookie })).body)).toBe(later);
+ expect(github.searches.length).toBe(searches + 4);
+ } finally {
+ await server.users.setGitHubToken(aliceId, null);
+ }
+ });
+
it('opens sessions in a new tab, folds each section, and allows only its own script', async () => {
github.reviewRequests['alice-token-tabs'] = [
{ owner: 'acme', repo: 'widgets', number: 2, title: 'Rename a file', author: 'carol', updatedAt: '2026-10-02T09:00:00Z' },
@@ -323,6 +409,12 @@ describe('pull requests awaiting review', () => {
const csp = res.headers.get('content-security-policy')!;
expect(csp).toContain("script-src 'self'");
expect(csp).not.toMatch(/script-src[^;]*unsafe/);
+ expect(csp).toContain("connect-src 'self'");
+ expect(page).toContain('');
+ const sessionsScript = await api('/sessions.js');
+ expect(sessionsScript.headers.get('content-type')).toContain('javascript');
+ expect(sessionsScript.body).toContain("'/sessions/body' + (fresh ? '?fresh=1' : '')");
+ expect(sessionsScript.body).toContain('applySections(template.content)');
const script = await api('/sections.js');
expect(script.headers.get('content-type')).toContain('javascript');
expect(script.body).toContain('localStorage');
diff --git a/packages/server/tests/service.test.ts b/packages/server/tests/service.test.ts
index 00ea86d1..5691b269 100644
--- a/packages/server/tests/service.test.ts
+++ b/packages/server/tests/service.test.ts
@@ -286,6 +286,36 @@ describe('review requests', () => {
}
});
+ it('asks GitHub’s GraphQL search for the token’s own pull requests, with threads and requested reviewers', async () => {
+ github.ownPulls['own-search-token'] = [
+ { owner: 'acme', repo: 'widgets', number: 8, title: 'Eight', author: 'erin', draft: true, updatedAt: '2026-10-02T08:30:00Z',
+ threads: [false, true], threadCount: 120, reviewers: ['kirill', { team: 'platform' }], reviewerCount: 2, reviewDecision: 'REVIEW_REQUIRED' },
+ ];
+ const api = new GitHubApi(github.url);
+ expect(await api.ownPulls('own-search-token')).toEqual([{
+ owner: 'acme', repo: 'widgets', number: 8, title: 'Eight', author: 'erin', draft: true,
+ updatedAt: '2026-10-02T08:30:00Z', url: 'https://github.com/acme/widgets/pull/8',
+ openThreads: 1, moreThreads: true, requestedReviewers: ['kirill', 'platform'], requestedReviewerCount: 2,
+ reviewDecision: 'REVIEW_REQUIRED',
+ }]);
+ expect(github.searches.at(-1)).toBe('is:pr is:open archived:false author:@me sort:updated-desc');
+
+ try {
+ github.failSearchWith = 403;
+ expect(await api.ownPulls('own-search-token')).toEqual([]);
+ github.failSearchWith = 422;
+ expect(await api.ownPulls('own-search-token')).toEqual([]);
+ github.failSearchWith = 500;
+ await expect(api.ownPulls('own-search-token')).rejects.toThrow('GitHub answered 500');
+ github.failSearchWith = null;
+ github.graphqlSearchErrors = ['API rate limit exceeded'];
+ expect(await api.ownPulls('own-search-token')).toEqual([]);
+ } finally {
+ github.failSearchWith = null;
+ github.graphqlSearchErrors = null;
+ }
+ });
+
it('lists them for the user with their newest session on each, filtered, and asks GitHub once a minute', async () => {
github.reviewRequests['bob-token'] = [
{ owner: 'acme', repo: 'widgets', number: 1, title: 'Change line ten', author: 'octocat', updatedAt: '2026-10-03T09:00:00Z' },
@@ -297,19 +327,45 @@ describe('review requests', () => {
const now = Date.now();
const searches = github.searches.length;
- const found = await service.reviewRequests(bob, now);
+ const found = await service.reviewRequests(bob, { now });
expect(found?.hidden).toBe(1);
expect(found?.requests.map(r => [r.number, r.session?.id ?? null])).toEqual([[1, newest.id], [2, null]]);
expect(found?.requests[0].session).toEqual({ id: newest.id, headSha: newest.headSha, createdAt: newest.createdAt });
await users.setSettings(bob, { skipTitles: ['bump'], includeDrafts: true });
- expect((await service.reviewRequests(bob, now + 30_000))?.requests.map(r => r.number)).toEqual([1, 3]);
+ expect((await service.reviewRequests(bob, { now: now + 30_000 }))?.requests.map(r => r.number)).toEqual([1, 3]);
expect(github.searches.length).toBe(searches + 1);
- await service.reviewRequests(bob, now + 61_000);
+ await service.reviewRequests(bob, { now: now + 61_000 });
expect(github.searches.length).toBe(searches + 2);
await users.setSettings(bob, { skipTitles: [], includeDrafts: false });
});
+ it('says when GitHub was asked, and asks again when fresh, replacing the cached answer', async () => {
+ github.reviewRequests['bob-token'] = [
+ { owner: 'acme', repo: 'widgets', number: 1, title: 'Change line ten', author: 'octocat', updatedAt: '2026-10-03T09:00:00Z' },
+ ];
+ github.ownPulls['bob-token'] = [
+ { owner: 'acme', repo: 'widgets', number: 4, title: 'Mine', author: 'bob', updatedAt: '2026-10-03T09:00:00Z' },
+ ];
+ const now = Date.now() + 200_000;
+ const searches = github.searches.length;
+
+ expect((await service.reviewRequests(bob, { now }))?.fetchedAt).toBe(now);
+ expect((await service.reviewRequests(bob, { now: now + 10_000 }))?.fetchedAt).toBe(now);
+ expect(github.searches.length).toBe(searches + 1);
+
+ expect((await service.reviewRequests(bob, { now: now + 20_000, fresh: true }))?.fetchedAt).toBe(now + 20_000);
+ expect(github.searches.length).toBe(searches + 2);
+ expect((await service.reviewRequests(bob, { now: now + 30_000 }))?.fetchedAt).toBe(now + 20_000);
+ expect(github.searches.length).toBe(searches + 2);
+
+ const own = await service.ownPulls(bob, { now: now + 30_000 });
+ expect(own?.fetchedAt).toBe(now + 30_000);
+ expect(own?.requests.map(r => r.number)).toEqual([4]);
+ expect((await service.ownPulls(bob, { now: now + 40_000, fresh: true }))?.fetchedAt).toBe(now + 40_000);
+ expect(github.searches.length).toBe(searches + 4);
+ });
+
it('never links another user’s session, and answers null without GitHub access', async () => {
github.reviewRequests['alice-token'] = [
{ owner: 'acme', repo: 'widgets', number: 1, title: 'Change line ten', author: 'octocat', updatedAt: '2026-10-03T09:00:00Z' },
From efa34785f9d416bc4571f0dbf12a7f3329691fa2 Mon Sep 17 00:00:00 2001
From: "Fredrik Liljegren (Claude Code Fable 5.1)"
create_session through the diffity MCP connector.
See settings for how to add it.Threads ');
} finally {
From 71ed29f589f1be428aae98db0118be61985fc37c Mon Sep 17 00:00:00 2001
From: "Fredrik Liljegren (Claude Code Fable 5.1)"