diff --git a/apps/diffshub/app/_home/HomeGitHubTokenForm.tsx b/apps/diffshub/app/_home/HomeGitHubTokenForm.tsx index cb59b2fce..5742fa845 100644 --- a/apps/diffshub/app/_home/HomeGitHubTokenForm.tsx +++ b/apps/diffshub/app/_home/HomeGitHubTokenForm.tsx @@ -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 ( 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', + }, + }); +} diff --git a/apps/diffshub/app/api/github-user/route.ts b/apps/diffshub/app/api/github-user/route.ts new file mode 100644 index 000000000..c0bd296a5 --- /dev/null +++ b/apps/diffshub/app/api/github-user/route.ts @@ -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', + }, + }); +} diff --git a/apps/diffshub/components/CommentAuthorAvatar.tsx b/apps/diffshub/components/CommentAuthorAvatar.tsx index b795ac038..b9159e4a8 100644 --- a/apps/diffshub/components/CommentAuthorAvatar.tsx +++ b/apps/diffshub/components/CommentAuthorAvatar.tsx @@ -4,6 +4,7 @@ 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; } @@ -11,9 +12,13 @@ interface CommentAuthorAvatarProps { // 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 (
0 ? `Line ${comment.lineNumber}` : 'a line'; + } + if (comment.lineType === 'context') { + return `Line ${comment.lineNumber}`; + } + const sigil = comment.side === 'additions' ? '+' : '-'; + return `Line ${sigil}${comment.lineNumber}`; } function getCommentLineClassName( @@ -78,10 +84,27 @@ function handleRowClick( } export const DiffsHubCommentsList = memo(function DiffsHubCommentsList({ + canPostToGitHub, commentSections, onSelectComment, onSelectItem, }: DiffsHubCommentsListProps) { + // Keys of outdated rows currently expanded to show their full thread. + const [expandedKeys, setExpandedKeys] = useState>( + () => new Set() + ); + const toggleExpanded = (key: string) => { + setExpandedKeys((previous) => { + const next = new Set(previous); + if (next.has(key)) { + next.delete(key); + } else { + next.add(key); + } + return next; + }); + }; + if (commentSections.length === 0) { return (
@@ -93,7 +116,10 @@ export const DiffsHubCommentsList = memo(function DiffsHubCommentsList({ {' '} - button to add fake code comments. + button to{' '} + {canPostToGitHub === true + ? 'comment on this pull request.' + : 'add fake code comments.'}

@@ -125,53 +151,140 @@ export const DiffsHubCommentsList = memo(function DiffsHubCommentsList({ )}
- {section.comments.map((comment) => ( - - ))} + {section.comments.map((comment) => + comment.anchor === 'outdated' ? ( + toggleExpanded(comment.key)} + /> + ) : ( + + ) + )}
))} ); }); + +// The avatar + text column shared by every sidebar comment row. `expanded` +// is undefined for rows that navigate on click; expandable (outdated) rows +// pass their current state, which unclamps the message and shows a chevron. +function CommentRowContent({ + comment, + expanded, +}: { + comment: DiffsHubSavedCommentEntry; + expanded?: boolean; +}) { + return ( + <> + +
+
+ {comment.author} commented on{' '} + + {getCommentLineLabel(comment)} + + {comment.replyCount != null && comment.replyCount > 0 && ( + + {' '} + · {comment.replyCount}{' '} + {comment.replyCount === 1 ? 'reply' : 'replies'} + + )} + {comment.anchor === 'outdated' && ( + + Outdated + + )} +
+

+ {comment.message} +

+
+ {expanded != null && ( +