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