pr merging
1038 unmodified linesExpand all10391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051011 unmodified linesExpand all21172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214973 unmodified lines1038 unmodified linesExpand all.untangled-button { min-height: 30px;}
.untangled-clone-dropdown { position: relative; display: inline-flex;}
.untangled-clone-popover { position: absolute; top: calc(100% + 0.5rem); right: 0; z-index: 70; width: min(calc(100vw - 2rem), 27.5rem); overflow: visible; border: 1px solid rgb(229 231 235); border-radius: 0.25rem; background: rgb(255 255 255); padding: 1rem; color: rgb(17 24 39); box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1);}
.untangled-clone-url-row { display: flex; align-items: stretch; overflow: hidden; border: 1px solid rgb(209 213 219); border-radius: 0.25rem;}
.untangled-clone-url-text { min-width: 0; flex: 1; overflow-x: auto; background: rgb(229 231 235); padding: 0.5rem 0.75rem; color: rgb(17 24 39); font-family: IBMPlexMono, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, Liberation Mono, Courier New, monospace; font-size: 0.875rem; white-space: nowrap; cursor: pointer; user-select: all;}
.untangled-clone-copy { display: inline-flex; align-items: center; justify-content: center; border: 0; border-left: 1px solid rgb(209 213 219); background: rgb(255 255 255); padding: 0 0.75rem; color: rgb(107 114 128);}
.untangled-clone-copy:hover { color: rgb(55 65 81);}
.untangled-input { min-height: 38px;}
1011 unmodified linesExpand all .untangled-button:not(.btn-create):hover { background: rgb(55 65 81 / 0.4); }
.untangled-clone-popover { border-color: rgb(55 65 81); background: rgb(31 41 55); color: rgb(243 244 246); }
.untangled-clone-url-row { border-color: rgb(75 85 99); }
.untangled-clone-url-text { background: rgb(55 65 81); color: rgb(243 244 246); }
.untangled-clone-copy { border-left-color: rgb(75 85 99); background: rgb(31 41 55); color: rgb(156 163 175); }
.untangled-clone-copy:hover { color: rgb(229 231 235); }
.untangled-blob-card { background: rgb(31 41 55); }
73 unmodified lines123456789101112131415161718192021222324212225262728242930313233471 unmodified linesExpand all505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672714 unmodified linesExpand allimport clsx from 'clsx';import DOMPurify from 'dompurify';import hljs from 'highlight.js/lib/common';import { Check, ChevronRight, Circle, Columns2, Copy, Download, ExternalLink, FileText, FoldVertical, Folder, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Square, SquareCheckBig, UnfoldVertical,} from 'lucide-solid';import { marked } from 'marked';import { A } from '@solidjs/router';import { For, Show, createMemo, createSignal, type Component, type JSX } from 'solid-js';import type { TreeEntry } from '../lib/api/repos';import { For, Show, createMemo, createSignal, onCleanup, onMount, type Component, type JSX } from 'solid-js';import type { RepoContext, TreeEntry } from '../lib/api/repos';import { formatRelativeTime } from '../lib/repo-utils';import { getTangledAppviewService } from '../lib/settings';import { Avatar, SkeletonBlock, cardStyles } from './common';import { Avatar, SkeletonBlock, buttonStyles, cardStyles } from './common';
marked.setOptions({ gfm: true, breaks: false,471 unmodified linesExpand all </section> </div>);
const cloneSshHost = (repo: RepoContext): string => { try { const host = new URL(repo.knot).hostname; return host === 'knot1.tangled.sh' ? 'tangled.org' : host; } catch { const host = repo.knot.replace(/^https?:\/\//, '').split('/')[0].replace(/:\d+$/, ''); return host === 'knot1.tangled.sh' ? 'tangled.org' : host; }};
const appviewArchiveUrl = (repo: RepoContext, refName: string): string => { const owner = encodeURIComponent(repo.owner.handle); const slug = encodeURIComponent(repo.slug); const ref = encodeURIComponent(refName); return new URL(`/${owner}/${slug}/archive/${ref}`, getTangledAppviewService()).toString();};
const selectContents = (element: HTMLElement) => { const selection = window.getSelection(); if (!selection) return;
const range = document.createRange(); range.selectNodeContents(element); selection.removeAllRanges(); selection.addRange(range);};
const CloneUrlItem: Component<{ label: string; handleUrl: string; permaUrl: string; permalink: boolean;}> = (props) => { const [copied, setCopied] = createSignal(false); const visibleUrl = createMemo(() => (props.permalink ? props.permaUrl : props.handleUrl)); let copyTimer: ReturnType<typeof setTimeout> | undefined;
onCleanup(() => clearTimeout(copyTimer));
const copy = () => { clearTimeout(copyTimer); void navigator.clipboard?.writeText(visibleUrl()); setCopied(true); copyTimer = setTimeout(() => setCopied(false), 2000); };
return ( <div class="mt-4"> <label class="mb-1 block text-xs font-medium text-gray-700 normal-case dark:text-gray-300"> {props.label} </label> <div class="untangled-clone-url-row"> <span class="untangled-clone-url-text" onClick={(event) => selectContents(event.currentTarget)} title={visibleUrl()} > {visibleUrl()} </span> <button type="button" class="untangled-clone-copy" title="Copy to clipboard" aria-label={`Copy ${props.label} clone URL`} onClick={copy} > <Show when={copied()} fallback={<Copy class="size-4" />}> <Check class="size-4" /> </Show> </button> </div> </div> );};
export const CloneDropdown: Component<{ repo: RepoContext; refName: string }> = (props) => { const [open, setOpen] = createSignal(false); const [permalink, setPermalink] = createSignal(false); const sshHost = createMemo(() => cloneSshHost(props.repo)); let root: HTMLDivElement | undefined;
onMount(() => { const closeOnOutsideClick = (event: MouseEvent) => { if (!open() || !root || root.contains(event.target as Node)) return; setOpen(false); }; const closeOnEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') setOpen(false); };
document.addEventListener('mousedown', closeOnOutsideClick); document.addEventListener('keydown', closeOnEscape); onCleanup(() => { document.removeEventListener('mousedown', closeOnOutsideClick); document.removeEventListener('keydown', closeOnEscape); }); });
return ( <div class="untangled-clone-dropdown" ref={root}> <button type="button" class={clsx(buttonStyles('primary'), 'cursor-pointer px-4')} aria-expanded={open()} aria-haspopup="dialog" onClick={() => setOpen(!open())} > <Download class="size-4" /> <span class="hidden md:inline">code</span> </button> <Show when={open()}> <div class="untangled-clone-popover" role="dialog" aria-label="Clone this repository"> <div class="flex items-center justify-between gap-3"> <h3 class="m-0 text-sm font-semibold text-gray-900 dark:text-white"> Clone this repository </h3> <label class="flex cursor-pointer items-center gap-1 text-xs font-normal normal-case text-gray-700 dark:text-gray-300"> <input type="checkbox" class="sr-only" checked={permalink()} onChange={(event) => setPermalink(event.currentTarget.checked)} /> <Show when={permalink()} fallback={<Square class="size-4" />}> <SquareCheckBig class="size-4" /> </Show> <span>Use permalink</span> </label> </div>
<CloneUrlItem label="HTTPS" handleUrl={`https://tangled.org/${props.repo.owner.handle}/${props.repo.slug}`} permaUrl={`https://tangled.org/${props.repo.repoDid}`} permalink={permalink()} /> <CloneUrlItem label="SSH" handleUrl={`git@${sshHost()}:${props.repo.owner.handle}/${props.repo.slug}`} permaUrl={`git@${sshHost()}:${props.repo.repoDid}`} permalink={permalink()} />
<p class="mt-2 text-xs text-gray-500 dark:text-gray-400"> For self-hosted knots, clone URLs may differ based on your setup. </p>
<a href={appviewArchiveUrl(props.repo, props.refName)} class={clsx(buttonStyles('primary'), 'mt-4 flex w-full justify-center text-sm hover:text-gray-100')} > <Download class="size-4" /> Download tar.gz </a> </div> </Show> </div> );};
export const CommentCard: Component<{ author: string; did: string; createdAt: string;714 unmodified linesExpand all56 unmodified lines57585960616263646539 unmodified lines1051061071081091101111121137 unmodified lines56 unmodified lines getPullRecord, getRepoPullCount, listPulls, listPullsPage, mergePull, setPullStatus,} from './api/pulls';export { searchRecords,39 unmodified lines IssueSummary,} from './api/issues';export type { CreatePullInput, MergePullInput, PullComment, PullDetail, PullSummary,} from './api/pulls';7 unmodified lines112345625 unmodified lines3233343536373839404142434445464748495051525354555657585960616263646566676869import { Client, simpleFetchHandler } from '@atcute/client';import { Client, ok, simpleFetchHandler } from '@atcute/client';import type { Nsid } from '@atcute/lexicons/syntax';import type { OAuthUserAgent } from '@atcute/oauth-browser-client';
const rpcCache = new Map<string, Client>();
25 unmodified linesexport const createAuthRpc = (agent: OAuthUserAgent): Client => new Client({ handler: agent, });
const serviceAudience = (service: string): `did:web:${string}` => { const url = new URL(normalizeServiceUrl(service)); return `did:web:${url.host}`;};
export const createServiceAuthRpc = async ( agent: OAuthUserAgent, service: string, lxm: Nsid,): Promise<Client> => { const authRpc = createAuthRpc(agent); const { token } = await ok( authRpc.get('com.atproto.server.getServiceAuth', { params: { aud: serviceAudience(service), exp: Math.floor(Date.now() / 1000) + 60, lxm, }, }), ); const serviceUrl = normalizeServiceUrl(service);
return new Client({ handler: async (pathname, init) => { const headers = new Headers(init.headers); headers.set('authorization', `Bearer ${token}`); return fetch(new URL(pathname, serviceUrl), { ...init, headers, }); }, });};12345678918 unmodified lines2829303131323334353647 unmodified lines84858687888990919293949596355 unmodified linesExpand all452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482import { ok } from '@atcute/client';import type { ResourceUri } from '@atcute/lexicons/syntax';import type { OAuthUserAgent } from '@atcute/oauth-browser-client';import { ShTangledRepoMerge, ShTangledRepoPull, ShTangledRepoPullComment, ShTangledRepoPullStatus,} from '@atcute/tangled';18 unmodified lines PULL_COLLECTION, PULL_COMMENT_COLLECTION, PULL_STATUS_COLLECTION,} from './constants';import { getRpc } from './client';import { createServiceAuthRpc, getRpc } from './client';import { appviewRecordToHydrated, fetchRecordValue, hydrateBacklinks,47 unmodified lines sourceBranch: string; patch: string;}
export interface MergePullInput { pull: PullSummary; patch: string;}
const normalizePullStatus = (value?: string): 'open' | 'closed' | 'merged' => { if (value === 'merged' || value?.endsWith('.merged')) { return 'merged'; }355 unmodified linesExpand all uri: result.uri, rkey: parseAtUri(result.uri).rkey, };};
export const mergePull = async ( agent: OAuthUserAgent, repo: RepoContext, input: MergePullInput,): Promise<RecordCreationResult> => { const rpc = await createServiceAuthRpc(agent, repo.knot, 'sh.tangled.repo.merge'); const pull = input.pull; const mergeInput: ShTangledRepoMerge.$input = { did: repo.owner.did, name: repo.slug, branch: pull.value.target.branch, patch: input.patch, commitMessage: pull.value.title, authorName: pull.author.handle, ...(pull.value.body ? { commitBody: pull.value.body } : {}), };
await ok( rpc.post('sh.tangled.repo.merge', { input: mergeInput, as: null, }), );
return setPullStatus(agent, pull.uri, 'merged');};12234567889101112622 unmodified linesExpand all635636637638639640641642639640641642643369 unmodified linesExpand allimport clsx from 'clsx';import { Download, File, Folder, GitBranch, GitCommitHorizontal, List } from 'lucide-solid';import { File, Folder, GitBranch, GitCommitHorizontal, List } from 'lucide-solid';import { A, useNavigate, useParams } from '@solidjs/router';import { createQuery, keepPreviousData, useQueryClient } from '@tanstack/solid-query';import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component } from 'solid-js';import { buildBlobDataUrl, decodeBlobText, getRepoBlob, getRepoBranches, getRepoDefaultBranch, getRepoLanguages, getRepoLog, getRepoTags, getRepoTree } from '../../lib/api/repos';import { Avatar, ErrorState, PlaceholderAvatar, buttonStyles, cardStyles } from '../../components/common';import { CodeView, FileRow, MarkdownBlock, OverviewFileRow, ReadmeCard, RepoTreeSkeleton } from '../../components/repo';import { CloneDropdown, CodeView, FileRow, MarkdownBlock, OverviewFileRow, ReadmeCard, RepoTreeSkeleton } from '../../components/repo';import { RepoFrame, useRepoQuery } from './shared';import { blobHref, commitHref, commitsHref, countLines, decodeRoutePath, formatBytes, formatLanguagePercent, formatRelativeTime, getErrorMessage, getParentPath, imageLike, isDirectory, joinPath, languageColor, markdownLike, safeDecode, sortedTreeEntries, svgLike, treeHref, videoLike } from '../../lib/repo-utils';import { LOADING_DELAY_MS, hasNamedRef, normalizeLanguages, resolveDefaultBranchName, resolveRouteRefAndPath, shouldAnimateNavigation, useDelayedLoading } from './code-helpers';
622 unmodified linesExpand all <GitBranch class="size-4" /> </button> </div>
<a href={treeHref(repo, activeRef, path())} class={clsx(buttonStyles('primary'), 'bg-green-700 border-green-700 hover:bg-green-800 hover:border-green-800')}> <Download class="size-4" /> code </a> <CloneDropdown repo={repo} refName={activeRef} /> </div>
<Show when={isNestedTreePath}> <div>369 unmodified linesExpand all1223456256 unmodified linesExpand all2632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023031 unmodified line3053063073082763093103113123133149 unmodified lines3243253263273283293303313323333343353363373383393403413423433443453463473483493503513523023533543553563573583053063593603613623633643653663673683693703713723733743103753763773783794 unmodified lines3843853863873233883893903913923933943953963323973983994004014 unmodified lines4064074084093454104114124134141 unmodified line416417418419355420421422423424155 unmodified linesExpand all58058158258351958458558658758858959026 unmodified lines61761861962055462162262362462562655855962762862963063163263363463563663763863964064139 unmodified linesimport clsx from 'clsx';import { Ban, CirclePlus, LoaderCircle, MessageSquarePlus, Pencil, Reply, Search, Smile, X } from 'lucide-solid';import { Ban, CirclePlus, LoaderCircle, MessageSquarePlus, Pencil, RefreshCcwDot, Reply, Search, Smile, X } from 'lucide-solid';import { 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';256 unmodified linesExpand all </RepoFrame> );};
const updateIssueStateData = ( data: unknown, issueUri: ResourceUri, state: 'open' | 'closed',): unknown => { if (!data || typeof data !== 'object') return data;
if ('issue' in data) { const detail = data as { issue?: { uri?: string; state?: string } }; if (detail.issue?.uri === issueUri) { return { ...detail, issue: { ...detail.issue, state, }, }; } }
if ('items' in data) { const page = data as { items?: Array<{ uri?: string; state?: string }> }; if (Array.isArray(page.items)) { return { ...page, items: page.items.map((issue) => (issue.uri === issueUri ? { ...issue, state } : issue)), }; } }
return data;};
export const IssuePage: Component = () => { const auth = useAuth(); const repoQuery = useRepoQuery(); const params = useParams();1 unmodified line const issueRef = createMemo(() => params.issueRef ?? ''); const [comment, setComment] = createSignal(''); const [replyingTo, setReplyingTo] = createSignal<ResourceUri | null>(null); const [replyBody, setReplyBody] = createSignal(''); const [working, setWorking] = createSignal(false); const [workingAction, setWorkingAction] = createSignal<'comment' | 'reply' | 'state' | null>(null); const working = createMemo(() => workingAction() !== null); const [error, setError] = createSignal<string | null>(null);
const issueQuery = createQuery(() => { const repo = repoQuery.data;9 unmodified lines await client.invalidateQueries({ queryKey: issueQueryKey(repo.repoDid, issueRef) }); await client.invalidateQueries({ queryKey: issueQueryKey(repo.repoDid, parseAtUri(issueUri).rkey) }); };
const updateIssueStateCaches = ( repo: RepoContext, currentIssueRef: string, issueUri: ResourceUri, state: 'open' | 'closed', ) => { client.setQueryData(issueQueryKey(repo.repoDid, currentIssueRef), (data: unknown) => updateIssueStateData(data, issueUri, state), ); client.setQueryData(issueQueryKey(repo.repoDid, parseAtUri(issueUri).rkey), (data: unknown) => updateIssueStateData(data, issueUri, state), ); client.setQueriesData<unknown>({ queryKey: issuesQueryKey(repo.repoDid) }, (data: unknown) => updateIssueStateData(data, issueUri, state), ); };
const toggleState = async () => { const agent = auth.agent(); const repo = repoQuery.data; const detail = issueQuery.data; if (!agent || !repo || !detail) { return; }
setWorking(true); const nextState = detail.issue.state === 'open' ? 'closed' : 'open'; const commentBody = nextState === 'closed' ? comment().trim() : '';
setWorkingAction('state'); setError(null); try { await setIssueState(agent, detail.issue.uri, detail.issue.state === 'open' ? 'closed' : 'open'); await withIssueInvalidation(repo, issueRef(), detail.issue.uri); if (commentBody) { await createIssueComment(agent, detail.issue.uri, commentBody); setComment(''); } await setIssueState(agent, detail.issue.uri, nextState); if (commentBody) { await client.invalidateQueries({ queryKey: issueQueryKey(repo.repoDid, issueRef()) }); await client.invalidateQueries({ queryKey: issueQueryKey(repo.repoDid, parseAtUri(detail.issue.uri).rkey) }); } updateIssueStateCaches(repo, issueRef(), detail.issue.uri, nextState); await client.invalidateQueries({ queryKey: issuesQueryKey(repo.repoDid), refetchType: 'none' }); await client.invalidateQueries({ queryKey: issueQueryKey(repo.repoDid, issueRef()), refetchType: 'none' }); await client.invalidateQueries({ queryKey: issueQueryKey(repo.repoDid, parseAtUri(detail.issue.uri).rkey), refetchType: 'none' }); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Failed to update issue state'); } finally { setWorking(false); setWorkingAction(null); } };
const addComment = async (event: SubmitEvent) => {4 unmodified lines if (!agent || !repo || !detail || !comment().trim()) { return; }
setWorking(true); setWorkingAction('comment'); setError(null); try { await createIssueComment(agent, detail.issue.uri, comment()); setComment(''); await withIssueInvalidation(repo, issueRef(), detail.issue.uri); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Failed to create comment'); } finally { setWorking(false); setWorkingAction(null); } };
const submitReply = async (event: SubmitEvent, replyTo: ResourceUri) => {4 unmodified lines if (!agent || !repo || !detail || !replyBody().trim()) { return; }
setWorking(true); setWorkingAction('reply'); setError(null); try { await createIssueComment(agent, detail.issue.uri, replyBody(), replyTo); setReplyBody('');1 unmodified line await withIssueInvalidation(repo, issueRef(), detail.issue.uri); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Failed to create reply'); } finally { setWorking(false); setWorkingAction(null); } };
return (155 unmodified linesExpand all <X class="size-4" /> cancel </button> <button type="submit" class={buttonStyles('primary')} disabled={working() || !replyBody().trim()}> <Reply class="size-4" /> <Show when={workingAction() === 'reply'} fallback={<Reply class="size-4" />}> <LoaderCircle class="size-4 animate-spin" /> </Show> reply </button> </div> </form>26 unmodified lines </Show> </div> <div class="flex flex-wrap items-center gap-2"> <button type="submit" class={clsx(buttonStyles('primary'), 'justify-center')} disabled={working() || !comment().trim()}> <MessageSquarePlus class="size-4" /> <Show when={workingAction() === 'comment'} fallback={<MessageSquarePlus class="size-4" />}> <LoaderCircle class="size-4 animate-spin" /> </Show> comment </button> <button type="button" class={buttonStyles()} disabled={working()} onClick={() => void toggleState()}> <Ban class="size-4" /> {detail().issue.state === 'open' ? 'close' : 'reopen'} <Show when={workingAction() === 'state'} fallback={ <Show when={detail().issue.state === 'open'} fallback={<RefreshCcwDot class="size-4" />}> <Ban class="size-4" /> </Show> } > <LoaderCircle class="size-4 animate-spin" /> </Show> {detail().issue.state === 'open' ? (comment().trim() ? 'close with comment' : 'close') : 'reopen'} </button> </div> </form> </Show>39 unmodified lines9 unmodified lines101112131415161718192021212223242526718 unmodified linesExpand all74574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878975679079179279379479525 unmodified lines8218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548557988568578588598018028608618628638648658068668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159161 unmodified line91891992092181992292392492592692792892993082893193293393493576 unmodified lines101210131014101591310161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041915916917104210431044104510461047104810491050105110521053105485 unmodified lines1140114111421143101310141144114511461147114811491150115112 unmodified lines9 unmodified lines GitPullRequestClosed, LoaderCircle, MessageSquare, Pencil, RefreshCcwDot, Search, X,} from 'lucide-solid';import { A, useNavigate, useParams, useSearchParams } from '@solidjs/router';import { createQuery, useQueryClient } from '@tanstack/solid-query';import { For, Match, Show, Switch, createEffect, createMemo, createSignal, type Component, type JSX } from 'solid-js';import { resolveActor } from '../../lib/api/identity';import { createPull, createPullComment, fetchPullRoundPatch, getPull, listPullsPage, setPullStatus } from '../../lib/api/pulls';import { createPull, createPullComment, fetchPullRoundPatch, getPull, listPullsPage, mergePull, setPullStatus } from '../../lib/api/pulls';import { parseAtUri } from '../../lib/api/records';import { compareBranches, getRepoBranches, type RepoContext } from '../../lib/api/repos';import { useAuth } from '../../lib/auth';import { Avatar, ErrorState, LoadingState, PaginationControls, StateBadge, ToggleButton, buttonStyles, cardStyles, textareaStyles } from '../../components/common';718 unmodified linesExpand all <span class="text-xs text-gray-500 dark:text-gray-400">{props.description}</span> </button>);
const updatePullStatusData = ( data: unknown, pullUri: string, state: 'open' | 'closed' | 'merged',): unknown => { if (!data || typeof data !== 'object') return data;
if ('pull' in data) { const detail = data as { pull?: { uri?: string; state?: string } }; if (detail.pull?.uri === pullUri) { return { ...detail, pull: { ...detail.pull, state, }, }; } }
if ('items' in data) { const page = data as { items?: Array<{ uri?: string; state?: string }> }; if (Array.isArray(page.items)) { return { ...page, items: page.items.map((pull) => (pull.uri === pullUri ? { ...pull, state } : pull)), }; } }
return data;};
export const PullPage: Component = () => { const auth = useAuth(); const repoQuery = useRepoQuery(); const params = useParams(); const client = useQueryClient(); const pullRef = createMemo(() => params.pullRef ?? ''); const [roundIndex, setRoundIndex] = createSignal(0); const [comment, setComment] = createSignal(''); const [working, setWorking] = createSignal(false); const [workingAction, setWorkingAction] = createSignal<'comment' | 'state' | 'merge' | null>(null); const working = createMemo(() => workingAction() !== null); const [error, setError] = createSignal<string | null>(null);
const pullQuery = createQuery(() => { const repo = repoQuery.data;25 unmodified lines await client.invalidateQueries({ queryKey: pullQueryKey(repo.repoDid, pullRef) }); await client.invalidateQueries({ queryKey: pullQueryKey(repo.repoDid, parseAtUri(pullUri).rkey) }); };
const updatePullStatusCaches = ( repo: RepoContext, currentPullRef: string, pullUri: string, state: 'open' | 'closed' | 'merged', ) => { client.setQueryData(pullQueryKey(repo.repoDid, currentPullRef), (data: unknown) => updatePullStatusData(data, pullUri, state), ); client.setQueryData(pullQueryKey(repo.repoDid, parseAtUri(pullUri).rkey), (data: unknown) => updatePullStatusData(data, pullUri, state), ); client.setQueriesData<unknown>({ queryKey: pullsQueryKey(repo.repoDid) }, (data: unknown) => updatePullStatusData(data, pullUri, state), ); };
const markPullQueriesStale = async (repo: RepoContext, currentPullRef: string, pullUri: string) => { await client.invalidateQueries({ queryKey: pullsQueryKey(repo.repoDid), refetchType: 'none' }); await client.invalidateQueries({ queryKey: pullQueryKey(repo.repoDid, currentPullRef), refetchType: 'none' }); await client.invalidateQueries({ queryKey: pullQueryKey(repo.repoDid, parseAtUri(pullUri).rkey), refetchType: 'none' }); };
const toggleState = async () => { const agent = auth.agent(); const repo = repoQuery.data; const detail = pullQuery.data; if (!agent || !repo || !detail) { return; }
setWorking(true); const nextState = detail.pull.state === 'open' ? 'closed' : 'open'; setWorkingAction('state'); setError(null); try { await setPullStatus(agent, detail.pull.uri, detail.pull.state === 'open' ? 'closed' : 'open'); await invalidate(repo, pullRef(), detail.pull.uri); await setPullStatus(agent, detail.pull.uri, nextState); updatePullStatusCaches(repo, pullRef(), detail.pull.uri, nextState); await markPullQueriesStale(repo, pullRef(), detail.pull.uri); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Failed to update pull state'); } finally { setWorking(false); setWorkingAction(null); } };
const mergeCurrentPull = async () => { const agent = auth.agent(); const repo = repoQuery.data; const detail = pullQuery.data; if (!agent || !repo || !detail || detail.pull.state !== 'open') { return; }
const latestRoundIndex = detail.pull.value.rounds.length - 1; if (latestRoundIndex < 0) { setError('No pull request patch is available to merge'); return; }
const confirmed = window.confirm( `Are you sure you want to merge pull #${detail.pull.number || detail.pull.rkey} into the ${detail.pull.value.target.branch} branch?`, ); if (!confirmed) return;
setWorkingAction('merge'); setError(null); try { const patch = roundIndex() === latestRoundIndex && patchQuery.data ? patchQuery.data : await fetchPullRoundPatch(detail.pull, latestRoundIndex); await mergePull(agent, repo, { pull: detail.pull, patch, }); updatePullStatusCaches(repo, pullRef(), detail.pull.uri, 'merged'); await markPullQueriesStale(repo, pullRef(), detail.pull.uri); await client.invalidateQueries({ queryKey: ['repo-tree', repo.repoDid] }); await client.invalidateQueries({ queryKey: ['repo-log', repo.repoDid] }); await client.invalidateQueries({ queryKey: ['repo-branches', repo.repoDid] }); await client.invalidateQueries({ queryKey: ['repo-languages', repo.repoDid] }); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Failed to merge pull request'); } finally { setWorkingAction(null); } };
const addComment = async (event: SubmitEvent) => { event.preventDefault(); const agent = auth.agent(); const repo = repoQuery.data;1 unmodified line if (!agent || !repo || !detail || !comment().trim()) { return; }
setWorking(true); setWorkingAction('comment'); setError(null); try { await createPullComment(agent, detail.pull.uri, comment()); setComment(''); await invalidate(repo, pullRef(), detail.pull.uri); } catch (cause) { setError(cause instanceof Error ? cause.message : 'Failed to create comment'); } finally { setWorking(false); setWorkingAction(null); } };
const participants = createMemo(() => {76 unmodified lines <MarkdownBlock markdown={detail().pull.value.body!} /> </div> </Show>
<div class="mt-4 flex items-center gap-2"> <div class="mt-4 flex flex-wrap items-center justify-end gap-2"> <Show when={auth.agent()}> <Show when={detail().pull.state === 'open'}> <button type="button" class={buttonStyles('primary')} disabled={working() || detail().pull.value.rounds.length === 0} onClick={() => void mergeCurrentPull()} > <Show when={workingAction() === 'merge'} fallback={<GitMerge class="size-4" />}> <LoaderCircle class="size-4 animate-spin" /> </Show> merge </button> </Show> <Show when={detail().pull.state !== 'merged'}> <button type="button" class={buttonStyles()} disabled={working()} onClick={() => void toggleState()}> <Show when={workingAction() === 'state'} fallback={ <Show when={detail().pull.state === 'open'} fallback={<RefreshCcwDot class="size-4" />}> <GitPullRequestClosed class="size-4" /> </Show> } > <LoaderCircle class="size-4 animate-spin" /> <button type="button" class={clsx(buttonStyles(), 'ml-auto')} disabled={working()} onClick={() => void toggleState()}> {detail().pull.state === 'open' ? 'close pull' : 'reopen pull'} </button> </Show> {detail().pull.state === 'open' ? 'close pull' : 'reopen pull'} </button> </Show> </Show> </div> <Show when={error()}> <p class="mt-3 text-sm text-red-500">{error()}</p> </Show> </section>
<aside class="untangled-pr-meta untangled-thread-meta"> <div class="untangled-pr-meta-section">85 unmodified lines /> <Show when={error()}> <p class="text-sm text-red-500">{error()}</p> </Show> <button type="submit" class={clsx(buttonStyles('primary'), 'justify-center')} disabled={working()}> <MessageSquare class="size-4" /> <button type="submit" class={clsx(buttonStyles('primary'), 'justify-center')} disabled={working() || !comment().trim()}> <Show when={workingAction() === 'comment'} fallback={<MessageSquare class="size-4" />}> <LoaderCircle class="size-4 animate-spin" /> </Show> comment </button> </form> </Show>12 unmodified lines