From 406fcd99fbb0a39fc01baeb2033ded61f0c07ca9 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Sun, 2 Aug 2026 17:06:34 -0700 Subject: [PATCH 1/5] feat(diffshub): Add read/write permission choice to GitHub tokens Saving a GitHub token now asks what the token is allowed to do: read private diffs only, or also post PR comments. The form's create-token link opens GitHub with the matching permissions preselected, and the active state shows which kind of token is saved. The app previously stored the bare token string and never knew what it was allowed to do, which blocks the upcoming GitHub comments work. Tokens are now saved in localStorage as a versioned JSON envelope recording the declared capability; existing bare-string tokens keep working and load as read-only. The two token forms (home page and viewer settings) also stay in sync, sharing changes via a same-tab broadcast and the cross-tab storage event. --- .../app/_home/HomeGitHubTokenForm.tsx | 3 +- apps/diffshub/components/DiffsHubHeader.tsx | 6 +- .../components/GitHubTokenControl.tsx | 51 ++++++- apps/diffshub/components/ReviewUI.tsx | 2 + apps/diffshub/components/useGitHubToken.ts | 131 ++++++++++++++---- apps/diffshub/lib/githubTokenStorage.ts | 76 ++++++++++ 6 files changed, 235 insertions(+), 34 deletions(-) create mode 100644 apps/diffshub/lib/githubTokenStorage.ts 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 ( diff --git a/apps/diffshub/components/GitHubTokenControl.tsx b/apps/diffshub/components/GitHubTokenControl.tsx index 737054030..1c44969b2 100644 --- a/apps/diffshub/components/GitHubTokenControl.tsx +++ b/apps/diffshub/components/GitHubTokenControl.tsx @@ -4,40 +4,53 @@ import { IconBrandGithub } from '@pierre/icons'; import { type FormEvent, memo, useState } from 'react'; import { Button } from '@/components/Button'; +import { ButtonGroup, ButtonGroupItem } from '@/components/ButtonGroup'; import { Input } from '@/components/Input'; import { cn } from '@/lib/cn'; +import type { GitHubTokenCapability } from '@/lib/githubTokenStorage'; export const CREATE_TOKEN_URL = 'https://github.com/settings/personal-access-tokens/new?name=DiffsHub%20Private%20Repo%20Read%20Access&description=Read+private+PRs+and+expand+collapsed+hunks&expires_in=90&contents=read&pull_requests=read&issues=read'; +export const CREATE_WRITE_TOKEN_URL = + 'https://github.com/settings/personal-access-tokens/new?name=DiffsHub%20GitHub%20Access&description=Read+private+PRs+and+post+review+comments&expires_in=90&contents=read&pull_requests=write&issues=write'; + export const CLASSIC_TOKEN_URL = 'https://github.com/settings/tokens/new?description=DiffsHub%20Private%20Repo%20Read%20Access&scopes=repo&default_expires_at=90'; interface GitHubTokenControlProps { active: boolean; + capability: GitHubTokenCapability; className?: string; onClear(): void; - onSave(token: string): void; + onSave(token: string, capability: GitHubTokenCapability): void; title?: string; } export const GitHubTokenControl = memo(function GitHubTokenControl({ active, + capability, className, onClear, onSave, title = 'GitHub Token', }: GitHubTokenControlProps) { const [draftToken, setDraftToken] = useState(''); + const [draftCapability, setDraftCapability] = + useState('read-write'); const canSave = draftToken.trim() !== ''; const handleSubmit = (event: FormEvent) => { event.preventDefault(); if (!canSave) { return; } - onSave(draftToken); + onSave(draftToken, draftCapability); setDraftToken(''); }; + const createTokenUrl = + draftCapability === 'read-write' + ? CREATE_WRITE_TOKEN_URL + : CREATE_TOKEN_URL; return (
@@ -52,13 +65,19 @@ export const GitHubTokenControl = memo(function GitHubTokenControl({ : 'text-muted-foreground border-current/20' )} > - {active ? 'Active' : 'Optional'} + {active + ? capability === 'read-write' + ? 'Active · Write' + : 'Active' + : 'Optional'} {active ? ( <>

- Using your PAT from localStorage. Clear it to create a new one. + {capability === 'read-write' + ? 'Using your PAT from localStorage. It can read private diffs and post comments. Clear it to create a new one.' + : 'Using your read-only PAT from localStorage. Clear it and save a token with write access to post comments.'}

- ))} + ) : ( + + ) + )}
))} ); }); + +// 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 && ( +
+ ); +} diff --git a/apps/diffshub/components/ExampleAnnotation.tsx b/apps/diffshub/components/LocalCommentAnnotation.tsx similarity index 93% rename from apps/diffshub/components/ExampleAnnotation.tsx rename to apps/diffshub/components/LocalCommentAnnotation.tsx index ca0191486..bd0497588 100644 --- a/apps/diffshub/components/ExampleAnnotation.tsx +++ b/apps/diffshub/components/LocalCommentAnnotation.tsx @@ -8,19 +8,19 @@ import { annotationCardBase } from '@/lib/annotation'; import { cn } from '@/lib/cn'; import type { SavedCommentMetadata } from '@/lib/types'; -interface ExampleAnnotationProps { +interface LocalCommentAnnotationProps { annotation: DiffLineAnnotation; itemId: string; onDelete(itemId: string, key: string): void; onToggleSelection(selection: CodeViewLineSelection): void; } -export const ExampleAnnotation = memo(function ExampleAnnotation({ +export const LocalCommentAnnotation = memo(function LocalCommentAnnotation({ annotation, itemId, onDelete, onToggleSelection, -}: ExampleAnnotationProps) { +}: LocalCommentAnnotationProps) { const selection = { id: itemId, range: annotation.metadata.range }; return (
{ setFileTreeOverlayOpen(false); + // File-level and outdated comments have no selectable lines in the + // current diff; jump to the file instead. + if (comment.anchor != null) { + viewerRef.current?.scrollTo({ + type: 'item', + id: comment.itemId, + align: 'start', + behavior: 'smooth-auto', + }); + return; + } viewerRef.current?.setSelectedLines({ id: comment.itemId, range: comment.range, diff --git a/apps/diffshub/components/useGitHubComments.ts b/apps/diffshub/components/useGitHubComments.ts index 7cf855385..73b3211a6 100644 --- a/apps/diffshub/components/useGitHubComments.ts +++ b/apps/diffshub/components/useGitHubComments.ts @@ -1,11 +1,13 @@ 'use client'; +import type { DiffLineAnnotation } from '@pierre/diffs'; import type { CodeViewHandle } from '@pierre/diffs/react'; import { type Dispatch, type RefObject, type SetStateAction, useEffect, + useRef, useState, } from 'react'; @@ -45,13 +47,23 @@ interface UseGitHubCommentsResult { payload: GitHubCommentsPayload | undefined; } +// One thread mapped onto the rendered diff: the sidebar row plus, when the +// thread anchors to a visible line or a whole file, the inline annotation. +interface MappedGitHubThreadView { + annotationsByItemId: ReadonlyMap< + string, + DiffLineAnnotation[] + >; + sections: DiffsHubSavedCommentItem[]; +} + // Loads the GitHub comments for the viewed source through the same-origin -// /api/github-comments proxy and feeds the anchorable threads into the -// comments sidebar. Fetching starts immediately, but applying waits until the -// patch loader reports 'ready' — items stream in incrementally and item ids -// can be renamed mid-stream, so anchoring against a half-built view is -// unsafe. A comments failure never blocks the diff itself; the error is only -// surfaced through the returned state. +// /api/github-comments proxy, renders each thread as an inline annotation, +// and feeds the sidebar's comments list. Fetching starts immediately, but +// applying waits until the patch loader reports 'ready' — items stream in +// incrementally and item ids can be renamed mid-stream, so anchoring against +// a half-built view is unsafe. A comments failure never blocks the diff +// itself; the error is only surfaced through the returned state. export function useGitHubComments({ commentFileByItemId, domain, @@ -65,6 +77,10 @@ export function useGitHubComments({ }: UseGitHubCommentsOptions): UseGitHubCommentsResult { const [payload, setPayload] = useState(); const [commentsError, setCommentsError] = useState(); + // Item ids that currently carry GitHub annotations, so a re-apply (e.g. + // after a token change refetch) can clear annotations from items whose + // threads disappeared. + const annotatedItemIdsRef = useRef>(new Set()); useEffect(() => { setPayload(undefined); @@ -110,14 +126,22 @@ export function useGitHubComments({ if (payload == null || loadState !== 'ready' || treeSource == null) { return; } - const githubSections = buildGitHubCommentSections( + const viewer = viewerRef.current; + const { annotationsByItemId, sections } = mapGitHubThreads( groupGitHubCommentThreads(payload.comments), treeSource.pathToItemId, commentFileByItemId, - viewerRef.current + viewer ); + if (viewer != null) { + annotatedItemIdsRef.current = applyGitHubAnnotations( + viewer, + annotationsByItemId, + annotatedItemIdsRef.current + ); + } setCommentSections((previous) => - restoreLocalEntries(githubSections, previous, commentFileByItemId) + restoreLocalEntries(sections, previous, commentFileByItemId) ); }, [ commentFileByItemId, @@ -131,63 +155,127 @@ export function useGitHubComments({ return { commentsError, payload }; } -// Converts GitHub comment threads into the sidebar's per-file sections. One -// entry represents a whole thread, anchored where its root comment is. -// Threads that cannot be anchored are skipped for now: outdated comments (no -// current line), file-level comments, and comments on files that are not part -// of the rendered diff get their own UI in a later phase. -function buildGitHubCommentSections( +// Converts GitHub comment threads into inline annotations plus the sidebar's +// per-file sections. One entry/annotation represents a whole thread, anchored +// where its root comment is: +// - current-line threads render inline and select their range on click; +// - file-level threads render as a lineNumber-0 annotation above the file; +// - outdated threads (no line in the head diff) are sidebar-only; +// - threads on files outside the rendered diff are dropped entirely. +function mapGitHubThreads( threads: readonly GitHubCommentThread[], pathToItemId: ReadonlyMap, commentFileByItemId: DiffsHubCommentFileByItemId | null, viewer: CodeViewHandle | null -): DiffsHubSavedCommentItem[] { +): MappedGitHubThreadView { interface SectionAccumulator { comments: DiffsHubSavedCommentEntry[]; file: DiffsHubCommentSidebarFile; } const sectionsByItemId = new Map(); + const annotationsByItemId = new Map< + string, + DiffLineAnnotation[] + >(); + + const pushEntry = (itemId: string, entry: DiffsHubSavedCommentEntry) => { + const section = sectionsByItemId.get(itemId); + if (section != null) { + section.comments.push(entry); + } + }; + const pushAnnotation = ( + itemId: string, + annotation: DiffLineAnnotation + ) => { + const annotations = annotationsByItemId.get(itemId); + if (annotations == null) { + annotationsByItemId.set(itemId, [annotation]); + } else { + annotations.push(annotation); + } + }; + for (const thread of threads) { const { root } = thread; - if (root.line == null || root.subjectType === 'file') { - continue; - } const itemId = pathToItemId.get(root.path); const file = itemId == null ? null : commentFileByItemId?.get(itemId); if (itemId == null || file == null) { continue; } + if (!sectionsByItemId.has(itemId)) { + sectionsByItemId.set(itemId, { comments: [], file }); + } + + const key = `gh-${root.id}`; const side = mapGitHubCommentSide(root.side); - const item = viewer?.getItem(itemId); - const entry: DiffsHubSavedCommentEntry = { + const shared = { author: root.author.login, avatarUrl: root.author.avatarUrl, itemId, - key: `gh-${root.id}`, + key, + message: root.body, + replyCount: thread.replies.length, + side, + thread, + }; + + if (root.subjectType === 'file') { + pushEntry(itemId, { + ...shared, + anchor: 'file', + lineNumber: 0, + lineType: 'context', + range: { start: 0, end: 0 }, + }); + pushAnnotation(itemId, { + side, + lineNumber: 0, + metadata: { kind: 'github', key, thread }, + }); + continue; + } + + if (root.line == null) { + const lineNumber = root.originalLine ?? 0; + pushEntry(itemId, { + ...shared, + anchor: 'outdated', + lineNumber, + lineType: 'context', + range: { start: lineNumber, end: lineNumber }, + }); + continue; + } + + const range = { + start: root.startLine ?? root.line, + side: mapGitHubCommentSide(root.startSide ?? root.side), + end: root.line, + endSide: side, + }; + const item = viewer?.getItem(itemId); + pushEntry(itemId, { + ...shared, lineNumber: root.line, lineType: item?.type === 'diff' ? classifyCommentLineType(item.fileDiff, side, root.line) : 'change', - message: root.body, - range: { - start: root.startLine ?? root.line, - side: mapGitHubCommentSide(root.startSide ?? root.side), - end: root.line, - endSide: side, - }, + range, + }); + pushAnnotation(itemId, { side, - }; - const section = sectionsByItemId.get(itemId); - if (section == null) { - sectionsByItemId.set(itemId, { comments: [entry], file }); - } else { - section.comments.push(entry); - } + lineNumber: root.line, + metadata: { kind: 'github', key, range, thread }, + }); } const sections: DiffsHubSavedCommentItem[] = []; for (const [itemId, { comments, file }] of sectionsByItemId) { + if (comments.length === 0) { + continue; + } comments.sort((a, b) => a.lineNumber - b.lineNumber); sections.push({ comments, @@ -197,7 +285,54 @@ function buildGitHubCommentSections( }); } sections.sort((a, b) => a.fileOrder - b.fileOrder); - return sections; + return { annotationsByItemId, sections }; +} + +// Replaces the GitHub-derived annotations on viewer items with the freshly +// mapped set, leaving local draft/saved annotations untouched. Items that had +// GitHub annotations in the previous apply but not in this one are cleared. +// Returns the item ids that now carry GitHub annotations. +function applyGitHubAnnotations( + viewer: CodeViewHandle, + annotationsByItemId: ReadonlyMap< + string, + DiffLineAnnotation[] + >, + previouslyAnnotatedItemIds: ReadonlySet +): ReadonlySet { + for (const [itemId, annotations] of annotationsByItemId) { + setGitHubAnnotationsOnItem(viewer, itemId, annotations); + } + for (const itemId of previouslyAnnotatedItemIds) { + if (!annotationsByItemId.has(itemId)) { + setGitHubAnnotationsOnItem(viewer, itemId, undefined); + } + } + return new Set(annotationsByItemId.keys()); +} + +function setGitHubAnnotationsOnItem( + viewer: CodeViewHandle, + itemId: string, + annotations: readonly DiffLineAnnotation[] | undefined +): void { + const item = viewer.getItem(itemId); + if (item == null || item.type !== 'diff') { + return; + } + const localAnnotations = (item.annotations ?? []).filter( + (annotation) => annotation.metadata.kind !== 'github' + ); + const nextAnnotations = + annotations == null + ? localAnnotations + : [...localAnnotations, ...annotations]; + if (nextAnnotations.length === 0 && (item.annotations?.length ?? 0) === 0) { + return; + } + item.annotations = nextAnnotations; + item.version = typeof item.version === 'number' ? item.version + 1 : 1; + viewer.updateItem(item); } // Re-applies locally created comments on top of freshly mapped GitHub @@ -233,7 +368,7 @@ function normalizeCommentsPayload(data: unknown): GitHubCommentsPayload { } // The payload comes from our own same-origin route, so this only guards the -// fields the mapping below dereferences rather than re-validating every +// fields the mapping above dereferences rather than re-validating every // property the server already normalized. function isWireComment(value: unknown): value is GitHubCommentWire { return ( diff --git a/apps/diffshub/lib/formatRelativeTime.ts b/apps/diffshub/lib/formatRelativeTime.ts new file mode 100644 index 000000000..4c6aff236 --- /dev/null +++ b/apps/diffshub/lib/formatRelativeTime.ts @@ -0,0 +1,33 @@ +// Formats an ISO timestamp as a short relative label ("2h ago", "3d ago"), +// falling back to a plain date for anything older than a month. Returns +// undefined for missing or unparseable input so callers can just omit the +// label. +export function formatRelativeTime( + isoDate: string | undefined, + now: number = Date.now() +): string | undefined { + if (isoDate == null) { + return undefined; + } + const timestamp = Date.parse(isoDate); + if (Number.isNaN(timestamp)) { + return undefined; + } + const seconds = Math.max(0, Math.floor((now - timestamp) / 1000)); + if (seconds < 60) { + return 'just now'; + } + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return `${minutes}m ago`; + } + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return `${hours}h ago`; + } + const days = Math.floor(hours / 24); + if (days < 31) { + return `${days}d ago`; + } + return new Date(timestamp).toLocaleDateString(); +} diff --git a/apps/diffshub/lib/isGitHubAnnotation.ts b/apps/diffshub/lib/isGitHubAnnotation.ts new file mode 100644 index 000000000..a4fe048a6 --- /dev/null +++ b/apps/diffshub/lib/isGitHubAnnotation.ts @@ -0,0 +1,9 @@ +import type { DiffLineAnnotation } from '@pierre/diffs'; + +import type { CommentMetadata, GitHubCommentMetadata } from './types'; + +export function isGitHubAnnotation( + annotation: DiffLineAnnotation +): annotation is DiffLineAnnotation { + return annotation.metadata.kind === 'github'; +} diff --git a/apps/diffshub/lib/types.ts b/apps/diffshub/lib/types.ts index ef4c4d268..e1077a90f 100644 --- a/apps/diffshub/lib/types.ts +++ b/apps/diffshub/lib/types.ts @@ -1,6 +1,8 @@ import type { AnnotationSide, SelectedLineRange } from '@pierre/diffs'; import type { FileTreeGitStatusPatch, GitStatusEntry } from '@pierre/trees'; +import type { GitHubCommentThread } from './githubComments'; + export type ViewerLoadState = | 'fetching' | 'streaming' @@ -23,7 +25,21 @@ export interface DraftCommentMetadata { range: SelectedLineRange; } -export type CommentMetadata = SavedCommentMetadata | DraftCommentMetadata; +// A real GitHub comment thread rendered inline. One annotation carries the +// whole thread (root plus replies), anchored where the root comment is. +export interface GitHubCommentMetadata { + kind: 'github'; + key: string; + // Selection range for the anchored lines; absent for file-level threads, + // which have no line to select. + range?: SelectedLineRange; + thread: GitHubCommentThread; +} + +export type CommentMetadata = + | SavedCommentMetadata + | DraftCommentMetadata + | GitHubCommentMetadata; export interface DiffsHubCommentSidebarFile { fileOrder: number; @@ -46,6 +62,12 @@ export interface DiffsHubDeletedCommentEvent { } export interface DiffsHubSavedCommentEntry { + // How the comment attaches to the diff. Absent means a normal line anchor; + // 'file' is a file-level comment; 'outdated' is a GitHub comment whose line + // no longer exists in the current head diff (lineNumber then holds the + // original line, or 0 when unknown). Non-line anchors navigate to the file + // instead of selecting lines. + anchor?: 'file' | 'outdated'; author: string; avatarUrl?: string; itemId: string; @@ -54,7 +76,9 @@ export interface DiffsHubSavedCommentEntry { lineType: CommentLineType; message: string; range: SelectedLineRange; + replyCount?: number; side: AnnotationSide; + thread?: GitHubCommentThread; } export interface DiffsHubSavedCommentItem { From 4fa6c9673a5a6e3b3088b0c6b857d036b5155a08 Mon Sep 17 00:00:00 2001 From: Amadeus Demarzi Date: Sun, 2 Aug 2026 21:45:58 -0700 Subject: [PATCH 4/5] feat(diffshub): Post review comments and replies to GitHub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a pull request with a write-capable token saved, the draft comment form now posts straight to GitHub: the form shows your GitHub avatar and a posting state, then the draft becomes a real thread card with the author and timestamp from GitHub's response. Thread cards gain a GitHub-style reply form. Failures keep the draft text and surface the error as a toast; with a read-only token the local demo flow remains, labeled as saved-locally with a hint to add a write token. The new POST /api/github-comments handler proxies both shapes with the caller's token only — the server env token never authors comments — and passes through actionable upstream statuses (rate limits remapped to 429 so 403 always means missing write access). A new /api/github-user route resolves the token owner's identity for the compose forms. The token control explains fine-grained PAT resource-owner scoping and preselects the viewed repo's owner in the creation link via target_name. Both compose forms are restyled after GitHub's comment box: bordered input, avatar beside the field, Cancel/Comment actions bottom-right. The comment wire model renames author to user to match GitHub's payload field names. --- .../diffshub/app/api/github-comments/route.ts | 72 +++++++- apps/diffshub/app/api/github-user/route.ts | 51 ++++++ .../components/DiffsHubCommentsList.tsx | 9 +- apps/diffshub/components/DiffsHubHeader.tsx | 3 + apps/diffshub/components/DiffsHubSidebar.tsx | 5 + apps/diffshub/components/DiffsHubViewer.tsx | 113 ++++++++++++ apps/diffshub/components/DraftAnnotation.tsx | 74 ++++---- apps/diffshub/components/GitHubAnnotation.tsx | 132 +++++++++++++- .../components/GitHubTokenControl.tsx | 59 ++++-- apps/diffshub/components/ReviewUI.tsx | 172 +++++++++++++++++- apps/diffshub/components/useGitHubComments.ts | 22 +-- apps/diffshub/components/useGitHubToken.ts | 6 +- apps/diffshub/components/useGitHubUser.ts | 68 +++++++ apps/diffshub/lib/githubComments.ts | 50 ++++- apps/diffshub/lib/githubCommentsClient.ts | 53 ++++++ apps/diffshub/lib/githubCommentsServer.ts | 169 ++++++++++++++++- apps/diffshub/lib/types.ts | 21 +++ 17 files changed, 985 insertions(+), 94 deletions(-) create mode 100644 apps/diffshub/app/api/github-user/route.ts create mode 100644 apps/diffshub/components/useGitHubUser.ts create mode 100644 apps/diffshub/lib/githubCommentsClient.ts diff --git a/apps/diffshub/app/api/github-comments/route.ts b/apps/diffshub/app/api/github-comments/route.ts index e66424fa0..8e7534d98 100644 --- a/apps/diffshub/app/api/github-comments/route.ts +++ b/apps/diffshub/app/api/github-comments/route.ts @@ -1,10 +1,20 @@ import { type NextRequest } from 'next/server'; -import { loadGitHubComments } from '@/lib/githubCommentsServer'; +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, @@ -32,11 +42,67 @@ export async function GET(request: NextRequest) { 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: error instanceof Error ? error.message : 'Unknown error' }, - { status: 502 } + { 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 { 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/DiffsHubCommentsList.tsx b/apps/diffshub/components/DiffsHubCommentsList.tsx index d80ab0a6c..f013b531b 100644 --- a/apps/diffshub/components/DiffsHubCommentsList.tsx +++ b/apps/diffshub/components/DiffsHubCommentsList.tsx @@ -14,6 +14,9 @@ import type { } from '@/lib/types'; interface DiffsHubCommentsListProps { + // Whether saved drafts post to the pull request on GitHub; only changes + // the empty-state copy. + canPostToGitHub?: boolean; commentSections: readonly DiffsHubSavedCommentItem[]; onSelectComment?(comment: DiffsHubSavedCommentEntry): void; onSelectItem?(itemId: string): void; @@ -81,6 +84,7 @@ function handleRowClick( } export const DiffsHubCommentsList = memo(function DiffsHubCommentsList({ + canPostToGitHub, commentSections, onSelectComment, onSelectItem, @@ -112,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.'}

diff --git a/apps/diffshub/components/DiffsHubHeader.tsx b/apps/diffshub/components/DiffsHubHeader.tsx index 494c80ef4..bc65a838d 100644 --- a/apps/diffshub/components/DiffsHubHeader.tsx +++ b/apps/diffshub/components/DiffsHubHeader.tsx @@ -64,6 +64,7 @@ interface HeaderProps { diffStyle: 'split' | 'unified'; fileTreeAvailable: boolean; fileTreeOverlayOpen: boolean; + githubRepoOwner?: string; githubTokenActive: boolean; githubTokenCapability: GitHubTokenCapability; initialUrl: string; @@ -94,6 +95,7 @@ export const DiffsHubHeader = memo(function DiffsHubHeader({ diffStyle, fileTreeAvailable, fileTreeOverlayOpen, + githubRepoOwner, githubTokenActive, githubTokenCapability, initialUrl, @@ -256,6 +258,7 @@ export const DiffsHubHeader = memo(function DiffsHubHeader({ capability={githubTokenCapability} onClear={onClearGitHubToken} onSave={onSaveGitHubToken} + resourceOwner={githubRepoOwner} />
; + postReply?(request: DiffsHubPostReplyRequest): Promise; overflow: 'wrap' | 'scroll'; showBackgrounds: boolean; diffIndicators: DiffIndicators; @@ -85,8 +95,12 @@ interface DiffsHubViewerProps { export const DiffsHubViewer = memo(function DiffsHubViewer({ className, diffStyle, + draftAuthor, + draftHint, onCommentDeleted, onCommentSaved, + postComment, + postReply, overflow, showBackgrounds, diffIndicators, @@ -271,6 +285,95 @@ export const DiffsHubViewer = memo(function DiffsHubViewer({ return; } + if (postComment != null) { + const { range } = draftAnnotation.metadata; + const setDraftPending = (pending: boolean) => { + updateViewerDiffItem(viewer, itemId, (item) => { + if (item.annotations == null) { + return false; + } + item.annotations = item.annotations.map((annotation) => + annotation.metadata.key === key && isDraftAnnotation(annotation) + ? { + ...annotation, + metadata: { + ...annotation.metadata, + message: trimmedMessage, + pending, + }, + } + : annotation + ); + return true; + }); + }; + + setDraftPending(true); + postComment({ + itemId, + key, + lineNumber: draftAnnotation.lineNumber, + message: trimmedMessage, + range, + side: draftAnnotation.side, + }) + .then((wire) => { + const githubKey = `gh-${wire.id}`; + const updatedItem = updateViewerDiffItem(viewer, itemId, (item) => { + if (item.annotations == null) { + return false; + } + item.annotations = item.annotations.map((annotation) => + annotation.metadata.key === key + ? { + side: draftAnnotation.side, + lineNumber: draftAnnotation.lineNumber, + metadata: { + kind: 'github', + key: githubKey, + range, + thread: { root: wire, replies: [] }, + }, + } + : annotation + ); + return true; + }); + if (updatedItem == null) { + return; + } + const { current: activeDraft } = activeDraftRef; + if (activeDraft?.itemId === itemId && activeDraft.key === key) { + activeDraftRef.current = null; + } + setSelectedLines(null); + onLineLinkChange(null); + onCommentSaved({ + author: wire.user.login, + avatarUrl: wire.user.avatarUrl, + itemId, + key: githubKey, + lineNumber: draftAnnotation.lineNumber, + lineType: classifyCommentLineType( + updatedItem.fileDiff, + draftAnnotation.side, + draftAnnotation.lineNumber + ), + message: wire.body, + range, + replyCount: 0, + side: draftAnnotation.side, + thread: { root: wire, replies: [] }, + }); + }) + .catch(() => { + // The caller already surfaced the error (toast); keep the draft + // with its text so the user can retry or cancel. + setDraftPending(false); + }); + return; + } + const updatedItem = updateViewerDiffItem(viewer, itemId, (item) => { if (item.annotations == null) { return false; @@ -383,6 +486,8 @@ export const DiffsHubViewer = memo(function DiffsHubViewer({ return ( + postReply({ body, itemId: item.id, key, rootCommentId }) + } onToggleSelection={handleToggleCommentSelection} /> ); diff --git a/apps/diffshub/components/DraftAnnotation.tsx b/apps/diffshub/components/DraftAnnotation.tsx index 114d53a1b..2b08e455a 100644 --- a/apps/diffshub/components/DraftAnnotation.tsx +++ b/apps/diffshub/components/DraftAnnotation.tsx @@ -1,5 +1,4 @@ import type { DiffLineAnnotation } from '@pierre/diffs'; -import { IconArrowRight } from '@pierre/icons'; import { useEffect, useRef, useState } from 'react'; import { CommentAuthorAvatar } from './CommentAuthorAvatar'; @@ -10,10 +9,13 @@ import { getRandomPersona, } from '@/lib/annotation'; import { cn } from '@/lib/cn'; +import type { GitHubCommentUser } from '@/lib/githubComments'; import type { DraftCommentMetadata } from '@/lib/types'; interface DraftAnnotationProps { annotation: DiffLineAnnotation; + githubAuthor?: GitHubCommentUser; + hint?: string; itemId: string; onCancel(itemId: string, key: string): void; onSave( @@ -26,6 +28,8 @@ interface DraftAnnotationProps { export function DraftAnnotation({ annotation, + githubAuthor, + hint, itemId, onCancel, onSave, @@ -34,15 +38,19 @@ export function DraftAnnotation({ const [persona] = useState(getRandomPersona); const textareaRef = useRef(null); const trimmedMessage = message.trim(); + const pending = annotation.metadata.pending === true; function handleSave() { - if (trimmedMessage.length === 0) { + if (trimmedMessage.length === 0 || pending) { return; } onSave(itemId, annotation.metadata.key, trimmedMessage, persona.name); } function tryCancel() { + if (pending) { + return; + } if (trimmedMessage.length > 0 && !window.confirm('Discard this comment?')) { return; } @@ -62,17 +70,21 @@ export function DraftAnnotation({ return ( { event.preventDefault(); handleSave(); }} >
- +