~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
683 lines · 23.3 KB Raw History
<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>