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
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ range syntax (main..feature, main...feature) also work.`)
}

let prBase: PrBase | null = null;
let parsedPrNumber: number | undefined;

if (refs.length === 1 && isGitHubPrUrl(refs[0])) {
const parsed = parseGitHubPrUrl(refs[0]);
Expand Down Expand Up @@ -181,6 +182,7 @@ range syntax (main..feature, main...feature) also work.`)
process.exit(1);
}

parsedPrNumber = parsed.number;
refs[0] = prBase.oid;
}

Expand Down Expand Up @@ -316,6 +318,7 @@ range syntax (main..feature, main...feature) also work.`)
description,
effectiveRef,
pinnedRef: prBase?.oid,
prNumber: parsedPrNumber,
version: pkg.version,
registryInfo: { repoRoot, repoHash, repoName },
});
Expand Down
18 changes: 13 additions & 5 deletions packages/cli/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,8 @@ interface ServerOptions {
* from. A `ref` in the URL that disagrees is corrected rather than honoured.
*/
pinnedRef?: string;
/** Set in pull-request mode, so details still resolve on a detached checkout. */
prNumber?: number;
version?: string;
registryInfo?: {
repoRoot: string;
Expand Down Expand Up @@ -230,6 +232,7 @@ export function startServer(options: ServerOptions): Promise<ServerResult> {
version,
registryInfo,
pinnedRef,
prNumber,
} = options;

const includeUntracked = diffArgs.length === 0;
Expand Down Expand Up @@ -488,13 +491,13 @@ export function startServer(options: ServerOptions): Promise<ServerResult> {
sendJson(res, null);
return;
}
const details = fetchGitHubDetails(githubRemote.owner, githubRemote.repo);
const details = fetchGitHubDetails(githubRemote.owner, githubRemote.repo, prNumber);
sendJson(res, details);
return;
}

if (pathname === '/api/github/create-review' && req.method === 'POST') {
const details = githubRemote ? fetchGitHubDetails(githubRemote.owner, githubRemote.repo) : null;
const details = githubRemote ? fetchGitHubDetails(githubRemote.owner, githubRemote.repo, prNumber) : null;
if (!githubRemote || !details?.headSha) {
sendError(res, 400, 'No GitHub PR detected');
return;
Expand All @@ -512,8 +515,13 @@ export function startServer(options: ServerOptions): Promise<ServerResult> {
const comments = (body.comments ?? []) as PrComment[];
const summary = typeof body.body === 'string' ? body.body : '';
const event = REVIEW_EVENTS.has(body.event) ? (body.event as ReviewEvent) : 'COMMENT';
if (!Array.isArray(comments) || (comments.length === 0 && !summary.trim())) {
sendError(res, 400, 'A review needs a summary or at least one comment');
if (!Array.isArray(comments)) {
sendError(res, 400, 'comments must be an array');
return;
}
// A verdict carries its own meaning; only a plain comment needs something in it.
if (event === 'COMMENT' && comments.length === 0 && !summary.trim()) {
sendError(res, 400, 'A comment review needs a summary or at least one comment');
return;
}
const result = createGitHubReview(
Expand All @@ -532,7 +540,7 @@ export function startServer(options: ServerOptions): Promise<ServerResult> {
sendError(res, 400, 'No GitHub repo detected');
return;
}
const details = fetchGitHubDetails(githubRemote.owner, githubRemote.repo);
const details = fetchGitHubDetails(githubRemote.owner, githubRemote.repo, prNumber);
if (!details) {
sendError(res, 400, 'No GitHub PR detected');
return;
Expand Down
18 changes: 14 additions & 4 deletions packages/github/src/detection.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { exec, execSilent } from './exec.js';
import { getReviews } from './reviews.js';
import type { GitHubRemote, GitHubDetails } from './types.js';

export function getRemote(): { owner: string; repo: string } | null {
Expand Down Expand Up @@ -30,12 +31,12 @@ export function detectRemote(): GitHubRemote | null {
return remote;
}

export function fetchDetails(owner: string, repo: string): GitHubDetails | null {
export function fetchDetails(owner: string, repo: string, prNumber?: number): GitHubDetails | null {
if (!isCliInstalled() || !isAuthenticated()) {
return null;
}

const pr = getPr();
const pr = getPr(prNumber);
if (!pr) {
return null;
}
Expand All @@ -50,6 +51,8 @@ export function fetchDetails(owner: string, repo: string): GitHubDetails | null
headSha: pr.headSha,
commentCount,
viewerDidAuthor: !!pr.authorLogin && pr.authorLogin === getViewerLogin(),
prBody: pr.body,
reviews: getReviews(owner, repo, pr.number),
};
}

Expand All @@ -60,6 +63,7 @@ interface PrData {
headSha: string;
createdAt: string;
authorLogin: string | null;
body: string;
}

// gh has no `viewerDidAuthor` field, so authorship is settled by comparing logins. The
Expand All @@ -77,9 +81,14 @@ function getViewerLogin(): string | null {
return viewerLogin;
}

function getPr(): PrData | null {
/**
* Without a number, gh resolves the pull request from the current branch — which fails on a
* detached checkout, and a merged pull request has no branch left to check out.
*/
function getPr(prNumber?: number): PrData | null {
try {
const json = exec('gh pr view --json number,title,url,headRefOid,createdAt,author');
const target = prNumber ? `${prNumber} ` : '';
const json = exec(`gh pr view ${target}--json number,title,url,headRefOid,createdAt,author,body`);
const data = JSON.parse(json);
if (data.number && data.url && data.headRefOid) {
return {
Expand All @@ -89,6 +98,7 @@ function getPr(): PrData | null {
headSha: data.headRefOid,
createdAt: data.createdAt,
authorLogin: data.author?.login ?? null,
body: data.body ?? '',
};
}
return null;
Expand Down
3 changes: 2 additions & 1 deletion packages/github/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export type { GitHubRemote, GitHubDetails, PrBase, PrComment, PulledThread, ReviewEvent, ReviewResult, ReviewSubmission } from './types.js';
export type { GitHubRemote, GitHubDetails, PrBase, PrComment, PrReview, PulledThread, ReviewEvent, ReviewResult, ReviewSubmission } from './types.js';
export { detectRemote, fetchDetails, isCliInstalled, isAuthenticated } from './detection.js';
export { getFiles, getComments, getCommentCount, pullComments, createReview } from './pr.js';
export { getReviews, parseReviews } from './reviews.js';
export { isGitHubPrUrl, parseGitHubPrUrl, checkoutPr, getPrBase, parsePrBase } from './pr-url.js';
2 changes: 1 addition & 1 deletion packages/github/src/pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ export function createReview(
const dropped = errors.length;
const body = submission.body.trim();

if (comments.length === 0 && !body) {
if (comments.length === 0 && !body && submission.event === 'COMMENT') {
return { submitted: 0, skipped, failed: dropped, errors, reviewUrl: null };
}

Expand Down
54 changes: 54 additions & 0 deletions packages/github/src/reviews.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { execFileSync } from 'node:child_process';
import type { PrReview } from './types.js';

interface RawReview {
user?: { login?: string; type?: string };
state?: string;
body?: string;
submitted_at?: string;
}

/**
* A review with no body and no verdict has nothing a reader can act on — the forge records one of
* those for a batch of inline comments, and those arrive through the inline pull instead.
*/
function isWorthShowing(review: PrReview): boolean {
return review.body.trim().length > 0 || review.state !== 'COMMENTED';
}

export function parseReviews(json: string): PrReview[] {
let data: unknown;
try {
data = JSON.parse(json);
} catch {
return [];
}

if (!Array.isArray(data)) {
return [];
}

return (data as RawReview[])
.map(review => ({
author: review.user?.login ?? 'unknown',
isBot: review.user?.type === 'Bot',
state: review.state ?? 'COMMENTED',
body: review.body ?? '',
submittedAt: review.submitted_at ?? '',
}))
.filter(isWorthShowing);
}

export function getReviews(owner: string, repo: string, prNumber: number): PrReview[] {
try {
return parseReviews(
execFileSync(
'gh',
['api', `repos/${owner}/${repo}/pulls/${prNumber}/reviews`, '--paginate'],
{ encoding: 'utf-8', stdio: 'pipe', maxBuffer: 10 * 1024 * 1024 },
),
);
} catch {
return [];
}
}
12 changes: 12 additions & 0 deletions packages/github/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ export interface GitHubDetails {
commentCount: number;
/** GitHub refuses to approve or request changes on your own pull request. */
viewerDidAuthor: boolean;
/** The description, which is where the author says what the change is for. */
prBody: string;
reviews: PrReview[];
}

export type ReviewEvent = 'COMMENT' | 'APPROVE' | 'REQUEST_CHANGES';
Expand All @@ -30,6 +33,15 @@ export interface ReviewResult {
reviewUrl: string | null;
}

export interface PrReview {
author: string;
isBot: boolean;
/** APPROVED, CHANGES_REQUESTED, COMMENTED, DISMISSED. */
state: string;
body: string;
submittedAt: string;
}

export interface PrBase {
/** The base branch's name, for display. */
name: string;
Expand Down
61 changes: 61 additions & 0 deletions packages/github/tests/reviews.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { describe, it, expect } from 'vitest';
import { parseReviews } from '../src/reviews.js';

const raw = JSON.stringify([
{
user: { login: 'copilot-pull-request-reviewer[bot]', type: 'Bot' },
state: 'COMMENTED',
body: '## Pull request overview\n\nUpdates the experiment form.',
submitted_at: '2026-08-21T11:54:36Z',
},
{
user: { login: 'fiddur', type: 'User' },
state: 'APPROVED',
body: 'lgtm',
submitted_at: '2026-08-21T12:11:34Z',
},
{
user: { login: 'someone', type: 'User' },
state: 'COMMENTED',
body: '',
submitted_at: '2026-08-21T12:20:00Z',
},
]);

describe('parseReviews', () => {
it('keeps author, state, body and time, newest last', () => {
const reviews = parseReviews(raw);

expect(reviews).toHaveLength(2);
expect(reviews[0]).toEqual({
author: 'copilot-pull-request-reviewer[bot]',
isBot: true,
state: 'COMMENTED',
body: '## Pull request overview\n\nUpdates the experiment form.',
submittedAt: '2026-08-21T11:54:36Z',
});
expect(reviews[1].author).toBe('fiddur');
expect(reviews[1].state).toBe('APPROVED');
});

it('drops a review with nothing to read', () => {
// A bodiless COMMENTED review is what the forge records for inline-only comments; the inline
// comments themselves come through the existing pull, so there is nothing to show here.
expect(parseReviews(raw).some(review => review.author === 'someone')).toBe(false);
});

it('keeps a bodiless approval, because the verdict is the content', () => {
const approvals = JSON.stringify([
{ user: { login: 'a', type: 'User' }, state: 'APPROVED', body: '', submitted_at: '2026-01-01T00:00:00Z' },
{ user: { login: 'b', type: 'User' }, state: 'CHANGES_REQUESTED', body: '', submitted_at: '2026-01-02T00:00:00Z' },
]);

expect(parseReviews(approvals).map(r => r.state)).toEqual(['APPROVED', 'CHANGES_REQUESTED']);
});

it('survives junk rather than failing the page', () => {
expect(parseReviews('not json')).toEqual([]);
expect(parseReviews('{}')).toEqual([]);
expect(parseReviews('[]')).toEqual([]);
});
});
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@react-router/dev": "^7.13.2",
"@react-router/fs-routes": "^7.13.2",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.5",
"@types/nprogress": "^0.2.3",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/src/components/diff/diff-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { Sidebar } from '../layout/sidebar';
import { ShortcutModal } from '../layout/shortcut-modal';
import { StaleDiffBanner } from '../layout/stale-diff-banner';
import { ReviewProgressBanner } from '../layout/review-progress-banner';
import { PullRequestPanel } from '../layout/pull-request-panel';
import { CheckCircleIcon } from '../icons/check-circle-icon';
import { PageLoader } from '../layout/skeleton';
import { useDiffStaleness } from '../../hooks/use-diff-staleness';
Expand Down Expand Up @@ -423,6 +424,7 @@ export function DiffPage() {
onGitHubPulled={() => queryClient.invalidateQueries({ queryKey: ['threads'] })}
/>
{isStale && <StaleDiffBanner onRefresh={handleRefreshDiff} />}
<PullRequestPanel details={githubDetails} />
{info?.review?.inProgress && (
<ReviewProgressBanner review={info.review} findings={threads.length} />
)}
Expand Down
14 changes: 12 additions & 2 deletions packages/ui/src/components/layout/github-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '../../lib/api';
import type { CommentThread } from '../comments/types';
import {
canSubmitReview,
isSubmittable,
summaryFromGeneralThreads,
threadToPayload,
Expand Down Expand Up @@ -86,7 +87,12 @@ export function GitHubDialog(props: GitHubDialogProps) {
}, [onClose]);

const chosen = submittable.filter(thread => selected.has(thread.id));
const canSubmit = !reviewInProgress && (chosen.length > 0 || summary.trim().length > 0);
const canSubmit = canSubmitReview({
event,
comments: chosen.length,
summary,
reviewInProgress,
});

const toggle = (id: string) => {
setSelected(prev => {
Expand Down Expand Up @@ -304,7 +310,11 @@ export function GitHubDialog(props: GitHubDialogProps) {
) : (
<UploadIcon className="w-3 h-3" />
)}
Submit {chosen.length > 0 ? `${chosen.length} ` : ''}as one review
{chosen.length > 0
? `Submit ${chosen.length} as one review`
: event === 'COMMENT'
? 'Submit as one review'
: `Submit ${EVENT_LABELS[event].toLowerCase()}`}
</button>
</div>

Expand Down
Loading