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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
419 lines · 14.3 KB Raw History
<script lang="ts">
	import type { ActorView, RepoViewDetailed } from '#lib/lexicons/dev/knotless/defs.ts';
	import type * as getProfile from '#lib/lexicons/dev/knotless/getProfile.ts';
	import type { Reviews, Rule } from '#lib/lexicons/dev/knotless/repo.ts';
	import type * as createBranch from '#lib/lexicons/dev/knotless/createBranch.ts';
	import type * as deleteBranch from '#lib/lexicons/dev/knotless/deleteBranch.ts';
	import type * as protectBranch from '#lib/lexicons/dev/knotless/protectBranch.ts';
	import type * as renameBranch from '#lib/lexicons/dev/knotless/renameBranch.ts';
	import type * as unprotectBranch from '#lib/lexicons/dev/knotless/unprotectBranch.ts';
	import { invalidateAll } from '$app/navigation';
	import { ask, call } from '#lib/data.ts';
	import { repoHref, urlPath } from '#lib/format.ts';
	import type { AtIdentifierString, DidString } from '@atproto/lex';
	import Avatar from '#lib/atoms/Avatar.svelte';
	import Icon from '#lib/atoms/Icon.svelte';
	import Empty from '#lib/molecules/Empty.svelte';
	import HandleInput from '#lib/molecules/HandleInput.svelte';
	import Person from '#lib/molecules/Person.svelte';
	/**
	 * The branches of `repo`, owned as `authority` names it, the default
	 * first: made, renamed and deleted as `knotless branch` does, and held
	 * by `rules`, the protected ones, as `knotless branch protect` holds them.
	 * `gates` are the accounts the rules name as gates, to show by handle.
	 * `landers`, whether it names a runner, which the merge queue needs.
	 */
	let {
		repo,
		authority,
		branches,
		defaultBranch,
		rules,
		gates = [],
		landers = false
	}: {
		repo: RepoViewDetailed;
		authority: string;
		branches: string[];
		defaultBranch?: string;
		rules: Rule[];
		gates?: ActorView[];
		landers?: boolean;
	} = $props();
	const rule = (name: string) => rules.find((r) => r.branch === name);
	/** The account a rule names as a gate by `did`, as the settings show it. */
	const gate = (did: string): ActorView =>
		gates.find((g) => g.did === did) ?? ({ did, handle: did } as ActorView);
	// What is typed to add as a gate, by branch.
	let adding = $state<Record<string, string>>({});
	let name = $state('');
	let from = $state('');
	let renaming = $state('');
	let to = $state('');
	let merges = $state(false);
	let problem = $state('');
	// What is under way: 'create', or what is done to which branch.
	let busy = $state('');
	/** Does `act`, as `action`, then reads the settings again; or says why it could not. */
	async function doing(action: string, act: () => Promise<unknown>) {
		busy = action;
		problem = '';
		try {
			await act();
			await invalidateAll();
			return true;
		} catch (err) {
			problem = (err as Error).message;
			return false;
		} finally {
			busy = '';
		}
	}
	async function create(e: SubmitEvent) {
		e.preventDefault();
		const made = () =>
			call<typeof createBranch.main>('dev.knotless.createBranch', {
				repo: repo.uri,
				name,
				from: from || undefined
			});
		if (await doing('create', made)) name = '';
	}
	async function rename(e: SubmitEvent, branch: string) {
		e.preventDefault();
		const moved = () =>
			call<typeof renameBranch.main>('dev.knotless.renameBranch', {
				repo: repo.uri,
				name: branch,
				to
			});
		if (await doing(`rename ${branch}`, moved)) renaming = '';
	}
	function remove(branch: string) {
		if (!confirm(`Delete the branch ${branch}?`)) return;
		doing(`delete ${branch}`, () =>
			call<typeof deleteBranch.main>('dev.knotless.deleteBranch', { repo: repo.uri, name: branch })
		);
	}
	function protect(branch: string) {
		doing(`protect ${branch}`, () =>
			call<typeof protectBranch.main>('dev.knotless.protectBranch', {
				repo: repo.uri,
				branch,
				merges
			})
		);
	}
	/** Protects `held`'s branch again as it is, but for what `change` says: whether it takes merge commits, whether it takes pulls only through the merge queue, and what it asks of their reviews. */
	function again(held: Rule, change: { merges?: boolean; queue?: boolean; reviews?: Reviews }) {
		const reviews = { ...held.reviews, ...change.reviews };
		const asks = !!(
			reviews.approvals ||
			reviews.codeOwners ||
			reviews.dismissStale ||
			reviews.gates?.length
		);
		const rule = {
			repo: repo.uri,
			branch: held.branch,
			merges: change.merges ?? held.history === 'merges',
			queue: change.queue ?? !!held.queue,
			reviews: asks ? reviews : undefined
		};
		doing(`rule ${held.branch}`, () =>
			call<typeof protectBranch.main>('dev.knotless.protectBranch', rule)
		);
	}
	/** Names whoever is typed for `held`'s branch as a gate of its rule, found by handle or DID. */
	async function addGate(e: SubmitEvent, held: Rule) {
		e.preventDefault();
		const who = (adding[held.branch] ?? '').trim();
		const named = held.reviews?.gates ?? [];
		const added = async () => {
			const found = await ask<typeof getProfile.main>('dev.knotless.getProfile', {
				actor: who as AtIdentifierString
			});
			if (named.some((g) => g.did === found.did)) return;
			const reviews = { ...held.reviews, gates: [...named, { did: found.did }] };
			await call<typeof protectBranch.main>('dev.knotless.protectBranch', {
				repo: repo.uri,
				branch: held.branch,
				merges: held.history === 'merges',
				queue: !!held.queue,
				reviews
			});
		};
		if (await doing(`gate ${held.branch}`, added)) adding[held.branch] = '';
	}
	/** Takes the gate `did` out of `held`'s rule. */
	function removeGate(held: Rule, did: DidString) {
		const left = (held.reviews?.gates ?? []).filter((g) => g.did !== did);
		const reviews = { ...held.reviews, gates: left };
		const asks = !!(reviews.approvals || reviews.codeOwners || reviews.dismissStale || left.length);
		const rule = {
			repo: repo.uri,
			branch: held.branch,
			merges: held.history === 'merges',
			queue: !!held.queue,
			reviews: asks ? reviews : undefined
		};
		doing(`ungate ${held.branch} ${did}`, () =>
			call<typeof protectBranch.main>('dev.knotless.protectBranch', rule)
		);
	}
	/** How many approvals a branch may ask for, as the select offers them. */
	const counts = [0, 1, 2, 3, 4, 5, 6];
	function unprotect(branch: string) {
		doing(`unprotect ${branch}`, () =>
			call<typeof unprotectBranch.main>('dev.knotless.unprotectBranch', { repo: repo.uri, branch })
		);
	}
</script>

<section class="setting">
	<h2>Branches</h2>
	<form class="form inline" onsubmit={create}>
		<label for="branch-name">New branch</label>
		<div class="owned">
			<input
				id="branch-name"
				name="name"
				maxlength="255"
				placeholder="topic"
				autocapitalize="none"
				spellcheck="false"
				required
				bind:value={name}
			/>
			<select aria-label="From" bind:value={from}>
				<option value="">from {defaultBranch || 'the default branch'}</option>
				{#each branches.filter((b) => b !== defaultBranch) as b (b)}<option value={b}
						>from {b}</option
					>{/each}
			</select>
			<button class="btn" type="submit" disabled={!!busy} aria-busy={busy === 'create'}
				>{busy === 'create' ? 'Creating…' : 'Create'}</button
			>
		</div>
	</form>
	{#if problem}<p class="problem" role="alert">{problem}</p>{/if}
	{#if branches.length}
		<ul class="box list rows">
			{#each branches as b (b)}
				{@const held = rule(b)}
				<li>
					<span class="row-main"
						><Icon name="branch" /><a href={repoHref(authority, repo.name, `/tree/${urlPath(b)}`)}
							>{b}</a
						>{#if b === defaultBranch}<span class="count">Default</span>{/if}{#if held}<span
								class="count"
								title={held.queue
									? 'Pulls land on it only through the merge queue'
									: 'Through the gateway it only moves forward and is never deleted'}
								>{held.queue
									? 'protected, queue only'
									: held.history === 'merges'
										? 'protected'
										: 'protected, squash only'}</span
							>{/if}</span
					>
					<span class="row-end">
						{#if held}
							<button
								class="btn"
								type="button"
								disabled={!!busy}
								aria-busy={busy === `unprotect ${b}`}
								onclick={() => unprotect(b)}
								>{busy === `unprotect ${b}` ? 'Unprotecting…' : 'Unprotect'}</button
							>
						{:else}
							<button
								class="btn"
								type="button"
								disabled={!!busy}
								aria-busy={busy === `protect ${b}`}
								onclick={() => protect(b)}
								>{busy === `protect ${b}` ? 'Protecting…' : 'Protect'}</button
							>
						{/if}
						{#if b !== defaultBranch}
							<button
								class="btn"
								type="button"
								disabled={!!busy}
								onclick={() => ((renaming = renaming === b ? '' : b), (to = b))}>Rename</button
							>
							<button
								class="btn danger"
								type="button"
								disabled={!!busy}
								aria-busy={busy === `delete ${b}`}
								onclick={() => remove(b)}>{busy === `delete ${b}` ? 'Deleting…' : 'Delete'}</button
							>
						{/if}
					</span>
					{#if held}
						<div class="branch-rule">
							<section class="rule-group">
								<h3>Merging</h3>
								<label class="check"
									><input
										type="checkbox"
										checked={!!held.queue}
										disabled={!!busy || (!held.queue && !landers)}
										onchange={(e) => again(held, { queue: e.currentTarget.checked })}
									/><span
										><b>Require the merge queue</b><span class="hint"
											>{#if held.queue || landers}Pulls land through the queue, checked together in
												batches. The merge button goes away.{:else}A runner lands the queue. <a
													href={repoHref(authority, repo.name, '/settings/actions')}
													>Name one under Actions</a
												> to turn this on.{/if}</span
										></span
									></label
								>
								<label class="check"
									><input
										type="checkbox"
										checked={held.history !== 'merges'}
										disabled={!!busy}
										onchange={(e) => again(held, { merges: !e.currentTarget.checked })}
									/><span
										><b>Keep history linear</b><span class="hint"
											>Each pull is squashed into one commit, with no merge commits.</span
										></span
									></label
								>
							</section>
							<section class="rule-group">
								<h3>Reviews</h3>
								<div class="check">
									<label for="approvals-{b}"
										><b>Approvals required</b><span class="hint"
											>How many of the owner and collaborators must approve. The author never
											counts.</span
										></label
									><select
										id="approvals-{b}"
										class="field"
										value={held.reviews?.approvals ?? 0}
										disabled={!!busy}
										onchange={(e) =>
											again(held, { reviews: { approvals: Number(e.currentTarget.value) } })}
										>{#each counts as n (n)}<option value={n}>{n === 0 ? 'None' : n}</option
											>{/each}</select
									>
								</div>
								<label class="check"
									><input
										type="checkbox"
										checked={!!held.reviews?.codeOwners}
										disabled={!!busy}
										onchange={(e) =>
											again(held, { reviews: { codeOwners: e.currentTarget.checked } })}
									/><span
										><b>Require code owners</b><span class="hint"
											>Each path the pull changes needs an approval from its owner in CODEOWNERS.</span
										></span
									></label
								>
								<label class="check"
									><input
										type="checkbox"
										checked={!!held.reviews?.dismissStale}
										disabled={!!busy}
										onchange={(e) =>
											again(held, { reviews: { dismissStale: e.currentTarget.checked } })}
									/><span
										><b>Only approvals of the latest push count</b><span class="hint"
											>A new push needs approving again.</span
										></span
									></label
								>
								<form class="form required" onsubmit={(e) => addGate(e, held)}>
									<label for="gate-{b}"
										><b>Required reviewers</b><span class="hint"
											>Asked to review every pull into {b}, and the pull waits for them. They don't
											need to be collaborators, so a review bot fits here.
											<a href="/docs/guide/reviews/" data-sveltekit-reload>Set up a review agent</a
											></span
										></label
									>
									{#if held.reviews?.gates?.length}
										<ul class="gates">
											{#each held.reviews.gates as g (g.did)}
												{@const a = gate(g.did)}
												<li>
													<span class="row-main"
														><Avatar name={a.handle} did={a.did} src={a.avatar} /><Person
															handle={a.handle}
														/></span
													><button
														class="btn danger"
														type="button"
														disabled={!!busy}
														aria-busy={busy === `ungate ${b} ${g.did}`}
														onclick={() => removeGate(held, g.did)}
														>{busy === `ungate ${b} ${g.did}` ? 'Removing…' : 'Remove'}</button
													>
												</li>
											{/each}
										</ul>
									{/if}
									<div class="owned">
										<HandleInput
											id="gate-{b}"
											name="gate"
											autocomplete="off"
											bind:value={() => adding[b] ?? '', (v) => (adding[b] = v)}
										/><button
											class="btn"
											type="submit"
											disabled={!!busy}
											aria-busy={busy === `gate ${b}`}
											>{busy === `gate ${b}` ? 'Adding…' : 'Add'}</button
										>
									</div>
								</form>
							</section>
							{#if busy === `rule ${b}`}<span class="mute" role="status">Saving…</span>{/if}
						</div>
					{/if}
					{#if renaming === b}
						<form class="form row-form" onsubmit={(e) => rename(e, b)}>
							<label for="rename-{b}">New name</label>
							<div class="owned">
								<input
									id="rename-{b}"
									maxlength="255"
									autocapitalize="none"
									spellcheck="false"
									required
									bind:value={to}
								/><button
									class="btn primary"
									type="submit"
									disabled={!!busy || to === b}
									aria-busy={busy === `rename ${b}`}
									>{busy === `rename ${b}` ? 'Renaming…' : 'Rename'}</button
								>
							</div>
							<p class="hint">Open pulls into it are pointed at the new name.</p>
						</form>
					{/if}
				</li>
			{/each}
		</ul>
	{:else}
		<div class="box"><Empty title="No branches yet">The first push makes one.</Empty></div>
	{/if}
	<label class="check"
		><input type="checkbox" bind:checked={merges} />Branches protected from here on take merge
		commits</label
	>
	<p class="hint">
		A protected branch only moves forward and is never deleted, as collaborators push it through the
		gateway. The owner, who writes directly, is held only by their own client.
	</p>
</section>