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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
152 lines · 5.3 KB Raw History
<script lang="ts">
	import type { ActorView, ReviewView } from '#lib/lexicons/dev/knotless/defs.ts';
	import { plural } from '#lib/format.ts';
	import Avatar from '#lib/atoms/Avatar.svelte';
	import BotMark from '#lib/atoms/BotMark.svelte';
	import Icon from '#lib/atoms/Icon.svelte';
	/**
	 * Who reviews a pull, as a fact of it: their faces, each marked with
	 * where they stand, and how many, opening to a row each, their latest
	 * verdict linked to the review that gave it, then those `requested` who
	 * have not reviewed it yet. Those `required`, the gates of the rule
	 * protecting `base`, are marked required reviewers for it, and
	 * another who may not write, that their approval does not count. With `people` and `ask`, as its author, it
	 * also asks them to review it, or stops asking one not yet heard from.
	 */
	let {
		reviews,
		requested = [],
		required = [],
		base = '',
		people = [],
		ask
	}: {
		reviews: ReviewView[];
		requested?: ActorView[];
		required?: ActorView[];
		base?: string;
		people?: ActorView[];
		ask?: (did: ActorView['did'], removed: boolean) => Promise<unknown>;
	} = $props();
	const gate = (did: ActorView['did']) => required.some((a) => a.did === did);
	const awaited = $derived(requested.filter((a) => !reviews.some((r) => r.reviewer.did === a.did)));
	/** Those who may yet be asked: not asked already, nor reviewing. */
	const askable = $derived(
		people.filter(
			(p) =>
				!requested.some((a) => a.did === p.did) && !reviews.some((r) => r.reviewer.did === p.did)
		)
	);
	type Stand = 'approved' | 'earlier' | 'changes' | 'dismissed' | 'awaited';
	const stand = (r: ReviewView): Stand =>
		r.dismissed
			? 'dismissed'
			: r.verdict === 'changes'
				? 'changes'
				: r.outdated
					? 'earlier'
					: 'approved';
	const said: Record<Stand, string> = {
		approved: 'Approved',
		earlier: 'Approved an earlier version',
		changes: 'Asked for changes',
		dismissed: 'Dismissed',
		awaited: 'Review requested'
	};
	const faces = $derived([
		...reviews.map((r) => ({ who: r.reviewer, stand: stand(r) })),
		...awaited.map((a) => ({ who: a, stand: 'awaited' as Stand }))
	]);
	const count = (s: Stand) => faces.filter((f) => f.stand === s).length;
	/** How they stand, in words, after how many there are. */
	const tally = $derived(
		[
			count('approved') ? `${count('approved')} approved` : '',
			count('changes') ? `${count('changes')} asked for changes` : '',
			count('awaited') ? `${count('awaited')} waiting` : ''
		]
			.filter(Boolean)
			.join(', ')
	);
	const SHOWN = 5;
	const anchor = (r: ReviewView) => `#comment-${r.uri.split('/').pop()}`;
	let busy = $state('');
	let open = $state(false);
	async function pick(did: ActorView['did'], removed: boolean) {
		busy = did;
		await ask?.(did, removed);
		busy = '';
	}
</script>

{#if faces.length}
	<details class="popover filter-menu reviewers-fact" bind:open data-held={busy || undefined}>
		<summary
			aria-label="{plural(faces.length, 'reviewer', 'reviewers')}{tally ? `, ${tally}` : ''}"
		>
			<span class="avs">
				{#each faces.slice(0, SHOWN).reverse() as f (f.who.did)}<span class="face {f.stand}"
						><Avatar name={f.who.handle} did={f.who.did} src={f.who.avatar} bot={f.who.bot} /></span
					>{/each}
			</span>
			<b>{plural(faces.length, 'reviewer', 'reviewers')}</b>
			{#if tally}<span class="tally">{tally}</span>{/if}
		</summary>
		<div class="menu">
			<div class="menu-head">Reviewers</div>
			{#each reviews as r (r.uri)}
				<a class="menu-item reviewer" href={anchor(r)} onclick={() => (open = false)}>
					<Avatar name={r.reviewer.handle} did={r.reviewer.did} src={r.reviewer.avatar} />
					<span class="who"
						>{r.reviewer.handle}{#if r.reviewer.bot}<BotMark />{/if}{#if r.gate}<small
								>{base ? `Required reviewer for ${base}` : 'Required reviewer'}</small
							>{:else if !r.maintainer && r.verdict === 'approve'}<small
								>Not a collaborator, so not counted</small
							>{/if}</span
					>
					<span class="said {stand(r)}">{said[stand(r)]}</span>
					{#if ask && awaited.length}<span class="stop-room"></span>{/if}
				</a>
			{/each}
			{#each awaited as a (a.did)}
				<div class="menu-item reviewer">
					<Avatar name={a.handle} did={a.did} src={a.avatar} />
					<span class="who"
						>{a.handle}{#if a.bot}<BotMark />{/if}{#if gate(a.did)}<small
								>{base ? `Required reviewer for ${base}` : 'Required reviewer'}</small
							>{/if}</span
					>
					<span class="said awaited">{said.awaited}</span>
					{#if ask && gate(a.did)}<span class="stop-room"></span>{:else if ask}<button
							class="stop"
							type="button"
							aria-label="Stop asking {a.handle}"
							title="Stop asking"
							disabled={!!busy}
							aria-busy={busy === a.did}
							onclick={() => pick(a.did, true)}
							>{#if busy === a.did}<span class="spinner"></span>{:else}<Icon
									name="x"
								/>{/if}</button
						>{/if}
				</div>
			{/each}
			{#if ask && askable.length}
				<div class="menu-head sub">Ask to review</div>
				{#each askable as p (p.did)}
					<button
						class="menu-item"
						type="button"
						disabled={!!busy}
						onclick={() => pick(p.did, false)}
						><span class="tick"
							>{#if busy === p.did}<span class="spinner"></span>{:else}<Icon
									name="plus"
								/>{/if}</span
						><Avatar name={p.handle} did={p.did} src={p.avatar} /><span>{p.handle}</span></button
					>
				{/each}
			{/if}
		</div>
	</details>
{/if}