Skip to content
Merged
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
119 changes: 94 additions & 25 deletions packages/server/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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' }));
Expand Down Expand Up @@ -583,25 +587,59 @@ export function createApp(deps: AppDeps): express.Express {
const section = (heading: string, content: string, count?: number): string =>
`<details open data-section="${heading}"><summary><h2>${heading}${count === undefined ? '' : ` (${count})`}</h2></summary>${content}</details>`;

const draftBadge = '<span class="badge">Draft</span>';

const plural = (count: number, word: string): string => `${count} ${word}${count === 1 ? '' : 's'}`;

/** Who one of the user's own pull requests is waiting on. */
const ownStatus = (request: ReviewRequestRow): string => {
const parts = request.draft ? [draftBadge] : [];
const reviewers = request.requestedReviewers ?? [];
const openThreads = request.openThreads ?? 0;
if (openThreads === 0 && request.moreThreads) {
parts.push('<span class="muted" title="Only the first page of review threads was read">Open threads unknown ·</span>');
}
if (openThreads > 0) {
parts.push(`Waiting on: you · ${openThreads}${request.moreThreads ? '+' : ''} open thread${openThreads === 1 && !request.moreThreads ? '' : 's'}`);
} else if (reviewers.length > 0) {
Comment thread
fiddur marked this conversation as resolved.
Resolved
const count = Math.max(request.requestedReviewerCount ?? 0, reviewers.length);
const shown = reviewers.slice(0, 2).join(', ') + (count > 2 ? ', …' : '');
const all = `${plural(count, 'review')} requested: ${reviewers.join(', ')}${count > reviewers.length ? ', …' : ''}`;
parts.push(`<span title="${escapeHtml(all)}">Waiting on: ${escapeHtml(shown)}</span>`);
} else if (request.reviewDecision === 'APPROVED') {
parts.push('Approved');
} else if (request.reviewDecision === 'CHANGES_REQUESTED') {
parts.push('Changes requested');
} else {
parts.push('<span class="muted">No reviewer requested</span>');
}
return parts.join(' ');
};

const pullsSection = async (
heading: string,
connectFor: string,
empty: string,
list: () => Promise<{ rows: ReviewRequestRow[]; hidden: number } | null>,
list: () => Promise<{ rows: ReviewRequestRow[]; hidden: number; fetchedAt: number } | null>,
reachable: boolean,
): Promise<string> => {
let found: { rows: ReviewRequestRow[]; hidden: number } | null;
own = false,
): Promise<{ html: string; fetchedAt: number | null; failed?: true }> => {
let found: { rows: ReviewRequestRow[]; hidden: number; fetchedAt: number } | null;
try {
found = await list();
} catch (err) {
return section(heading, `<p class="error">GitHub could not be asked: ${escapeHtml(err instanceof Error ? err.message : String(err))}</p>`);
return {
html: section(heading, `<p class="error">GitHub could not be asked: ${escapeHtml(err instanceof Error ? err.message : String(err))}</p>`),
fetchedAt: null,
failed: true,
};
}
if (!found) {
return section(heading, `<p class="muted"><a href="/settings">Connect GitHub on Settings</a> to see ${connectFor}.</p>`);
return { html: section(heading, `<p class="muted"><a href="/settings">Connect GitHub on Settings</a> to see ${connectFor}.</p>`), fetchedAt: null };
}
const hidden = found.hidden > 0 ? ` <span class="muted">(${found.hidden} hidden by your <a href="/settings">filters</a>)</span>` : '';
if (found.rows.length === 0) {
return section(heading, `<p>${empty}${hidden}</p>`, 0);
return { html: section(heading, `<p>${empty}${hidden}</p>`, 0), fetchedAt: found.fetchedAt };
}
const rows = found.rows.map(request => {
const slug = `${request.owner}/${request.repo}`;
Expand All @@ -619,35 +657,38 @@ export function createApp(deps: AppDeps): express.Express {
return `
<tr>
<td><a href="${escapeHtml(request.url)}">${escapeHtml(`${slug}#${request.number}`)}</a></td>
<td>${escapeHtml(request.title)}${request.draft ? ' <span class="muted">draft</span>' : ''}</td>
<td>${escapeHtml(request.author)}</td>
<td>${escapeHtml(request.title)}${!own && request.draft ? ` ${draftBadge}` : ''}</td>
<td>${own ? ownStatus(request) : escapeHtml(request.author)}</td>
<td class="muted">${escapeHtml(request.updatedAt.slice(0, 16).replace('T', ' '))}</td>
<td>${action}</td>
</tr>`;
}).join('');
return section(heading, `
<table><tr><th>Pull request</th><th>Title</th><th>Author</th><th>Updated</th><th></th></tr>${rows}</table>
${hidden ? `<p>${hidden}</p>` : ''}`, found.rows.length);
return {
html: section(heading, `
<table><tr><th>Pull request</th><th>Title</th><th>${own ? 'Status' : 'Author'}</th><th>Updated</th><th></th></tr>${rows}</table>
${hidden ? `<p>${hidden}</p>` : ''}`, found.rows.length),
fetchedAt: found.fetchedAt,
};
};

app.get('/', async (req, res) => {
const user = await userFor(req);
if (!user) {
notSignedIn(res, req.originalUrl);
return;
}
/**
* Everything on the sessions page below its status line. `fetchedAt` is when GitHub was last asked for the
* oldest list shown, or now when no list came from GitHub.
*/
const sessionsBody = async (user: User, { fresh = false } = {}): Promise<{ html: string; fetchedAt: number; failed: boolean }> => {
const now = Date.now();
const status = await live.userStatus(user.id);
const listening = status.listening;
const reachable = listening || (await routine.configured(user.id));
const [requests, own, sessions] = await Promise.all([
pullsSection('Awaiting your review', 'pull requests waiting for your review', 'Nothing is waiting for your review.', async () => {
const found = await service.reviewRequests(user.id);
return found && { rows: found.requests, hidden: found.hidden };
const found = await service.reviewRequests(user.id, { now, fresh });
return found && { rows: found.requests, hidden: found.hidden, fetchedAt: found.fetchedAt };
}, reachable),
pullsSection('Your open pull requests', 'your open pull requests', 'You have no open pull requests.', async () => {
const rows = await service.ownPulls(user.id);
return rows && { rows, hidden: 0 };
}, reachable),
const found = await service.ownPulls(user.id, { now, fresh });
return found && { rows: found.requests, hidden: 0, fetchedAt: found.fetchedAt };
}, reachable, true),
reviews.listSessions(user.id),
]);
const rows = sessions.map(session => `
Expand All @@ -661,10 +702,38 @@ export function createApp(deps: AppDeps): express.Express {
: listening
? '<p><strong>Agent listening</strong> <span class="muted">· Create and review hands a pull request to it</span></p>'
: '';
sendPage(res, 200, 'Sessions', agent + requests + own + (sessions.length > 0
const html = agent + requests.html + own.html + (sessions.length > 0
? section('Your review sessions', `<table><tr><th>Repository</th><th>Change</th><th>Created</th></tr>${rows}</table>`, sessions.length)
: section('No review sessions yet', `<p>Ask your agent to <code>create_session</code> through the diffity MCP connector.
See <a href="/settings">settings</a> for how to add it.</p>`)) + '<script src="/sections.js"></script>', user, [], true);
See <a href="/settings">settings</a> for how to add it.</p>`));
const fetched = [requests.fetchedAt, own.fetchedAt].filter((at): at is number => at !== null);
return { html, fetchedAt: fetched.length > 0 ? Math.min(...fetched) : now, failed: !!(requests.failed || own.failed) };
};

app.get('/', async (req, res) => {
const user = await userFor(req);
if (!user) {
notSignedIn(res, req.originalUrl);
return;
}
const body = await sessionsBody(user);
sendPage(res, 200, 'Sessions', sessionsStatus(body.fetchedAt, Date.now())
+ `<div id="sessions" data-fetched-at="${new Date(body.fetchedAt).toISOString()}">${body.html}</div>`
+ '<script src="/sections.js"></script><script src="/sessions.js"></script>', user, [], true);
});

app.get('/sessions/body', async (req, res) => {
res.set('Cache-Control', 'no-store');
const user = await userFor(req);
if (!user) {
res.status(401).json({ error: 'Sign in first' });
return;
}
const body = await sessionsBody(user, { fresh: req.query.fresh === '1' });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: fresh=1 skips the cache on every click, and each click runs both the REST search and the GraphQL search. Search allows 30 requests a minute per user, and its 403 is turned into an empty list. A few impatient clicks therefore show "Nothing is waiting for your review" as if it were true. Let a fresh request reuse a cache entry younger than about 10 seconds.

// A failed GitHub search is not swapped in over the lists the page already shows.
res.status(body.failed ? 502 : 200)
.set('Content-Security-Policy', pageContentSecurityPolicy())
.set('X-Fetched-At', new Date(body.fetchedAt).toISOString()).type('html').send(body.html);
});

app.post('/sessions', sameOrigin, forms, async (req, res) => {
Expand Down
97 changes: 92 additions & 5 deletions packages/server/src/github.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,15 @@ export interface ReviewRequest {
draft: boolean;
updatedAt: string;
url: string;
/** Unresolved review threads; known for the user's own pull requests only. */
openThreads?: number;
/** More threads than one page holds, so `openThreads` counts only the first page. */
moreThreads?: boolean;
/** Users and bots by login, and teams by name, whose review is still requested. */
requestedReviewers?: string[];
requestedReviewerCount?: number;
/** GitHub's APPROVED, CHANGES_REQUESTED or REVIEW_REQUIRED; null when the repository requires no review. */
reviewDecision?: string | null;
}

interface RawSearchIssue {
Expand All @@ -98,6 +107,39 @@ const MAX_PAGES = 30;

const REVIEW_REQUESTS_QUERY = 'is:pr is:open archived:false review-requested:@me';
const OWN_PULLS_QUERY = 'is:pr is:open archived:false author:@me';
const THREADS_PAGE = 100;

const OWN_PULLS_GRAPHQL = `query($q:String!){
search(type:ISSUE,query:$q,first:50){
nodes{
... on PullRequest{
number
title
url
updatedAt
isDraft
repository{owner{login} name}
author{login}
reviewDecision
reviewRequests(first:20){totalCount nodes{requestedReviewer{... on User{login} ... on Bot{login} ... on Team{name}}}}
reviewThreads(first:${THREADS_PAGE}){totalCount nodes{isResolved isOutdated}}
}
}
}
}`;

interface RawOwnPull {
number?: number;
title?: string;
url?: string;
updatedAt?: string;
isDraft?: boolean;
repository?: { owner?: { login?: string }; name?: string };
author?: { login?: string } | null;
reviewDecision?: string | null;
reviewRequests?: { totalCount?: number; nodes?: { requestedReviewer?: { login?: string; name?: string } | null }[] };
reviewThreads?: { totalCount?: number; nodes?: { isResolved?: boolean }[] };
}

const REVIEW_THREADS_QUERY = `query($owner:String!,$repo:String!,$number:Int!){
repository(owner:$owner,name:$repo){
Expand Down Expand Up @@ -295,9 +337,54 @@ export class GitHubApi {
return this.searchPulls(token, REVIEW_REQUESTS_QUERY);
}

/** The token's own open pull requests, most recently updated first. */
ownPulls(token: string): Promise<ReviewRequest[]> {
return this.searchPulls(token, OWN_PULLS_QUERY);
/**
* The token's own open pull requests, most recently updated first, with who each is waiting on.
* Review threads and requested reviewers live on GraphQL only.
*/
async ownPulls(token: string): Promise<ReviewRequest[]> {
let json: { data?: { search?: { nodes?: (RawOwnPull | null)[] } }; errors?: { message?: string }[] };
try {
json = await this.json(token, '/graphql', {
method: 'POST',
body: { query: OWN_PULLS_GRAPHQL, variables: { q: `${OWN_PULLS_QUERY} sort:updated-desc` } },
});
} catch (err) {
if (err instanceof GitHubApiError && err.status === 422) {
console.warn(`Searching pull requests failed: ${err.message}`);
return [];
}
throw err;
}
// GraphQL answers a refused or partly failed query with 200 and errors, e.g. when rate limited.
if (!json.data?.search || (json.errors?.length ?? 0) > 0) {
throw new Error(`Searching pull requests failed: ${json.errors?.map(e => e.message).join('; ') || 'no data'}`);
}
return (json.data.search.nodes ?? []).flatMap(node => {
const owner = node?.repository?.owner?.login;
const repo = node?.repository?.name;
if (!node || !owner || !repo || node.number === undefined) {
return [];
}
const threads = node.reviewThreads?.nodes ?? [];
const reviewers = (node.reviewRequests?.nodes ?? [])
.map(request => request.requestedReviewer?.login ?? request.requestedReviewer?.name)
.filter((name): name is string => !!name);
return [{
owner,
repo,
number: node.number,
title: node.title ?? '',
author: node.author?.login ?? '',
draft: !!node.isDraft,
updatedAt: node.updatedAt ?? '',
url: node.url ?? `https://github.com/${owner}/${repo}/pull/${node.number}`,
openThreads: threads.filter(thread => !thread.isResolved).length,
moreThreads: (node.reviewThreads?.totalCount ?? 0) > threads.length,
requestedReviewers: reviewers,
requestedReviewerCount: node.reviewRequests?.totalCount ?? reviewers.length,
reviewDecision: node.reviewDecision ?? null,
}];
});
}

private async searchPulls(token: string, q: string): Promise<ReviewRequest[]> {
Expand All @@ -306,8 +393,8 @@ export class GitHubApi {
try {
result = await this.json(token, `/search/issues?${query.toString()}`);
} catch (err) {
// A search GitHub refuses (rate limit, an App without access) leaves the list empty, not the page broken.
if (err instanceof GitHubApiError && (err.status === 422 || err.status === 403)) {
// A query GitHub cannot validate, e.g. for an App without access, has no results. A rate limit's 403 is a failure.
if (err instanceof GitHubApiError && err.status === 422) {
console.warn(`Searching pull requests failed: ${err.message}`);
return [];
}
Expand Down
Loading
Loading