johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
<script lang="ts">
import type {
ActorView,
CommentView,
CommitView,
LineView
} from '#lib/lexicons/dev/knotless/defs.ts';
import type * as createComment from '#lib/lexicons/dev/knotless/createComment.ts';
import type { Image } from '#lib/lexicons/dev/knotless/defs.ts';
import type * as deleteComment from '#lib/lexicons/dev/knotless/deleteComment.ts';
import type * as editItem from '#lib/lexicons/dev/knotless/editItem.ts';
import type * as hideItem from '#lib/lexicons/dev/knotless/hideItem.ts';
import type * as assignItem from '#lib/lexicons/dev/knotless/assignItem.ts';
import type * as dismissReview from '#lib/lexicons/dev/knotless/dismissReview.ts';
import type * as labelItem from '#lib/lexicons/dev/knotless/labelItem.ts';
import type * as revertPull from '#lib/lexicons/dev/knotless/revertPull.ts';
import type * as setItemState from '#lib/lexicons/dev/knotless/setItemState.ts';
import type { Mention } from '#lib/lexicons/dev/knotless/getThread.ts';
import type { AtUriString } from '@atproto/lex';
import { goto, invalidateAll } from '$app/navigation';
import type { Ticking } from '#lib/tasks.ts';
import { call } from '#lib/data.ts';
import { checkIcon, stateIcon } from '#lib/icons.ts';
import { LOGIN } from '#lib/login.ts';
import { REPORT, reporting } from '#lib/report.svelte.ts';
import { written } from '#lib/write.ts';
import {
ago,
capitalized,
checkHref,
ejectedWhy,
itemHref,
mergeSaid,
repoHref,
summary,
whenCommitted
} from '#lib/format.ts';
import Icon from '#lib/atoms/Icon.svelte';
import Ref from '#lib/atoms/Ref.svelte';
import Sha from '#lib/atoms/Sha.svelte';
import Time from '#lib/atoms/Time.svelte';
import ActionMenu, { type Action } from '#lib/molecules/ActionMenu.svelte';
import Person from '#lib/molecules/Person.svelte';
import PullTabs from '#lib/molecules/PullTabs.svelte';
import Verdict from '#lib/molecules/Verdict.svelte';
import ReactionBar from '#lib/molecules/ReactionBar.svelte';
import RerunMenu from '#lib/molecules/RerunMenu.svelte';
import SubscribeButton from '#lib/molecules/SubscribeButton.svelte';
import MergeChecks from '#lib/organisms/MergeChecks.svelte';
import Said from '#lib/atoms/Said.svelte';
import ClosingList from '#lib/organisms/ClosingList.svelte';
import ItemFacts from '#lib/organisms/ItemFacts.svelte';
import CommitEvent from '#lib/organisms/CommitEvent.svelte';
import Comment from '#lib/organisms/Comment.svelte';
import Composer, { type Verdict as Found } from '#lib/organisms/Composer.svelte';
import LineThread from '#lib/organisms/LineThread.svelte';
import MergeBox from '#lib/organisms/MergeBox.svelte';
import PullStack from '#lib/organisms/PullStack.svelte';
import RepoPage from '#lib/templates/RepoPage.svelte';
import Empty from '#lib/molecules/Empty.svelte';
let { data } = $props();
const t = $derived(data.thread);
const d = $derived(t.item);
const r = $derived(data.repo);
const base = $derived(repoHref(data.authority, r.name));
const n = $derived(t.comments.length);
const ch = $derived(t.changes);
const sum = (files: { added: number; removed: number }[], k: 'added' | 'removed') =>
files.reduce((total, f) => total + f[k], 0);
const from = $derived(t.head?.repo ? `${t.head.repo.handle}/${t.head.repo.name}` : '');
/** Whoever is logged in, and whether it is the repository's owner, who may run its checks again. */
const viewer = $derived(data.viewer?.actor);
const owns = $derived(viewer?.did === r.owner.did);
/** The workflows of the pull's own runs, by the repository's runners. */
const ran = $derived(
t.checks
.filter((c) => c.repo.did === r.owner.did && c.repo.name === r.name)
.map((c) => c.workflow)
);
/** An open pull's merge box, which shows its checks in place of the timeline. */
const box = $derived(d.kind === 'pull' && d.state === 'open' && t.base !== undefined);
const about = $derived(
t.body
? summary(t.body, 200)
: `${d.kind === 'pull' ? 'A pull' : 'An issue'} on ${data.authority}/${r.name} by ${d.author.handle}.`
);
type Root = CommentView & { line: LineView };
const said = $derived(new Set(t.comments.map((c) => c.uri)));
/** The threads on its lines sent with the review `uri`. */
const sentWith = (uri?: string) =>
t.comments.filter((c): c is Root => !!c.line && !c.reply && !!uri && c.review === uri);
const answers = (root: CommentView) => t.comments.filter((c) => c.reply === root.uri);
/**
* Whether a comment shows under another rather than as an entry of its
* own: an answer in a thread shows in it, and a thread sent with a
* review, under the review.
*/
const under = (c: CommentView) =>
(!!c.reply && said.has(c.reply)) || (!!c.line && !!c.review && said.has(c.review));
/**
* What was said and what was pushed, in the order it happened: a
* comment to an entry, and each run of commits between comments to one.
* Commits made before the pull was opened come first, after its opening;
* its merge shows as the merge it is, not as a commit it added. On an
* issue, each pull that names it shows where it was opened.
*/
const events = $derived.by(() => {
const out: ({ said: CommentView } | { commits: CommitView[] } | { mention: Mention })[] = [];
const when = (at: string) => Math.max(Date.parse(at), Date.parse(d.createdAt));
const commits = (ch?.commits ?? []).filter((c) => c.sha !== t.merged);
const mentions = t.mentionedBy ?? [];
let next = 0;
let named = 0;
const happened = (before: number) => {
while (named < mentions.length && when(mentions[named].item.createdAt) <= before)
out.push({ mention: mentions[named++] });
const run: CommitView[] = [];
while (next < commits.length && when(whenCommitted(commits[next])) <= before)
run.push(commits[next++]);
if (run.length) out.push({ commits: run });
};
for (const said of t.comments.filter((c) => !under(c))) {
happened(Date.parse(said.createdAt));
out.push({ said });
}
happened(Infinity);
return out;
});
/** Each event by what it is rather than where it falls, so a comment being edited or replied to keeps its box as others come before it. */
const keyOf = (e: (typeof events)[number], k: number) =>
'said' in e
? (e.said.uri ?? `said ${k}`)
: 'mention' in e
? `mention ${e.mention.item.uri}`
: `commits ${e.commits[0].sha}`;
/** Whether a review looked at a head the open pull has since moved past. */
const earlier = (c: CommentView) =>
!!c.verdict && !!c.commit && !!ch?.head && c.commit !== ch.head;
// Who is looking, and what they may do with it.
const me = $derived(data.viewer?.actor?.did);
const may = $derived(t.viewer);
const noun = $derived(d.kind === 'pull' ? 'pull' : 'issue');
const repo = $derived(r.uri);
/** The repository as the page names it, which what is said here links inside. */
const named = $derived(`at://${data.authority}/dev.knotless.repo/${r.name}` as AtUriString);
const subject = $derived(d.uri);
/** What is being changed in place: the title, the body, or a comment by its URI. */
let editing = $state('');
/** What was last hidden, to show again; and why a write failed. */
let hidden = $state<AtUriString | ''>('');
let problem = $state('');
let title = $state('');
/** Which of the page's own buttons is under way: 'title', 'state' or 'unhide'. */
let busy = $state('');
async function act(write: () => Promise<unknown>) {
problem = (await written(write)) ?? '';
return problem || undefined;
}
/** Does `work`, its button showing `action` under way. */
async function doing(action: string, work: () => Promise<unknown>) {
busy = action;
await work();
busy = '';
}
const comment = (body: string, verdict?: Found, images?: Image[]) =>
act(() =>
call<typeof createComment.main>('dev.knotless.createComment', {
repo,
subject,
body,
verdict,
images
})
);
const edit = (on: AtUriString, change: { title?: string; body?: string; images?: Image[] }) =>
act(() =>
call<typeof editItem.main>('dev.knotless.editItem', { repo, subject: on, ...change })
).then((failed) => {
if (!failed) editing = '';
return failed;
});
/** How ticks of the task boxes in what `on` says are kept: each saved as it comes, the page read anew once the last is. */
const ticking = (on: AtUriString): Ticking => ({
write: (body) =>
call<typeof editItem.main>('dev.knotless.editItem', { repo, subject: on, body }),
settle: () => invalidateAll(),
failed: (why) => (problem = why)
});
const setState = (state: 'open' | 'closed') =>
doing('state', () =>
act(() =>
call<typeof setItemState.main>('dev.knotless.setItemState', { repo, subject, state })
)
);
const label = (name: string, removed: boolean) =>
act(() =>
call<typeof labelItem.main>('dev.knotless.labelItem', { repo, subject, label: name, removed })
);
/** Asks an account to review the pull, or assigns it to one, or with `removed` no longer. */
const assigning = (role: 'reviewer' | 'assignee') => (did: ActorView['did'], removed: boolean) =>
act(() =>
call<typeof assignItem.main>('dev.knotless.assignItem', { repo, subject, did, role, removed })
);
const ask = assigning('reviewer');
/** Dismisses `did`'s review of the pull, saying why, then draws the page again. */
const dismiss = async (did: ActorView['did'], reason: string) => {
await call<typeof dismissReview.main>('dev.knotless.dismissReview', {
repo,
pull: subject,
reviewer: did,
reason
});
await invalidateAll();
};
/** What a comment did, beside saying something: reviewed, or dismissed someone's review. */
const deed = (c: CommentView) => {
if (c.dismisses) {
const of = t.comments.find((o) => o.uri === c.dismisses);
return of ? `dismissed ${of.author.handle}'s review` : 'dismissed a review';
}
return c.verdict ? 'reviewed' : 'commented';
};
/** Whether the review `c` gave was dismissed. */
const wasDismissed = (c: CommentView) =>
!!c.verdict && (t.reviews ?? []).some((r) => r.uri === c.uri && r.dismissed);
const assign = assigning('assignee');
const maintainers = $derived([r.owner, ...(r.collaborators ?? [])]);
/** Whom its author, the owner or a collaborator may ask to review an open pull: the owner and collaborators, besides the author. */
const people = $derived(
d.kind === 'pull' && d.state === 'open' && (me === d.author.did || may?.assign)
? maintainers.filter((p) => p.did !== d.author.did)
: []
);
/** Whom the owner or a collaborator may assign it to: one of them, or its author. */
const assignable = $derived(
may?.assign
? [...maintainers, d.author].filter(
(p, i, all) => all.findIndex((o) => o.did === p.did) === i
)
: []
);
/** What the author does with the pull or issue as a whole, beside its title. */
const own = $derived<Action[]>(
me === d.author.did
? [
{
label: 'Edit title',
run: () => {
title = d.title;
editing = 'title';
}
},
{ label: 'Edit description', run: () => (editing = 'body') }
]
: []
);
async function hide(on: AtUriString, shown = false) {
const failed = await act(() =>
call<typeof hideItem.main>('dev.knotless.hideItem', { repo, subject: on, hidden: !shown })
);
if (failed) return;
hidden = shown ? '' : on;
// A pull or an issue hidden leaves its page with it.
if (!shown && on === subject) goto(`${base}/${noun}s`);
}
const reply = (root: CommentView) => (body: string) =>
act(() =>
call<typeof createComment.main>('dev.knotless.createComment', {
repo,
subject: root.uri as AtUriString,
body
})
);
const resolve = (root: CommentView) => (resolved: boolean) =>
act(() =>
call<typeof setItemState.main>('dev.knotless.setItemState', {
repo,
subject: root.uri as AtUriString,
state: resolved ? 'resolved' : 'open'
})
);
/** Whether whoever is looking may resolve the thread `root` starts: as its author, the pull's, the owner or a collaborator. */
const resolves = (root: CommentView) => !!me && (me === root.author.did || !!may?.setState);
async function remove(on: AtUriString) {
if (!confirm('Delete this comment for good?')) return;
await act(() =>
call<typeof deleteComment.main>('dev.knotless.deleteComment', { repo, subject: on })
);
}
/** Whether a revert is being made, which takes a moment: a commit, a push and a pull. */
let reverting = $state(false);
/** Reverts the merged pull, and goes to the pull that brings the revert in. */
async function revert() {
reverting = true;
let made = '';
await act(async () => {
made = (
await call<typeof revertPull.main>('dev.knotless.revertPull', { repo, pull: subject })
).uri;
});
reverting = false;
if (made) goto(itemHref(base, made));
}
/** Reporting `on`, a record or its author, as `what` says it is. */
const report = (on: string, what: string): Action => ({
label: `Report ${what}`,
opens: me ? REPORT : LOGIN,
run: () => {
reporting.subject = on;
reporting.what = what;
}
});
/** What can be done with what `author` wrote at `uri`, its body for editing. */
function actions(
uri: AtUriString | undefined,
author: string,
what: string,
editAs: string
): Action[] {
if (!uri) return [];
const out: Action[] = [];
if (me === author) out.push({ label: 'Edit', run: () => (editing = editAs) });
if (me === author && uri !== subject)
out.push({ label: 'Delete', run: () => remove(uri), danger: true });
if (may?.hide) out.push({ label: `Hide ${what}`, run: () => hide(uri) });
if (me !== author) out.push(report(uri, what));
return out;
}
</script>
{#snippet pill()}<span class="pill {d.draft && d.state === 'open' ? 'draft' : d.state} {d.kind}"
><Icon name={stateIcon(d.kind, d.state, d.draft)} />{capitalized(
d.draft && d.state === 'open' ? 'draft' : d.state
)}</span
>{/snippet}
{#snippet thread(root: Root)}
<LineThread
{root}
replies={answers(root)}
quote
in={!!me}
may={resolves(root)}
repo={named}
reply={reply(root)}
resolve={resolve(root)}
>
{#snippet menu(c)}<ActionMenu
actions={actions(c.uri, c.author.did, 'this comment', c.uri ?? '').filter(
(a) => a.label !== 'Edit'
)}
/>{/snippet}
</LineThread>
{/snippet}
<RepoPage
repo={r}
tab={d.kind === 'pull' ? 'pulls' : 'issues'}
title="{d.title} · {r.name}"
{about}
card={t.card}
>
{#if editing === 'title'}
<form
class="title-edit"
onsubmit={(e) => {
e.preventDefault();
doing('title', () => edit(subject, { title }));
}}
>
<input class="field" name="title" aria-label="Title" required bind:value={title} />
<button
class="btn primary"
type="submit"
disabled={!!busy || !title.trim()}
aria-busy={busy === 'title'}>{busy === 'title' ? 'Saving…' : 'Save'}</button
>
<button class="btn" type="button" onclick={() => (editing = '')}>Cancel</button>
</form>
{:else}
<div class="item-head">
<h2 class="item-title">{d.title}</h2>
{@render pill()}
{#if (me && may) || own.length}
<div class="item-actions">
{#if me && may}<SubscribeButton {subject} viewer={may} {noun} />{/if}
<ActionMenu actions={own} label="More for this {noun}" />
</div>
{/if}
</div>
{/if}
<div class="item-meta">
{@render pill()}
{#if t.base !== undefined && t.head !== undefined}
<span
><Person handle={d.author.handle} bot={d.author.bot} />{mergeSaid(d.state)}<Ref
name={t.base}
/> from <Ref name="{from}:{t.head.branch}" /></span
>
{:else}
<span
><Person handle={d.author.handle} bot={d.author.bot} />{' opened this issue '}<Time
at={d.createdAt}
show={(t) => ago(t, 'on ')}
/></span
>
{/if}
{#if !ch}<span class="end"><Icon name="chat" />{n} comment{n === 1 ? '' : 's'}</span>{/if}
<ItemFacts
reviews={d.kind === 'pull' ? t.reviews : []}
requested={d.kind === 'pull' && d.state === 'open' ? d.reviewers : []}
required={d.requiredReviewers}
base={t.base}
{people}
ask={people.length ? ask : undefined}
assignees={d.assignees}
{assignable}
assign={assignable.length ? assign : undefined}
labels={d.labels}
offered={may?.labels}
label={may?.labels?.length ? label : undefined}
/>
</div>
{#if ch}<PullTabs
href={itemHref(base, d.uri)}
comments={n}
files={ch.files.length}
added={sum(ch.files, 'added')}
removed={sum(ch.files, 'removed')}
current="conversation"
/>{/if}
<div class="timeline">
{#if editing === 'body'}
<Composer
in
repo={named}
label="Update {noun}"
text={t.body ?? ''}
placeholder="What it is about"
rows={14}
send={(body, _, images) => edit(subject, { body, images })}
cancel={() => (editing = '')}
/>
{:else if t.html !== undefined}
<Comment
author={d.author.handle}
did={d.author.did}
avatar={d.author.avatar}
bot={d.author.bot}
said="opened"
createdAt={d.createdAt}
updatedAt={d.updatedAt}
html={t.html}
body={t.body}
tick={me === d.author.did ? ticking(subject) : undefined}
>
{#snippet menu()}<ActionMenu
actions={actions(subject, d.author.did, `this ${noun}`, 'body')}
/>{/snippet}
{#snippet below()}<ReactionBar
repo={r.uri}
subject={d.uri}
reactions={t.reactions}
in={!!viewer}
/>{/snippet}
</Comment>
{:else if me === d.author.did}
<div class="event">
<button class="btn" type="button" onclick={() => (editing = 'body')}
>Describe this {noun}</button
>
</div>
{/if}
{#each events as e, k (keyOf(e, k))}
{#if 'said' in e && e.said.line && !e.said.reply}
{@const root = e.said as Root}
{@render thread(root)}
{:else if 'said' in e}
{#if e.said.uri && editing === e.said.uri}
{@const on = e.said.uri}
<Composer
in
repo={named}
label="Update comment"
text={e.said.body ?? ''}
send={(body, _, images) => edit(on, { body, images })}
cancel={() => (editing = '')}
/>
{:else}
<Comment
id={e.said.uri ? `comment-${e.said.uri.split('/').pop()}` : undefined}
author={e.said.author.handle}
did={e.said.author.did}
avatar={e.said.author.avatar}
bot={e.said.author.bot}
said={deed(e.said)}
createdAt={e.said.createdAt}
updatedAt={e.said.updatedAt}
html={e.said.html}
body={e.said.body}
tick={me === e.said.author.did && e.said.uri ? ticking(e.said.uri) : undefined}
>
{#snippet end()}<Verdict
verdict={e.said.verdict}
earlier={earlier(e.said)}
dismissed={wasDismissed(e.said)}
/>{/snippet}
{#snippet menu()}<ActionMenu
actions={actions(e.said.uri, e.said.author.did, 'this comment', e.said.uri ?? '')}
/>{/snippet}
{#snippet below()}
{#if e.said.uri}<ReactionBar
repo={r.uri}
subject={e.said.uri}
reactions={e.said.reactions}
in={!!viewer}
/>{/if}
{#each sentWith(e.said.uri) as root (root.uri)}<div class="review-thread">
{@render thread(root)}
</div>{/each}
{/snippet}
</Comment>
{/if}
{:else if 'mention' in e}
{@const p = e.mention.item}
<div class="event">
<span class="dot soft"><Icon name={stateIcon(p.kind, p.state, p.draft)} /></span><span
><Person handle={p.author.handle} bot={p.author.bot} />{' mentioned this in '}<a
class="closer"
href={itemHref(base, p.uri)}>{p.title}</a
>{e.mention.closes && p.state === 'open' ? ', which closes it once merged, ' : ' '}<Time
at={p.createdAt}
show={(at) => ago(at, 'on ')}
/></span
>
</div>
{:else if ch}
<CommitEvent
author={d.author.handle}
commits={e.commits}
at={repoHref(ch.repo.handle, ch.repo.name)}
/>
{/if}
{/each}
{#if t.checks.length && !box}<div class="box merge-box">
<MergeChecks checks={t.checks} base={t.base ?? ''} />
</div>{/if}
{#if t.queue?.state === 'queued'}
<div class="event">
<span class="dot"><Icon name="queue" /></span><a class="queued" href="{base}/queue"
>{`${t.queue.said} · queued `}<Time
at={t.queue.createdAt}
show={(at) => ago(at, 'on ')}
/></a
>
</div>
{:else if t.queue?.state === 'ejected'}
<div class="event">
<span class="dot"><Icon name="failed" /></span><span
><a class="queued" href="{base}/queue">Ejected from the merge queue</a>{': '}<Said
text={ejectedWhy(t.queue.reason)}
/>{#each t.queue.checks as c (c.uri)}{' '}<a
class="failed-check"
href={checkHref(c.repo, c.uri)}><Icon name={checkIcon(c.status)} />{c.workflow}</a
>{/each}</span
>
</div>
{/if}
{#if t.merged}
<div class="event">
<span class="dot"><Icon name="merge" /></span><span
>Merged as <Sha
sha={t.merged}
href={repoHref(data.authority, r.name, `/commit/${t.merged}`)}
/></span
>{#if may?.revert && !t.revertedBy}<button
class="btn end"
type="button"
disabled={reverting}
aria-busy={reverting}
onclick={revert}><Icon name="undo" />{reverting ? 'Reverting…' : 'Revert'}</button
>{/if}
</div>
{/if}
{#if t.revertedBy}
<div class="event">
<span class="dot"><Icon name="undo" /></span><span
>Reverted by <a class="closer" href={itemHref(base, t.revertedBy.uri)}
>{t.revertedBy.title}</a
></span
>
</div>
{/if}
{#if t.closedBy}
<div class="event">
<span class="dot closed"><Icon name="done" /></span><span
>Closed by <a class="closer" href={itemHref(base, t.closedBy.uri)}>{t.closedBy.title}</a
></span
>
</div>
{/if}
{#if t.stack && !box}<div class="box pull-stack">
<div class="boxhead">
<Icon name="stack" /><span>{`Part of a stack of ${t.stack.pulls.length}`}</span>
</div>
<PullStack stack={t.stack} current={d.uri} {base} />
</div>{/if}
{#if t.closes.length && d.state !== 'closed'}<ClosingList
issues={t.closes}
{base}
label={d.state === 'merged'
? 'Merging this pull closed these issues'
: 'Successfully merging this pull may close these issues'}
/>{/if}
{#if n === 0 && t.html === undefined}<div class="box">
<Empty title="Nothing said yet" />
</div>{/if}
{#if box && t.base !== undefined}
<MergeBox
stack={t.stack}
repo={r}
pull={d.uri}
base={t.base}
head={t.head}
queue={t.queue}
reviews={t.reviews}
requested={d.reviewers}
required={d.requiredReviewers}
dismiss={may?.dismiss ? dismiss : undefined}
draft={d.draft}
checks={t.checks}
minutes={t.minutes}
in={!!viewer}
>
{#snippet rerun()}{#if owns}<RerunMenu
repo={r.uri}
pull={d.uri}
workflows={ran}
/>{/if}{/snippet}
</MergeBox>
{/if}
{#if hidden}
{@const shown = hidden}
<div class="event">
<span>{'Hidden from this repository. '}</span><button
class="btn"
type="button"
disabled={!!busy}
aria-busy={busy === 'unhide'}
onclick={() => doing('unhide', () => hide(shown, true))}>Undo</button
>
</div>
{/if}
{#if problem}<p class="problem" role="alert">{problem}</p>{/if}
{#if !data.viewer || data.viewer.login}
<Composer
in={!!me}
repo={named}
review={d.kind === 'pull' && d.state === 'open' && !!me && me !== d.author.did}
send={comment}
>
{#snippet extra()}
{#if may?.setState && d.state !== 'merged'}
{@const to = d.state === 'open' ? 'closed' : 'open'}
<button
class="btn"
type="button"
disabled={!!busy}
aria-busy={busy === 'state'}
onclick={() => setState(to)}
><Icon name={to === 'closed' ? 'done' : stateIcon(d.kind, 'open')} />{to === 'closed'
? `Close ${noun}`
: `Reopen ${noun}`}</button
>
{/if}
{/snippet}
</Composer>
{/if}
</div>
</RepoPage>