johnandersen777.bsky.social/knotless
git that lives in your Atmosphere account
Forked from chadtmiller.com/knotless
0
0
<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}