diff --git a/packages/server/src/app.ts b/packages/server/src/app.ts
index 5ce6cb89..431137c3 100644
--- a/packages/server/src/app.ts
+++ b/packages/server/src/app.ts
@@ -18,7 +18,7 @@ import { createMcpServer } from './mcp.js';
import { MAX_WAIT_SECONDS, type Live } from './live.js';
import type { Routine } from './routine.js';
import { uiApiRouter } from './ui-api.js';
-import { SECTIONS_SCRIPT, escapeHtml, page } from './html.js';
+import { SECTIONS_SCRIPT, SESSIONS_SCRIPT, escapeHtml, page, sessionsStatus } from './html.js';
import { randomToken } from './crypto.js';
import {
SESSION_COOKIE,
@@ -151,6 +151,10 @@ export function createApp(deps: AppDeps): express.Express {
res.type('text/javascript').set('Cache-Control', 'no-cache').send(SECTIONS_SCRIPT);
});
+ app.get('/sessions.js', (_req, res) => {
+ res.type('text/javascript').set('Cache-Control', 'no-cache').send(SESSIONS_SCRIPT);
+ });
+
if (deps.uiDir) {
// Build artifacts, the same for everyone; nothing about a review is in them.
app.use('/assets', express.static(join(deps.uiDir, 'assets'), { index: false, immutable: true, maxAge: '365d' }));
@@ -583,25 +587,59 @@ export function createApp(deps: AppDeps): express.Express {
const section = (heading: string, content: string, count?: number): string =>
` 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 = ${formatAge.toString()}; + 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; + // Swapping the content would take focus from the control a keyboard user is on; the next tick tries again. + if (!fresh && container.contains(document.activeElement)) 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..2af2f1d1 100644 --- a/packages/server/src/service.ts +++ b/packages/server/src/service.ts @@ -63,11 +63,27 @@ 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; const CACHE_ENTRIES = 256; const REVIEW_REQUESTS_TTL_MS = 60_000; +/** GitHub's search allows 30 requests a minute, and a refused one reads as an empty list. */ +const FRESH_MIN_AGE_MS = 10_000; export function filterReviewRequests(requests: ReviewRequest[], settings: UserSettings): ReviewRequest[] { const skip = settings.skipTitles.map(pattern => pattern.trim().toLowerCase()).filter(Boolean); @@ -167,27 +183,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('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 soon = await api('/sessions/body?fresh=1', { cookie: aliceCookie });
+ expect(soon.headers.get('x-fetched-at')).toBe(at);
+ expect(github.searches.length).toBe(searches + 2);
+
+ vi.useFakeTimers({ toFake: ['Date'] });
+ vi.setSystemTime(Date.parse(at) + 11_000);
+ 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 {
+ vi.useRealTimers();
+ 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 +435,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..b05c9431 100644
--- a/packages/server/tests/service.test.ts
+++ b/packages/server/tests/service.test.ts
@@ -279,6 +279,8 @@ describe('review requests', () => {
github.failSearchWith = 422;
try {
expect(await api.reviewRequests('search-token')).toEqual([]);
+ github.failSearchWith = 403;
+ await expect(api.reviewRequests('search-token')).rejects.toThrow('GitHub answered 403');
github.failSearchWith = 500;
await expect(api.reviewRequests('search-token')).rejects.toThrow('GitHub answered 500');
} finally {
@@ -286,6 +288,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;
+ await expect(api.ownPulls('own-search-token')).rejects.toThrow('GitHub answered 403');
+ 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'];
+ await expect(api.ownPulls('own-search-token')).rejects.toThrow('Searching pull requests failed: API rate limit exceeded');
+ } 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 +329,47 @@ 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 + 5_000, fresh: true }))?.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' },