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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
526 lines · 19.2 KB Raw History
<script lang="ts">
	import type {
		ActorView,
		CheckView,
		MinutesView,
		PullHeadView,
		QueueStateView,
		RepoViewDetailed,
		ReviewView,
		StackView
	} from '#lib/lexicons/dev/knotless/defs.ts';
	import type * as editItem from '#lib/lexicons/dev/knotless/editItem.ts';
	import type * as getMergeStatus from '#lib/lexicons/dev/knotless/getMergeStatus.ts';
	import type * as leaveQueue from '#lib/lexicons/dev/knotless/leaveQueue.ts';
	import type * as listBranches from '#lib/lexicons/dev/knotless/listBranches.ts';
	import type * as mergePull from '#lib/lexicons/dev/knotless/mergePull.ts';
	import type * as queuePull from '#lib/lexicons/dev/knotless/queuePull.ts';
	import type * as retargetPull from '#lib/lexicons/dev/knotless/retargetPull.ts';
	import type * as setAutoMerge from '#lib/lexicons/dev/knotless/setAutoMerge.ts';
	import type { AtUriString } from '@atproto/lex';
	import type { Snippet } from 'svelte';
	import { invalidateAll } from '$app/navigation';
	import { ask, call, type Answer } from '#lib/data.ts';
	import { landsAt, landsIn, repoHref } from '#lib/format.ts';
	import { zone } from '#lib/zone.svelte.ts';
	import Icon from '#lib/atoms/Icon.svelte';
	import Ref from '#lib/atoms/Ref.svelte';
	import Command from '#lib/molecules/Command.svelte';
	import MergeChecks from '#lib/organisms/MergeChecks.svelte';
	import PullStack from '#lib/organisms/PullStack.svelte';
	import Reviewers from '#lib/organisms/Reviewers.svelte';
	import Dialog from '#lib/molecules/Dialog.svelte';
	/**
	 * The open pull `pull` on `repo`, into `base` from `head`, as GitHub's
	 * merge box shows one: where its `reviews` stand, its `checks`, then whether it merges with its
	 * base, then the buttons that land it, queue it and retarget it, each
	 * enabled for whoever may. All but the checks are asked of the server
	 * once the page is drawn, as `knotless pull merge` decides them, and shown
	 * only to whoever is `in`. `queue` is where it stands in the merge
	 * queue; `minutes`, its runners' for the owner; `rerun`, at the end of
	 * the checks' head. Whichever lands it next is the primary button:
	 * the queue while its checks have not passed against its base as it
	 * is now, a merge once it can; neither when nothing can land it. Into
	 * a branch that takes pulls only through the queue, there is no merge
	 * button: the queue lands it. A `draft` neither merges nor queues
	 * until its author marks it ready, here or with `knotless pull edit
	 * --ready`, and its author makes one a draft again from the More
	 * menu; those it `requested` to review it show with the reviews, and
	 * with `dismiss`, as the owner or a collaborator, each review can be
	 * dismissed from there.
	 */
	let {
		repo,
		pull,
		base,
		head,
		queue,
		reviews = [],
		requested = [],
		required = [],
		dismiss,
		draft = false,
		checks,
		minutes = [],
		rerun,
		stack,
		in: logged
	}: {
		stack?: StackView;
		repo: RepoViewDetailed;
		pull: string;
		base: string;
		head?: PullHeadView;
		queue?: QueueStateView;
		reviews?: ReviewView[];
		requested?: ActorView[];
		required?: ActorView[];
		dismiss?: (did: ActorView['did'], reason: string) => Promise<unknown>;
		draft?: boolean;
		checks: CheckView[];
		minutes?: MinutesView[];
		rerun?: Snippet;
		in: boolean;
	} = $props();
	type Status = Answer<typeof getMergeStatus.main>;
	let status = $state<Status | null>(null);
	let problem = $state('');
	// Which is under way: 'merge', 'queue', 'stack', 'leave', 'auto', 'cancel', 'ready', 'draft' or 'retarget'.
	let busy = $state('');
	let keep = $state(false);
	let merged = $state('');
	/** The branches it may target instead, once asked for. */
	let branches = $state<string[]>([]);
	const queued = $derived(queue?.state === 'queued');
	/** When it should land, while queued. */
	const landing = $derived(
		queue
			? landsAt(queue, queue.typical, queue.batchStartedAt, queue.aheadStartedAt, zone.now)
			: null
	);
	const retarget = $derived(`retarget-${pull.split('/').pop()}`);

	$effect(() => {
		if (!logged) return;
		const asked = { repo: repo.uri, pull: pull as AtUriString };
		ask<typeof getMergeStatus.main>('dev.knotless.getMergeStatus', asked).then(
			(s) => (status = s),
			(e: Error) => (problem = e.message)
		);
	});
	$effect(() => {
		if (!status?.may.base) return;
		ask<typeof listBranches.main>('dev.knotless.listBranches', {
			repo: repo.uri,
			limit: 1000
		}).then(
			(b) => (branches = b.branches.map((b) => b.name)),
			() => {}
		);
	});

	/** Does `act`, as `action`, then draws the page again, as it stands after; or says why not. */
	async function doing(action: string, act: () => Promise<unknown>) {
		busy = action;
		problem = '';
		try {
			await act();
			await invalidateAll();
		} catch (e) {
			problem = (e as Error).message;
		}
		busy = '';
	}
	const one = $derived({ repo: repo.uri, pull: pull as AtUriString });
	const merge = () =>
		doing('merge', async () => {
			const done = await call<typeof mergePull.main>('dev.knotless.mergePull', {
				...one,
				keepBranch: keep
			});
			merged = done.commit;
		});
	const enqueue = () =>
		doing('queue', () => call<typeof queuePull.main>('dev.knotless.queuePull', one));
	/** The open pulls under this one in its stack, lowest first. */
	const under = $derived(
		stack
			? stack.pulls
					.slice(stack.pulls.findIndex((p) => p.item.uri === pull) + 1)
					.filter((p) => p.item.state === 'open')
					.map((p) => p.item.uri)
					.reverse()
			: []
	);
	/** Queues those under it, then it, lowest first: each waits for the one below it, and they land in turn. */
	const enqueueStack = () =>
		doing('stack', async () => {
			for (const uri of [...under, pull as AtUriString])
				await call<typeof queuePull.main>('dev.knotless.queuePull', { repo: repo.uri, pull: uri });
		});
	const leave = () =>
		doing('leave', () => call<typeof leaveQueue.main>('dev.knotless.leaveQueue', one));
	/** Sets it to merge by itself once its checks pass, or with `undo` cancels that. */
	const auto = (undo = false) =>
		doing(undo ? 'cancel' : 'auto', () =>
			call<typeof setAutoMerge.main>('dev.knotless.setAutoMerge', { ...one, undo })
		);
	/** Whether it is set to merge by itself once its checks pass, and that stands. */
	const set = $derived(status?.autoMerge?.state === 'set');
	/** Whether merge when checks pass is offered: it cannot merge now, and may be set to once it can. */
	const offer = (s: Status) =>
		!!s.may.autoMerge && !set && !queued && !draft && s.state !== 'clean';
	/** Marks it ready for review, or with `again` a draft once more. */
	const ready = (again = false) =>
		doing(again ? 'draft' : 'ready', () =>
			call<typeof editItem.main>('dev.knotless.editItem', {
				repo: repo.uri,
				subject: pull as AtUriString,
				draft: again
			})
		);
	/** Points it at the branches the form names, those that changed. */
	async function point(e: SubmitEvent) {
		e.preventDefault();
		const form = new FormData(e.currentTarget as HTMLFormElement);
		const to = String(form.get('base') ?? base);
		const from = String(form.get('head') ?? '').trim();
		const change = {
			...one,
			base: to !== base ? to : undefined,
			head: from && from !== head?.branch ? from : undefined
		};
		if (!change.base && !change.head) return;
		await doing('retarget', () =>
			call<typeof retargetPull.main>('dev.knotless.retargetPull', change)
		);
		(document.getElementById(retarget) as HTMLElement | null)?.hidePopover();
	}
	/** How it merges, in words. */
	const method = (s: Status) =>
		s.queueOnly
			? `${base} takes pulls only through the merge queue. It checks this one against ${base} as it is now and lands it as one commit.`
			: ((<Record<string, string>>{
					fastForward: `It fast-forwards ${base}.`,
					merge: `It merges into ${base} with a merge commit.`,
					squash: `${base} is protected as linear: it is squashed into one commit.`
				})[s.method] ?? '');
	/** Bringing its base into its branch in a checkout, to resolve what conflicts. */
	const resolve = $derived(
		`git checkout ${head?.branch}\ngit fetch knotless://${repo.owner.handle}/${repo.name} ${base}\ngit merge FETCH_HEAD`
	);
	/**
	 * Whether the queue, not a merge, is what lands it next: its checks are
	 * running, or passed only against a base that has since moved, which
	 * the queue checks it against without being asked; or its branch takes
	 * pulls only through the queue, and its checks have not failed.
	 */
	const queueFirst = (s: Status) =>
		lands(s) &&
		!queued &&
		s.state !== 'conflicting' &&
		s.reviews !== 'blocked' &&
		(s.queueOnly ? s.checks !== 'failed' : s.checks === 'pending' || s.checks === 'running');
	/** Whether queueing it would land it: it may be queued, and the queue lands, its owner having delegated writes where it lands through. */
	const lands = (s: Status) => s.may.queue && !s.queueBlocked;
	/** What it waits for before it merges, and what to do about it, when its checks hold it. */
	function next(s: Status) {
		if (set && !s.may.merge) return s.may.head ? 'A push from you cancels it.' : '';
		if (set && s.state === 'conflicting')
			return 'It stays set. Once it merges cleanly and its checks pass, it merges.';
		if (set && s.checks === 'failed')
			return 'It stays set. Fix what failed and push. It merges once its checks pass.';
		if (set) return '';
		if (queued || s.state === 'conflicting' || s.state === 'draft' || !s.may.merge) return '';
		if (s.reviews === 'blocked') return `It merges once reviewed as ${base} asks.`;
		if (s.queueOnly && !s.may.queue)
			return 'No runner lands its merge queue yet: the owner names one under Actions in its settings.';
		if (s.queueOnly)
			return s.checks === 'failed'
				? 'The queue takes it once its checks pass: fix what failed and push.'
				: '';
		const self = s.may.rerun
			? 'To merge it yourself, re-run its checks above.'
			: `To merge it yourself, merge ${base} into its branch and push, which runs its checks again.`;
		if (s.checks === 'pending')
			return lands(s)
				? `The merge queue checks it against ${base} as it is now, then lands it. ${self}`
				: `Its checks must pass against ${base} as it is now first. ${self}`;
		if (s.checks === 'running')
			return lands(s)
				? 'It merges once its checks pass. Queued now, it lands then.'
				: s.may.autoMerge
					? 'Its checks are still running. Set it to merge by itself once they pass.'
					: 'Its checks are still running.';
		if (s.checks === 'failed') return 'Fix what failed and push. It merges once its checks pass.';
		return '';
	}
	/** Its checks show once there are some, or the server says some wait. */
	const checked = $derived(
		checks.length > 0 || (!!status && status.checks !== 'none' && status.checks !== 'passed')
	);
</script>

{#if logged || checks.length || reviews.length || requested.length}
	<div class="box merge-box">
		{#if !merged}<Reviewers {pull} {reviews} {requested} {required} {dismiss} />{/if}
		{#if merged}
			<div class="row clean"><Icon name="merge" /><b>{`Merged as ${merged.slice(0, 7)}`}</b></div>
		{:else}
			{#if queued && queue}<div class="row running">
					<Icon name="queue" />
					<div class="grow">
						<b>{queue.said}</b>
						<p class="mute">
							{landing === null
								? 'A runner lands it once its batch passes its checks.'
								: `It ${landsIn(landing)}, once its batch passes its checks.`}
						</p>
					</div>
				</div>{/if}
			{#if status?.autoMerge}
				{@const a = status.autoMerge}
				{#if a.state === 'set'}<div class="row auto-set">
						<Icon name="merge" />
						<div class="grow">
							<b>Merges when checks pass</b>
							<p class="mute">
								{`Set by ${a.by.handle}. It merges into ${base} once its checks pass.`}
							</p>
						</div>
					</div>{:else}<div class="row running">
						<Icon name="merge" />
						<div class="grow">
							<b>Merge cancelled</b>
							<p class="mute">
								{a.state === 'pushed'
									? `${a.pushedBy?.handle ?? 'Someone'} pushed to ${head?.branch ?? 'its branch'} after ${a.by.handle} set it.${status.may.autoMerge ? ' Set it again to merge this head.' : ''}`
									: `It was retargeted after ${a.by.handle} set it.`}
							</p>
						</div>
					</div>{/if}
			{/if}
			{#if checked}<MergeChecks
					{checks}
					{base}
					standing={status?.checks}
					{minutes}
					end={rerun}
				/>{/if}
		{/if}
		{#if stack && !merged}
			<div class="row">
				<Icon name="stack" />
				<div class="grow">
					<b>{`Part of a stack of ${stack.pulls.length}`}</b
					>{#if under.length && !queued && status?.may.queue}<p class="mute">
							{`Add this alone and it lands on ${base}. Add all ${under.length + 1} and they land on ${stack.into ?? base} in turn, lowest first.`}
						</p>{:else if queued && status?.may.queue}<p class="mute">
							Leaving the queue takes any pull waiting for this one with it.
						</p>{/if}
				</div>
			</div>
			<PullStack {stack} current={pull} base={repoHref(repo.owner.handle, repo.name)} />
		{/if}
		{#if logged && !merged && status}
			{@const s = status}
			{#if s.state === 'conflicting'}
				<div class="row conflicting">
					<Icon name="failed" />
					<div class="grow">
						<b>This branch has conflicts that must be resolved</b>
						<p class="mute">
							{`Merge ${base} into ${head?.branch ?? 'its branch'}, resolve the files that conflict, commit and push.`}
						</p>
						{#if s.conflicts?.length}
							<p class="conflicts-head">Conflicting files</p>
							<ul class="conflicts">
								{#each s.conflicts as c (c.path)}<li>
										<a href="#file-{c.path}">{c.path}</a>
									</li>{/each}
							</ul>
						{/if}
						{#if head}<details class="how">
								<summary>From the command line</summary><Command text={resolve} />
							</details>{/if}
					</div>
				</div>
			{:else}
				{#if s.reviews === 'blocked'}
					<div class="row failed">
						<Icon name="failed" />
						<div class="grow">
							<b>Review required</b>
							<p class="mute">{`Pulls into ${base} need these reviews before they merge.`}</p>
							<ul class="missing">
								{#each s.reviewsMissing ?? [] as m (m.text)}<li>
										{m.text.charAt(0).toUpperCase() + m.text.slice(1)}
									</li>{/each}
							</ul>
						</div>
					</div>
				{/if}
				<div class="row clean">
					<Icon name="passed" />
					<div class="grow">
						<b>{`No conflicts with ${base}`}</b>
						<p class="mute">{method(s)}</p>
					</div>
				</div>
			{/if}
			{#if draft}
				<div class="row draft">
					<Icon name="pullDraft" />
					<div class="grow">
						<b>This pull is still a draft</b>
						<p class="mute">
							{s.may.head
								? 'It cannot be merged or queued until you mark it ready for review.'
								: 'It cannot be merged or queued until its author marks it ready for review.'}
						</p>
					</div>
					{#if s.may.head}<button
							class="btn"
							type="button"
							disabled={!!busy}
							aria-busy={busy === 'ready'}
							onclick={() => ready()}
							>{busy === 'ready' ? 'Marking ready…' : 'Ready for review'}</button
						>{/if}
				</div>
			{/if}
			{@const first = queueFirst(s) && !draft}
			{@const said = next(s)}
			<div class="actions">
				{#if said}<p class="next mute">{said}</p>{/if}
				{#if s.queueBlocked && !queued && s.may.force}<p class="next mute">
						To use the merge queue, delegate writes to this server in <a href="/settings/writes"
							>your settings</a
						>.
					</p>{:else if s.queueBlocked && !queued}<p class="next mute">
						The merge queue is off until the owner delegates writes to this server.
					</p>{/if}
				{#if offer(s)}<button
						class="btn primary"
						type="button"
						disabled={!!busy}
						aria-busy={busy === 'auto'}
						onclick={() => auto()}
						><Icon name="merge" />{busy === 'auto' ? 'Setting…' : 'Merge when checks pass'}</button
					>{/if}
				{#if set && (s.may.merge || s.may.head)}<button
						class="btn"
						type="button"
						disabled={!!busy}
						aria-busy={busy === 'cancel'}
						onclick={() => auto(true)}>{busy === 'cancel' ? 'Cancelling…' : 'Cancel merge'}</button
					>{/if}
				{#if s.may.merge && !s.queueOnly}
					<button
						class={s.state === 'clean' && !queued ? 'btn primary' : 'btn'}
						type="button"
						disabled={!!busy || s.state !== 'clean'}
						aria-busy={busy === 'merge'}
						onclick={merge}
						><Icon name="merge" />{busy === 'merge' ? 'Merging…' : 'Merge pull'}</button
					>
				{:else if !s.may.merge}
					<span class="mute">Only the owner and collaborators merge it.</span>
				{/if}
				{#if s.may.queue && !queued}<button
						class={first ? 'btn primary' : 'btn'}
						type="button"
						aria-busy={busy === 'queue'}
						disabled={!!busy ||
							draft ||
							!lands(s) ||
							s.state === 'conflicting' ||
							s.checks === 'failed' ||
							s.reviews === 'blocked'}
						onclick={enqueue}
						><Icon name="queue" />{busy === 'queue' ? 'Adding…' : 'Add to merge queue'}</button
					>{/if}
				{#if s.may.queue && !queued && under.length}<button
						class="btn"
						type="button"
						aria-busy={busy === 'stack'}
						disabled={!!busy ||
							draft ||
							!lands(s) ||
							s.state === 'conflicting' ||
							s.checks === 'failed' ||
							s.reviews === 'blocked'}
						onclick={enqueueStack}
						><Icon name="stack" />{busy === 'stack'
							? 'Adding…'
							: `Add all ${under.length + 1} to merge queue`}</button
					>{/if}
				{#if s.may.queue && queued}<button
						class="btn"
						type="button"
						disabled={!!busy}
						aria-busy={busy === 'leave'}
						onclick={leave}>{busy === 'leave' ? 'Leaving…' : 'Leave queue'}</button
					>{/if}
				{#if s.may.merge && !s.queueOnly && s.deletes}<label class="keep"
						><input type="checkbox" bind:checked={keep} />Keep <Ref name={s.deletes} /></label
					>{/if}
				{#if s.may.base || s.may.head}
					<details class="popover more-menu end">
						<summary class="btn" aria-label="More"><Icon name="kebab" /></summary>
						<div class="menu">
							<button class="menu-item" type="button" popovertarget={retarget}
								><Icon name="branch" />Retarget…</button
							>
							{#if s.may.head && !draft}<button
									class="menu-item"
									type="button"
									disabled={!!busy}
									aria-busy={busy === 'draft'}
									onclick={(e) => {
										e.currentTarget.closest('details')?.removeAttribute('open');
										ready(true);
									}}><Icon name="pullDraft" />Convert to draft</button
								>{/if}
						</div>
					</details>
				{/if}
			</div>
			{#if s.may.base || s.may.head}
				<Dialog id={retarget} title="Retarget this pull">
					<form onsubmit={point}>
						{#if s.may.base}
							<label for="{retarget}-base">Base branch</label>
							<select id="{retarget}-base" name="base" value={base}>
								{#each branches.length ? branches : [base] as b (b)}<option value={b}>{b}</option
									>{/each}
							</select>
						{/if}
						{#if s.may.head}
							<label for="{retarget}-head">Head branch</label>
							<input
								id="{retarget}-head"
								name="head"
								value={head?.branch ?? ''}
								autocapitalize="none"
								spellcheck="false"
							/>
						{/if}
						<button
							class="btn primary"
							type="submit"
							disabled={!!busy}
							aria-busy={busy === 'retarget'}
							>{busy === 'retarget' ? 'Retargeting…' : 'Retarget'}</button
						>
					</form>
				</Dialog>
			{/if}
		{:else if logged && !merged && !problem}
			<div class="row">
				<Icon name="running" /><span class="mute">Checking whether it can merge…</span>
			</div>
		{/if}
		{#if problem}<p class="problem" role="alert">{problem}</p>{/if}
	</div>
{/if}