fix pr and issue comments, make pr history like upstreams, make reviewed checkmarks usable
3595 unmodified linesExpand all3596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223595 unmodified linesExpand all .untangled-topbar-menu-item-separator { border-color: rgb(55 65 81); }}
/* Override btn-flat styles to ensure correct transparency and hover background colors */.btn-flat { background-color: transparent !important; border: 1px solid transparent !important; border-radius: 0.25rem !important; transition: background-color 150ms ease-in-out, color 150ms ease-in-out !important;}
.btn-flat:hover { background-color: rgb(229 231 235 / 0.5) !important; /* bg-gray-200/50 */}
@media (prefers-color-scheme: dark) { .btn-flat { background-color: transparent !important; border-color: transparent !important; } .btn-flat:hover { background-color: rgb(55 65 81 / 0.5) !important; /* bg-gray-700/50 */ }}
3 unmodified lines45678910111214 unmodified lines272829303031323334351398 unmodified linesExpand all143414351436143714371438143914401441144214391443144414451446144714481443144914501451145214531454145514563 unmodified lines1460146114621463145514641465146614671468146914701471146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114927 unmodified lines15001501150215031487150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160913 unmodified lines1623162416251626151616271628162916301631163216331634163516361637163816391640164116421643164425 unmodified lines16701671167216731550167416751676167716781679168015571681168216831684168516861687168816891690169116921693169415631695156515661567169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719272 unmodified linesExpand all3 unmodified linesimport { Check, ChevronRight, Circle, CircleCheck, Columns2, Copy, Download, ExternalLink,14 unmodified lines GitPullRequest,} from 'lucide-solid';import { marked } from 'marked';import { A } from '@solidjs/router';import { For, Show, createMemo, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js';import { For, Show, createEffect, createMemo, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js';import type { RepoContext, TreeEntry } from '../lib/api/repos';import type { Did } from '@atcute/lexicons/syntax';import { createQuery } from '@tanstack/solid-query';import { getRepo, getRepoLanguages, getRepoForkCount, getRepoByDid } from '../lib/api/repos';1398 unmodified linesExpand all window.history.pushState(null, '', `${window.location.pathname}${window.location.search}${hash}`); }};
const DiffFileTreeNodes: Component<{ nodes: DiffFileTreeNode[] }> = (props) => (const DiffFileTreeNodes: Component<{ nodes: DiffFileTreeNode[]; reviewedFiles?: () => Set<string>;}> = (props) => ( <div class="untangled-pr-file-tree-list"> <For each={props.nodes}>{(node) => <DiffFileTreeNodeView node={node} />}</For> <For each={props.nodes}> {(node) => <DiffFileTreeNodeView node={node} reviewedFiles={props.reviewedFiles} />} </For> </div>);
const DiffFileTreeNodeView: Component<{ node: DiffFileTreeNode }> = (props) => (const DiffFileTreeNodeView: Component<{ node: DiffFileTreeNode; reviewedFiles?: () => Set<string>;}> = (props) => ( <div class="untangled-pr-file-tree-node"> <Show when={props.node.file} fallback={3 unmodified lines <span class="truncate">{props.node.name}</span> </div> <Show when={props.node.children?.length}> <div class="untangled-pr-file-tree-children"> <DiffFileTreeNodes nodes={props.node.children!} /> <DiffFileTreeNodes nodes={props.node.children!} reviewedFiles={props.reviewedFiles} /> </div> </Show> </> } > {(file) => { const isReviewed = () => props.reviewedFiles?.().has(file().path) ?? false; {(file) => ( <a href={`#${diffFileElementId(file().path)}`} class="untangled-pr-file-tree-entry untangled-pr-file-tree-link" title={file().path} onClick={(event) => scrollToDiffFile(file().path, event)} > <FileText class="size-4 shrink-0" /> <span class="truncate">{props.node.name}</span> </a> )} return ( <a href={`#${diffFileElementId(file().path)}`} class="untangled-pr-file-tree-entry untangled-pr-file-tree-link" title={file().path} onClick={(event) => scrollToDiffFile(file().path, event)} > <FileText class="size-4 shrink-0" /> <span class="truncate">{props.node.name}</span> <Show when={isReviewed()}> <span class="review-indicator text-green-600 dark:text-green-400 flex-shrink-0 ml-1"> ✓ </span> </Show> </a> ); }} </Show> </div>);
7 unmodified lines </Show> </div>);
export const PullDiffView: Component<{ patch: string; roundLabel: string; history: JSX.Element }> = (props) => {export const PullDiffView: Component<{ patch: string; roundLabel: string; storageKey: string; history: JSX.Element;}> = (props) => { const files = createMemo(() => parseDiffFiles(props.patch)); const stats = createMemo(() => totalDiffStats(files())); const tree = createMemo(() => diffFileTree(files())); const [expanded, setExpanded] = createSignal(true); const [filesVisible, setFilesVisible] = createSignal(true); const [historyVisible, setHistoryVisible] = createSignal(true);
const loadReviewed = (): Set<string> => { try { const data = localStorage.getItem(props.storageKey); if (!data) return new Set(); const entry = JSON.parse(data); return new Set(Array.isArray(entry) ? entry : (entry.files || [])); } catch { return new Set(); } };
const [reviewed, setReviewed] = createSignal<Set<string>>(new Set());
createEffect(() => { setReviewed(loadReviewed()); });
const saveReviewed = (newSet: Set<string>) => { const currentFilePaths = new Set(files().map((f) => f.path)); const filesToSave = Array.from(newSet).filter((path) => currentFilePaths.has(path)); try { localStorage.setItem( props.storageKey, JSON.stringify({ files: filesToSave, ts: Date.now(), }), ); } catch (e) { console.error('Failed to save reviewed files:', e); } };
const pruneStale = () => { const now = Date.now(); const REVIEWED_PREFIX = 'reviewed:'; const MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; for (let i = 0; i < localStorage.length; i++) { const key = localStorage.key(i); if (key && key.startsWith(REVIEWED_PREFIX) && key !== props.storageKey) { try { const entry = JSON.parse(localStorage.getItem(key) || '{}'); if (!entry.ts || now - entry.ts > MAX_AGE_MS) { localStorage.removeItem(key); } } catch { localStorage.removeItem(key); } } } };
createEffect(() => { if (Math.random() < 0.1) { pruneStale(); } });
const [localOpen, setLocalOpen] = createSignal<Record<string, boolean>>({}); const [hasSetInitial, setHasSetInitial] = createSignal(false);
createEffect(() => { if (files().length > 0) { const isExpanded = expanded(); const next: Record<string, boolean> = {}; for (const f of files()) { if (!hasSetInitial() && reviewed().has(f.path)) { next[f.path] = false; } else { next[f.path] = isExpanded; } } setLocalOpen(next); setHasSetInitial(true); } });
const toggleReview = (path: string) => { const nextReviewed = new Set(reviewed()); if (nextReviewed.has(path)) { nextReviewed.delete(path); } else { nextReviewed.add(path); setLocalOpen((prev) => ({ ...prev, [path]: false })); } setReviewed(nextReviewed); saveReviewed(nextReviewed); };
return ( <section class="untangled-pr-diff-breakout"> <div class={clsx(13 unmodified lines <Show when={filesVisible()} fallback={<PanelLeftOpen class="size-4" />}> <PanelLeftClose class="size-4" /> </Show> </button> <DiffToolbarStats additions={stats().additions} deletions={stats().deletions} fileCount={files().length} class="text-xs text-gray-600 dark:text-gray-400" /> <DiffStatPill additions={stats().additions} deletions={stats().deletions} /> <span class={clsx( 'text-xs font-medium transition-colors duration-150', reviewed().size === files().length && files().length > 0 ? 'text-green-600 dark:text-green-400' : 'text-gray-600 dark:text-gray-400' )} > {reviewed().size > 0 ? `${reviewed().size}/${files().length} file${files().length === 1 ? '' : 's'} reviewed` : `${files().length} changed file${files().length === 1 ? '' : 's'}` } </span> <span class="hidden h-5 w-px bg-gray-300 dark:bg-gray-700 sm:inline-block" /> <span class="uppercase tracking-wide">diff</span> <span class="rounded border border-gray-300 px-2 py-0.5 font-mono text-xs text-gray-700 dark:border-gray-600 dark:text-gray-200"> {props.roundLabel}25 unmodified lines )} > <Show when={filesVisible()}> <aside class="untangled-pr-file-tree" aria-label="changed files"> <DiffFileTreeNodes nodes={tree()} /> <DiffFileTreeNodes nodes={tree()} reviewedFiles={reviewed} /> </aside> </Show>
<div class="untangled-pr-diff-files"> <For each={files()}> {(file) => ( <details id={diffFileElementId(file.path)} class="untangled-pr-diff-file" open={expanded()}> <details id={diffFileElementId(file.path)} class={clsx('untangled-pr-diff-file', reviewed().has(file.path) && 'opacity-60')} open={Boolean(localOpen()[file.path])} onToggle={(e) => { const target = e.currentTarget; setLocalOpen((prev) => ({ ...prev, [file.path]: target.open })); }} > <summary class="untangled-pr-diff-file-header"> <div class="flex min-w-0 items-center gap-2"> <ChevronRight class="untangled-pr-diff-chevron size-4 shrink-0" /> <DiffStatPill additions={file.additions} deletions={file.deletions} /> <span class="truncate text-base text-gray-100">{file.path}</span> <Circle class="size-4 shrink-0 text-gray-500" /> </div> <label class="flex shrink-0 items-center gap-1 text-xs text-gray-400"> <Circle class="size-4" /> reviewed <label class="flex shrink-0 items-center gap-1 text-xs text-gray-400 dark:text-gray-500 hover:text-green-600 dark:hover:text-green-400 transition-colors cursor-pointer" title={reviewed().has(file.path) ? 'Mark as unreviewed' : 'Mark as reviewed'} onClick={(e) => { e.stopPropagation(); }} > <input type="checkbox" class="sr-only" checked={reviewed().has(file.path)} onChange={() => toggleReview(file.path)} /> <Show when={reviewed().has(file.path)} fallback={<Circle class="size-4" />} > <CircleCheck class="size-4 text-green-600 dark:text-green-400" /> </Show> <span class="hidden md:inline">reviewed</span> </label> </summary> <div class="untangled-pr-diff-ellipsis">...</div> <CodeView text={file.text} diff path={file.path} />272 unmodified linesExpand all18 unmodified lines19202122232418 unmodified linesexport const ISSUE_STATE_COLLECTION: Nsid = 'sh.tangled.repo.issue.state';export const PULL_COLLECTION: Nsid = 'sh.tangled.repo.pull';export const PULL_COMMENT_COLLECTION: Nsid = 'sh.tangled.repo.pull.comment';export const PULL_STATUS_COLLECTION: Nsid = 'sh.tangled.repo.pull.status';export const FEED_COMMENT_COLLECTION: Nsid = 'sh.tangled.feed.comment';
24 unmodified lines252627282930313233124 unmodified linesExpand all1581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941681691951961971981992002012022032042052062072082092102112122132142151 unmodified line217218219220179180221222223224183225226227228229230231232135 unmodified linesExpand all36836937037137237332837437537633133233333437737837938038138238338438538638733633733838838939039139239339439539639739839934540040140240340437 unmodified lines24 unmodified linesimport { ISSUE_COLLECTION, ISSUE_COMMENT_COLLECTION, ISSUE_STATE_COLLECTION, FEED_COMMENT_COLLECTION,} from './constants';import { appviewRecordToHydrated, fetchRecordValue,124 unmodified linesExpand all 'sh.tangled.repo.issue:repoDid', 'sh.tangled.repo.issue:repo', ])).length;
const normalizeIssueComment = (hydrated: HydratedRecord<any>, issueUri: ResourceUri): IssueComment => { const val = hydrated.value; if (!val || typeof val !== 'object') { return hydrated as IssueComment; } const bodyText = (val.body && typeof val.body === 'object' && 'text' in val.body) ? val.body.text : (typeof val.body === 'string' ? val.body : ''); const replyTo = val.replyTo?.uri ?? val.replyTo;
const mappedValue: ShTangledRepoIssueComment.Main = { $type: 'sh.tangled.repo.issue.comment', body: bodyText, createdAt: val.createdAt || new Date().toISOString(), issue: val.issue ?? val.subject?.uri ?? issueUri, replyTo, references: val.references ?? (replyTo ? [replyTo] : []), };
return { ...hydrated, value: mappedValue, };};
const getIssueFromBacklinks = async (repo: RepoContext, issueRef: string): Promise<IssueDetail> => { const issues = await listIssuesFromBacklinks(repo); const issue = findNumberedRecord(issues, issueRef); if (!issue) { throw new Error(`Issue not found`); }
const commentRefs = await getBacklinks(issue.uri, 'sh.tangled.repo.issue.comment:issue'); const comments = await hydrateBacklinks<ShTangledRepoIssueComment.Main>(commentRefs); const [newCommentRefs, legacyCommentRefs] = await Promise.all([ getBacklinks(issue.uri, 'sh.tangled.feed.comment:subject'), getBacklinks(issue.uri, 'sh.tangled.repo.issue.comment:issue'), ]); const commentRefs = [...newCommentRefs]; const seenUris = new Set(commentRefs.map(r => `at://${r.did}/${r.collection}/${r.rkey}`)); for (const ref of legacyCommentRefs) { const refUri = `at://${ref.did}/${ref.collection}/${ref.rkey}`; if (!seenUris.has(refUri)) { commentRefs.push(ref); seenUris.add(refUri); } }
const hydrated = await hydrateBacklinks<any>(commentRefs); const comments = hydrated.map((record) => normalizeIssueComment(record, issue.uri)); comments.sort(sortByCreatedAt);
return { issue,1 unmodified line };};
const listIssueCommentsFromAppview = async (issueUri: ResourceUri): Promise<IssueComment[]> => { const records = await listAllAppviewRecords<ShTangledRepoIssueComment.Main>( 'sh.tangled.repo.issue.listComments', const records = await listAllAppviewRecords<any>( 'sh.tangled.feed.listComments', issueUri, ); const comments = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); const comments = await Promise.all(records.map(async (record) => { const hydrated = await appviewRecordToHydrated<any>(record); return normalizeIssueComment(hydrated, issueUri); })); comments.sort(sortByCreatedAt); return comments;};
135 unmodified linesExpand all
export const createIssueComment = async ( agent: OAuthUserAgent, issueUri: ResourceUri, issueCid: string, body: string, replyTo?: ResourceUri, replyTo?: { uri: ResourceUri; cid: string },): Promise<RecordCreationResult> => { const rpc = createAuthRpc(agent); const record: ShTangledRepoIssueComment.Main = { $type: 'sh.tangled.repo.issue.comment', issue: issueUri, body: body.trim(), const record = { $type: 'sh.tangled.feed.comment', subject: { uri: issueUri, cid: issueCid, }, body: { $type: 'sh.tangled.markup.markdown', text: body.trim(), }, createdAt: new Date().toISOString(), mentions: extractDids(body), references: extractAtUris(body), ...(replyTo ? { replyTo } : {}), ...(replyTo ? { replyTo: { uri: replyTo.uri, cid: replyTo.cid, } } : {}), };
const result = await ok( rpc.post('com.atproto.repo.createRecord', { input: { repo: agent.sub, collection: ISSUE_COMMENT_COLLECTION, collection: FEED_COMMENT_COLLECTION, record, }, }), );37 unmodified lines26 unmodified lines272829303132333435147 unmodified linesExpand all183184185186186187187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242197198243244245246201247248249250251252253254188 unmodified linesExpand all44344444544644744844945045140140240340445245345445545645745845946046146240640746346446546646746846941447047147247347466 unmodified lines26 unmodified linesimport { PULL_COLLECTION, PULL_COMMENT_COLLECTION, PULL_STATUS_COLLECTION, FEED_COMMENT_COLLECTION,} from './constants';import { createServiceAuthRpc, getRpc } from './client';import { appviewRecordToHydrated,147 unmodified linesExpand all if (!pull) { throw new Error(`Pull request not found`); }
const commentRefs = await getBacklinks(pull.uri, 'sh.tangled.repo.pull.comment:pull'); const comments = await hydrateBacklinks<ShTangledRepoPullComment.Main>(commentRefs); const [newCommentRefs, legacyCommentRefs] = await Promise.all([ getBacklinks(pull.uri, 'sh.tangled.feed.comment:subject'), getBacklinks(pull.uri, 'sh.tangled.repo.pull.comment:pull'), ]); const commentRefs = [...newCommentRefs]; const seenUris = new Set(commentRefs.map(r => `at://${r.did}/${r.collection}/${r.rkey}`)); for (const ref of legacyCommentRefs) { const refUri = `at://${ref.did}/${ref.collection}/${ref.rkey}`; if (!seenUris.has(refUri)) { commentRefs.push(ref); seenUris.add(refUri); } }
const hydrated = await hydrateBacklinks<any>(commentRefs); const comments = hydrated.map((record) => normalizePullComment(record, pull.uri)); comments.sort(sortByCreatedAt);
return { pull, comments, };};
const normalizePullComment = (hydrated: HydratedRecord<any>, pullUri: ResourceUri): PullComment => { const val = hydrated.value; if (!val || typeof val !== 'object') { return hydrated as PullComment; } const bodyText = (val.body && typeof val.body === 'object' && 'text' in val.body) ? val.body.text : (typeof val.body === 'string' ? val.body : ''); const pullRoundIdx = val.pullRoundIdx !== undefined ? val.pullRoundIdx : val.round; const replyTo = val.replyTo?.uri ?? val.replyTo;
const mappedValue: ShTangledRepoPullComment.Main = { $type: 'sh.tangled.repo.pull.comment', body: bodyText, createdAt: val.createdAt || new Date().toISOString(), pull: val.pull ?? val.subject?.uri ?? pullUri, references: val.references ?? (replyTo ? [replyTo] : []), };
return { ...hydrated, value: { ...mappedValue, replyTo, pullRoundIdx, } as any, };};
const listPullCommentsFromAppview = async (pullUri: ResourceUri): Promise<PullComment[]> => { const records = await listAllAppviewRecords<ShTangledRepoPullComment.Main>( 'sh.tangled.repo.pull.listComments', const records = await listAllAppviewRecords<any>( 'sh.tangled.feed.listComments', pullUri, ); const comments = await Promise.all(records.map((record) => appviewRecordToHydrated(record))); const comments = await Promise.all(records.map(async (record) => { const hydrated = await appviewRecordToHydrated<any>(record); return normalizePullComment(hydrated, pullUri); })); comments.sort(sortByCreatedAt); return comments;};
188 unmodified linesExpand all
export const createPullComment = async ( agent: OAuthUserAgent, pullUri: ResourceUri, pullCid: string, body: string, pullRoundIdx: number,): Promise<RecordCreationResult> => { const rpc = createAuthRpc(agent); const record: ShTangledRepoPullComment.Main = { $type: 'sh.tangled.repo.pull.comment', pull: pullUri, body: body.trim(), const record = { $type: 'sh.tangled.feed.comment', subject: { uri: pullUri, cid: pullCid, }, body: { $type: 'sh.tangled.markup.markdown', text: body.trim(), }, createdAt: new Date().toISOString(), mentions: extractDids(body), references: extractAtUris(body), pullRoundIdx, };
const result = await ok( rpc.post('com.atproto.repo.createRecord', { input: { repo: agent.sub, collection: PULL_COMMENT_COLLECTION, collection: FEED_COMMENT_COLLECTION, record, }, }), );66 unmodified lines2 unmodified lines345677891011291 unmodified linesExpand all30330430530630730730830931031159 unmodified lines37137237337437537537637737837922 unmodified lines4024034044054064064074084094101 unmodified line4124134144154164164174184194203 unmodified lines42442542642742842842943043143243343443543643743894 unmodified lines53353453553653153753853954053554154254354454513 unmodified lines5595605615625575635645655665675685635695705715725733 unmodified lines577578579580575581582583584585116 unmodified linesExpand all2 unmodified linesimport { A, useNavigate, useParams, useSearchParams } from '@solidjs/router';import { createQuery, useQueryClient } from '@tanstack/solid-query';import type { ResourceUri } from '@atcute/lexicons/syntax';import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js';import { createIssue, createIssueComment, getIssue, listIssuesPage, setIssueState } from '../../lib/api/issues';import { createIssue, createIssueComment, getIssue, listIssuesPage, setIssueState, type IssueComment } from '../../lib/api/issues';import { resolveActor } from '../../lib/api/identity';import { parseAtUri } from '../../lib/api/records';import type { RepoContext } from '../../lib/api/repos';import { useAuth } from '../../lib/auth';291 unmodified linesExpand all const params = useParams(); const client = useQueryClient(); const issueRef = createMemo(() => params.issueRef ?? ''); const [comment, setComment] = createSignal(''); const [replyingTo, setReplyingTo] = createSignal<ResourceUri | null>(null); const [replyingTo, setReplyingTo] = createSignal<IssueComment | null>(null); const [replyBody, setReplyBody] = createSignal(''); const [workingAction, setWorkingAction] = createSignal<'comment' | 'reply' | 'state' | null>(null); const working = createMemo(() => workingAction() !== null); const [error, setError] = createSignal<string | null>(null);59 unmodified lines setWorkingAction('state'); setError(null); try { if (commentBody) { await createIssueComment(agent, detail.issue.uri, commentBody); await createIssueComment(agent, detail.issue.uri, detail.issue.cid ?? '', commentBody); setComment(''); } await setIssueState(agent, detail.issue.uri, nextState); if (commentBody) {22 unmodified lines
setWorkingAction('comment'); setError(null); try { await createIssueComment(agent, detail.issue.uri, comment()); await createIssueComment(agent, detail.issue.uri, detail.issue.cid ?? '', comment()); setComment(''); await withIssueInvalidation(repo, issueRef(), detail.issue.uri); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Failed to create comment');1 unmodified line setWorkingAction(null); } };
const submitReply = async (event: SubmitEvent, replyTo: ResourceUri) => { const submitReply = async (event: SubmitEvent, replyToComment: IssueComment) => { event.preventDefault(); const agent = auth.agent(); const repo = repoQuery.data; const detail = issueQuery.data;3 unmodified lines
setWorkingAction('reply'); setError(null); try { await createIssueComment(agent, detail.issue.uri, replyBody(), replyTo); await createIssueComment( agent, detail.issue.uri, detail.issue.cid ?? '', replyBody(), { uri: replyToComment.uri, cid: replyToComment.cid ?? '' } ); setReplyBody(''); setReplyingTo(null); await withIssueInvalidation(repo, issueRef(), detail.issue.uri); } catch (cause) {94 unmodified lines </div> </div>
<Show when={thread.replies.length > 0}> <div class="relative ml-10 border-l-2 border-gray-200 dark:border-gray-700"> <div class="relative ml-10 border-l border-gray-200 dark:border-gray-700"> <For each={thread.replies}> {(reply) => ( <div class="-ml-4 flex gap-2 py-4 pr-4"> <div class="shrink-0"> <div class="shrink-0 relative z-10"> <Avatar did={reply.author.did} size="size-8 mr-1" /> </div> <div class="min-w-0 flex-1"> <div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400">13 unmodified lines </Show>
<Show when={auth.agent()}> <Show when={replyingTo() === thread.item.uri} when={replyingTo()?.uri === thread.item.uri} fallback={ <button type="button" class="flex w-full items-center gap-2 border-t border-gray-300 px-6 py-2 text-left text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400" onClick={() => { setReplyingTo(thread.item.uri); setReplyingTo(thread.item); setReplyBody(''); }} > <Show when={currentDid()}>3 unmodified lines </button> } > <form onSubmit={(event) => void submitReply(event, thread.item.uri)} onSubmit={(event) => void submitReply(event, thread.item)} class="flex w-full flex-col gap-2 border-t border-gray-200 p-2 dark:border-gray-700" > <Show when={currentDid()}> {(did) => (116 unmodified linesExpand all73 unmodified lines747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813973 unmodified lines const body = separator === -1 ? '' : afterSubject.slice(0, separator).trim();
return { title: subject, body };};
import type { PullComment } from '../../lib/api/pulls';
export type PRCommentThread = { item: PullComment; replies: PullComment[];};
export const getRoundIndexForComment = (commentCreatedAt: string, rounds: Array<{ createdAt: string }>): number => { if (rounds.length <= 1) return 0; const commentTime = new Date(commentCreatedAt).getTime();
for (let i = 1; i < rounds.length; i++) { const nextRoundTime = new Date(rounds[i].createdAt).getTime(); if (commentTime < nextRoundTime) { return i - 1; } } return rounds.length - 1;};
export const buildPRCommentThreads = (comments: PullComment[]): PRCommentThread[] => { const byUri = new Map(comments.map((comment) => [comment.uri, comment])); const topLevel: PullComment[] = []; const repliesByRoot = new Map<string, PullComment[]>();
const findRoot = (comment: PullComment): PullComment | null => { let current = comment; const visited = new Set<string>([comment.uri]);
while ((current.value as any).replyTo) { const replyToRef = (current.value as any).replyTo; const parentUri = typeof replyToRef === 'string' ? replyToRef : (replyToRef.uri || replyToRef.URI || replyToRef.Uri); if (!parentUri) break;
const parent = byUri.get(parentUri); if (!parent || visited.has(parent.uri)) { return current === comment ? null : current; }
current = parent; visited.add(current.uri); }
return current; };
for (const comment of comments) { const root = findRoot(comment); if (!root || root.uri === comment.uri) { topLevel.push(comment); continue; }
repliesByRoot.set(root.uri, [...(repliesByRoot.get(root.uri) ?? []), comment]); }
return topLevel.map((item) => ({ item, replies: repliesByRoot.get(item.uri) ?? [], }));};