Skip to content
Draft
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
3 changes: 2 additions & 1 deletion apps/diffshub/app/_home/HomeGitHubTokenForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import { GitHubTokenControl } from '@/components/GitHubTokenControl';
import { useGitHubToken } from '@/components/useGitHubToken';

export const HomeGitHubTokenForm = memo(function HomeGitHubTokenForm() {
const { clearToken, hasToken, setToken } = useGitHubToken();
const { capability, clearToken, hasToken, setToken } = useGitHubToken();
return (
<GitHubTokenControl
active={hasToken}
capability={capability}
className="border-border/70 border-t px-4 py-3"
onClear={clearToken}
onSave={setToken}
Expand Down
129 changes: 129 additions & 0 deletions apps/diffshub/app/api/github-comments/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { type NextRequest } from 'next/server';

import {
GitHubCommentsRequestError,
loadGitHubComments,
parsePostGitHubCommentRequest,
postGitHubComment,
} from '@/lib/githubCommentsServer';
import { parseGitHubDiffSource } from '@/lib/githubDiffSource';

const CACHE_CONTROL = 'no-store';

// Upstream statuses passed through to the client as-is; anything else
// collapses to 502. 403 in particular must survive: it drives the client's
// write-capability downgrade.
const PASSTHROUGH_ERROR_STATUSES = new Set([400, 401, 403, 404, 422, 429]);

// Read-side proxy for GitHub comments. Browser code only talks to this
// same-origin route: an optional user PAT arrives as a bearer header, and for
// public sources without one the server falls back to its env token,
// mirroring the other GitHub proxy routes. Responses are never cached
// server-side so PAT-derived data cannot leak across viewers.
export async function GET(request: NextRequest) {
const path = request.nextUrl.searchParams.get('path');
const token = parseBearerToken(request.headers.get('authorization'));

if (path == null) {
return createJSONResponse(
{ error: 'path parameter is required.' },
{ status: 400 }
);
}

const source = parseGitHubDiffSource(path);
if (source == null) {
return createJSONResponse(
{ error: 'path is not a supported GitHub diff source.' },
{ status: 400 }
);
}

try {
return createJSONResponse(await loadGitHubComments(source, { token }));
} catch (error) {
return createErrorResponse(error);
}
}

// Write-side proxy: posts a review comment or thread reply to the pull
// request named by `path`. Requires the caller's own token — the server env
// token is never used to author comments on a user's behalf.
export async function POST(request: NextRequest) {
const path = request.nextUrl.searchParams.get('path');
const token = parseBearerToken(request.headers.get('authorization'));

if (path == null) {
return createJSONResponse(
{ error: 'path parameter is required.' },
{ status: 400 }
);
}

const source = parseGitHubDiffSource(path);
if (source == null) {
return createJSONResponse(
{ error: 'path is not a supported GitHub diff source.' },
{ status: 400 }
);
}

if (token == null) {
return createJSONResponse(
{ error: 'Posting comments requires a GitHub token.' },
{ status: 401 }
);
}

const body: unknown = await request.json().catch(() => undefined);
const postRequest = parsePostGitHubCommentRequest(body);
if (postRequest == null) {
return createJSONResponse(
{ error: 'Unsupported comment payload.' },
{ status: 400 }
);
}

try {
return createJSONResponse(
await postGitHubComment(source, postRequest, { token })
);
} catch (error) {
return createErrorResponse(error);
}
}

function createErrorResponse(error: unknown): Response {
const status =
error instanceof GitHubCommentsRequestError &&
PASSTHROUGH_ERROR_STATUSES.has(error.status)
? error.status
: 502;
return createJSONResponse(
{ error: error instanceof Error ? error.message : 'Unknown error' },
{ status }
);
}

function parseBearerToken(value: string | null): string | undefined {
if (value == null) {
return undefined;
}

const match = /^Bearer\s+(.+)$/i.exec(value.trim());
const token = match?.[1]?.trim();
return token == null || token === '' ? undefined : token;
}

function createJSONResponse(
body: unknown,
options: { status?: number } = {}
): Response {
return Response.json(body, {
status: options.status ?? 200,
headers: {
'Cache-Control': CACHE_CONTROL,
Vary: 'Authorization',
},
});
}
51 changes: 51 additions & 0 deletions apps/diffshub/app/api/github-user/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { type NextRequest } from 'next/server';

import { loadGitHubTokenUser } from '@/lib/githubCommentsServer';

const CACHE_CONTROL = 'no-store';

// Resolves the identity of the caller's GitHub token (login + avatar) so the
// comment form can show who a posted comment will be authored as. Requires
// the user's own token — there is nothing meaningful to resolve without one.
export async function GET(request: NextRequest) {
const token = parseBearerToken(request.headers.get('authorization'));

if (token == null) {
return createJSONResponse(
{ error: 'Resolving the GitHub user requires a token.' },
{ status: 401 }
);
}

try {
return createJSONResponse(await loadGitHubTokenUser({ token }));
} catch (error) {
return createJSONResponse(
{ error: error instanceof Error ? error.message : 'Unknown error' },
{ status: 502 }
);
}
}

function parseBearerToken(value: string | null): string | undefined {
if (value == null) {
return undefined;
}

const match = /^Bearer\s+(.+)$/i.exec(value.trim());
const token = match?.[1]?.trim();
return token == null || token === '' ? undefined : token;
}

function createJSONResponse(
body: unknown,
options: { status?: number } = {}
): Response {
return Response.json(body, {
status: options.status ?? 200,
headers: {
'Cache-Control': CACHE_CONTROL,
Vary: 'Authorization',
},
});
}
7 changes: 6 additions & 1 deletion apps/diffshub/components/CommentAuthorAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,21 @@ import { cn } from '@/lib/cn';
interface CommentAuthorAvatarProps {
// A stable seed (e.g. comment key or a fixed name) used to pick the avatar.
seed: string;
avatarUrl?: string;
className?: string;
}

// Renders a circular avatar image for a comment author.
// Defaults to 32px (size-8); pass className to override for other sizes.
export function CommentAuthorAvatar({
seed,
avatarUrl,
className,
}: CommentAuthorAvatarProps) {
const { name, avatarSrc } = getCommentPersona(seed);
const { name, avatarSrc } =
avatarUrl == null
? getCommentPersona(seed)
: { avatarSrc: avatarUrl, name: seed };
return (
<div className="relative shrink-0 self-start after:absolute after:inset-0 after:z-10 after:block after:rounded-full after:border after:border-[rgb(0_0_0_/_0.1)] after:content-[''] dark:after:border-[rgb(255_255_255_/_0.1)]">
<img
Expand Down
Loading