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
19 changes: 12 additions & 7 deletions packages/cli/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,10 @@ import {
import {
detectRemote as detectGitHubRemote,
fetchDetails as fetchGitHubDetails,
pushComments as pushGitHubComments,
createReview as createGitHubReview,
pullComments as pullGitHubComments,
type PrComment,
type ReviewEvent,
} from '@diffity/github';
import { findOrCreateSession } from './session.js';
import { createThread, addReply, getThreadsForSession } from './threads.js';
Expand Down Expand Up @@ -78,6 +79,8 @@ const MIME_TYPES: Record<string, string> = {
* `script-src` still needs 'unsafe-inline' for the inline scripts in the built index.html;
* markdown is sanitized separately, and every exfiltration sink is closed here.
*/
const REVIEW_EVENTS = new Set(['COMMENT', 'APPROVE', 'REQUEST_CHANGES']);

const CONTENT_SECURITY_POLICY = [
"default-src 'self'",
"base-uri 'none'",
Expand Down Expand Up @@ -499,7 +502,7 @@ export function startServer(options: ServerOptions): Promise<ServerResult> {
return;
}

if (pathname === '/api/github/push-comments' && req.method === 'POST') {
if (pathname === '/api/github/create-review' && req.method === 'POST') {
const details = githubRemote ? fetchGitHubDetails(githubRemote.owner, githubRemote.repo) : null;
if (!githubRemote || !details?.headSha) {
sendError(res, 400, 'No GitHub PR detected');
Expand All @@ -515,17 +518,19 @@ export function startServer(options: ServerOptions): Promise<ServerResult> {
return;
}
const body = JSON.parse(await readBody(req));
const comments = body.comments as PrComment[];
if (!Array.isArray(comments) || comments.length === 0) {
sendError(res, 400, 'No comments provided');
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');
return;
}
const result = pushGitHubComments(
const result = createGitHubReview(
githubRemote.owner,
githubRemote.repo,
details.prNumber,
details.headSha,
comments,
{ event, body: summary, comments },
);
sendJson(res, result);
return;
Expand Down
20 changes: 19 additions & 1 deletion packages/github/src/detection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ export function fetchDetails(owner: string, repo: string): GitHubDetails | null
prCreatedAt: pr.createdAt,
headSha: pr.headSha,
commentCount,
viewerDidAuthor: !!pr.authorLogin && pr.authorLogin === getViewerLogin(),
};
}

Expand All @@ -58,11 +59,27 @@ interface PrData {
url: string;
headSha: string;
createdAt: string;
authorLogin: string | null;
}

// gh has no `viewerDidAuthor` field, so authorship is settled by comparing logins. The
// authenticated user cannot change while the process lives, so it is asked for once.
let viewerLogin: string | null | undefined;

function getViewerLogin(): string | null {
if (viewerLogin === undefined) {
try {
viewerLogin = exec('gh api user --jq .login') || null;
} catch {
viewerLogin = null;
}
}
return viewerLogin;
}

function getPr(): PrData | null {
try {
const json = exec('gh pr view --json number,title,url,headRefOid,createdAt');
const json = exec('gh pr view --json number,title,url,headRefOid,createdAt,author');
const data = JSON.parse(json);
if (data.number && data.url && data.headRefOid) {
return {
Expand All @@ -71,6 +88,7 @@ function getPr(): PrData | null {
url: data.url,
headSha: data.headRefOid,
createdAt: data.createdAt,
authorLogin: data.author?.login ?? null,
};
}
return null;
Expand Down
4 changes: 2 additions & 2 deletions packages/github/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export type { GitHubRemote, GitHubDetails, PrBase, PrComment, PushResult, PulledThread } from './types.js';
export type { GitHubRemote, GitHubDetails, PrBase, PrComment, PulledThread, ReviewEvent, ReviewResult, ReviewSubmission } from './types.js';
export { detectRemote, fetchDetails, isCliInstalled, isAuthenticated } from './detection.js';
export { getFiles, getComments, getCommentCount, pushComments, pullComments } from './pr.js';
export { getFiles, getComments, getCommentCount, pullComments, createReview } from './pr.js';
export { isGitHubPrUrl, parseGitHubPrUrl, checkoutPr, getPrBase, parsePrBase } from './pr-url.js';
115 changes: 76 additions & 39 deletions packages/github/src/pr.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { execSync } from 'node:child_process';
import { execFileSync, execSync } from 'node:child_process';
import { exec } from './exec.js';
import type { PrComment, PushResult, PulledThread } from './types.js';
import type { PrComment, PulledThread, ReviewResult, ReviewSubmission } from './types.js';

export function getFiles(owner: string, repo: string, prNumber: number): Set<string> {
try {
Expand Down Expand Up @@ -114,61 +114,98 @@ function isDuplicate(existing: ExistingComment[], comment: PrComment): boolean {
);
}

export function pushComments(
interface ReviewCommentPayload {
path: string;
side: string;
line: number;
body: string;
start_line?: number;
start_side?: string;
}

function toReviewComment(comment: PrComment): ReviewCommentPayload {
const payload: ReviewCommentPayload = {
path: comment.filePath,
side: comment.side,
line: comment.endLine,
body: comment.body,
};

if (comment.startLine && comment.startLine !== comment.endLine) {
payload.start_line = comment.startLine;
payload.start_side = comment.side;
}

return payload;
}

/**
* Submits one review holding every comment, rather than posting them one at a time: the author
* gets a single notification, a partial failure cannot leave half a review on the pull request,
* and the summary body has somewhere to live.
*
* GitHub does not deduplicate, so comments already on the pull request are dropped first.
*/
export function createReview(
owner: string,
repo: string,
prNumber: number,
headSha: string,
comments: PrComment[],
): PushResult {
submission: ReviewSubmission,
): ReviewResult {
const prFiles = getFiles(owner, repo, prNumber);
const existing = getComments(owner, repo, prNumber);

let pushed = 0;
let skipped = 0;
let failed = 0;
const errors: string[] = [];
const comments: ReviewCommentPayload[] = [];
let skipped = 0;

for (const comment of comments) {
for (const comment of submission.comments) {
if (!prFiles.has(comment.filePath)) {
failed++;
errors.push(`${comment.filePath} — not in PR diff (push your changes first)`);
continue;
}

if (isDuplicate(existing, comment)) {
skipped++;
continue;
}
comments.push(toReviewComment(comment));
}

try {
const payload: Record<string, unknown> = {
body: comment.body,
commit_id: headSha,
path: comment.filePath,
side: comment.side,
line: comment.endLine,
};
if (comment.startLine && comment.startLine !== comment.endLine) {
payload.start_line = comment.startLine;
payload.start_side = comment.side;
}
execSync(
`gh api repos/${owner}/${repo}/pulls/${prNumber}/comments --method POST --input -`,
{
input: JSON.stringify(payload),
encoding: 'utf-8',
stdio: 'pipe',
},
);
pushed++;
} catch (err) {
failed++;
const msg = err instanceof Error ? err.message : String(err);
const ghLine = msg.split('\n').find(l => l.includes('gh:'));
errors.push(`${comment.filePath}:${comment.endLine} — ${ghLine ? ghLine.trim() : 'GitHub API error'}`);
}
const dropped = errors.length;
const body = submission.body.trim();

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

return { pushed, skipped, failed, errors };
try {
const raw = execFileSync(
'gh',
['api', `repos/${owner}/${repo}/pulls/${prNumber}/reviews`, '--method', 'POST', '--input', '-'],
{
input: JSON.stringify({ commit_id: headSha, event: submission.event, body, comments }),
encoding: 'utf-8',
stdio: 'pipe',
},
);
const review = JSON.parse(raw) as { html_url?: string };
return {
submitted: comments.length,
skipped,
failed: dropped,
errors,
reviewUrl: review.html_url ?? null,
};
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
const ghLine = msg.split('\n').find(line => line.includes('gh:'));
return {
submitted: 0,
skipped,
failed: dropped + comments.length,
errors: [...errors, ghLine ? ghLine.trim() : 'GitHub rejected the review'],
reviewUrl: null,
};
}
}
18 changes: 18 additions & 0 deletions packages/github/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ export interface GitHubDetails {
prCreatedAt: string;
headSha: string;
commentCount: number;
/** GitHub refuses to approve or request changes on your own pull request. */
viewerDidAuthor: boolean;
}

export type ReviewEvent = 'COMMENT' | 'APPROVE' | 'REQUEST_CHANGES';

export interface ReviewSubmission {
event: ReviewEvent;
body: string;
comments: PrComment[];
}

export interface ReviewResult {
submitted: number;
skipped: number;
failed: number;
errors: string[];
reviewUrl: string | null;
}

export interface PrBase {
Expand Down
Loading