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