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

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

A pull opens as a draft, and its author makes it a draft again, from its page

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:web/pull-draft
docs/design/collaboration.md+9−6
@@ -75,7 +75,7 @@ so a comment on a pull opened a moment ago never waits for an index.
### Reviews
Reviews stand as GitHub's do. Each reviewer's latest comment with a verdict
Each reviewer's latest comment with a verdict
is where they stand; a comment without one changes nothing, and a later
verdict replaces the earlier. The pull's author reviews nothing of their
own: the client refuses, and readers pass over any such verdict, as they do
@@ -89,7 +89,7 @@ pull's merge box, and `knotless show` lists them as `review:` lines.
### Review agents
A review needs no person behind it. `knotless-agent review` is an account
that reviews the pulls asking it, as GitHub's review bots do: a command,
that reviews the pulls asking it: a command,
by default Claude Code (`knotless-claude-review`), reads each pull and
answers a verdict, a body and comments on lines, and the agent writes them
as its own review, with the head it read. It reviews a pull again when it
@@ -128,7 +128,7 @@ removes, otherwise the side it brings), the last `line` and, for more than
one, the `startLine`, counted in the file as it is on that side. Its
`commit` is the pull's head as it was then, which the lines are of.
Comments on lines go one of two ways, as on GitHub. Sent with a body or a
Comments on lines go one of two ways. Sent with a body or a
verdict, they are that review's: the review and each comment, naming the
review in `review`, are written in one `applyWrites`. With neither, each
stands alone. On the pages, the `+` on a line of the diff (shift-click a
@@ -179,7 +179,10 @@ they have not reviewed it; once it is merged or closed, no longer.
Both are fields of the pull record, so only its author sets them: when
opening it (`knotless pull --draft --reviewer <handle>`) and with `knotless
edit` (`--draft`, `--ready`, `--reviewer`, `--no-reviewer`). A maintainer
edit` (`--draft`, `--ready`, `--reviewer`, `--no-reviewer`). On the pages,
the author opens one as a draft with Open as draft, marks it
ready with the merge box's Ready for review, and makes it a draft again
with Convert to draft in the merge box's More menu. A maintainer
cannot ask someone to review another person's pull: that would need a
record of the maintainer's own, which knotless does not have yet.
@@ -437,7 +440,7 @@ One parser (`crates/knotless/src/query.rs`) reads the query everywhere:
items it matches; `knotless list -q`; and the MCP's `list_items`. The web
pages keep it in the address (`?q=`), so a filtered list can be linked.
Their Author and Labels menus, and a click on a label in a row, change the
query a word at a time, as GitHub's do, so the menus teach the words.
query a word at a time, so the menus teach the words.
## Finding everything said about a repository
@@ -542,7 +545,7 @@ Details:
## Reverting a pull
`knotless revert`, the MCP `revert` tool and a merged pull's Revert button
undo a pull as GitHub's button does: they never merge, they open a pull
undo a pull without merging: they never merge, they open a pull
that undoes it, to check and merge like any other. The owner and
collaborators may revert, as they may merge.
docs/design/merge-queue.md+1−2
@@ -127,8 +127,7 @@ A pull's own checks are its pre-flight: they say it builds and passes at
its head merged with the branch, but not what the batch's checks hold it
to on top of the others. A merge past the queue lands what only the
pull's own checks passed. A branch whose rule says `queue` (`knotless
protect <url> <branch> --queue`) takes pulls only through the queue,
as GitHub's "Require merge queue" does:
protect <url> <branch> --queue`) takes pulls only through the queue:
- A plain merge (`knotless merge`, MCP `merge`) queues the pull instead
and says so (`main takes pulls through the merge queue: queued, 1 of
docs/design/serve.md+19−22
@@ -733,8 +733,7 @@ comment, naming the asker's own reaction where there is an asker, and
never to what its owner hid, and a reaction the pages write is indexed at
once. The pages, `knotless react` and MCP's `react` share the code that
writes it: reacting the same way twice keeps the first, and taking it
back deletes every record of it. As on GitHub, a reaction notifies no
one.
back deletes every record of it. A reaction notifies no one.
On a private repository, a reaction is written in the reacting member's
repo in its space, as a comment is, so it names nothing to anyone else.
@@ -745,7 +744,7 @@ each look reads it, with the pulls and issues. `knotless show` and MCP's
### Landing and checks
A pull's page ends in a merge box, as GitHub's does. An open pull's
A pull's page ends in a merge box. An open pull's
checks show there, not in the timeline: how many are in each state, then
a row for each, those that failed or are running open and the rest
folded, each linked to its run's page, which has its log and the
@@ -808,7 +807,7 @@ refreshed, so the page it goes on to, whose empty state says how to push,
and the account's profile show it.
A repository's owner sees a Settings tab, at `settings`, with a side nav
of its sections, as GitHub's. General sets its description, links, topics
of its sections. General sets its description, links, topics
and default branch (`knotless describe`), renames it, shows what it takes up and
compacts it, and has a Danger zone to offer it to another account or
withdraw the offer, and to delete it once its full name is typed.
@@ -852,13 +851,12 @@ server, read from the owner's PDS once and checked against its release.
Logged in, someone opens an issue from the Issues list's New issue, its
body started from the repository's issue template, and a pull from the
Pulls list's New pull, or Compare & pull on a branch's files: as GitHub's
compare page does, it chooses the branch to merge into, then the branch to
Pulls list's New pull, or Compare & pull on a branch's files: it chooses the branch to merge into, then the branch to
bring in, from the repository or a fork of it, shows the commits and diff
it would bring (`getCompare`), with a button that unfolds the form to open
it, its body started from the pull template at that base (`getTemplate`).
Compare & pull links with `expand=1`, which shows the form at once, as
GitHub's banner does. Beside the form, as GitHub's is, a sidebar
Compare & pull links with `expand=1`, which shows the form at once.
Beside the form, a sidebar
chooses the labels to put on it, those the repository's viewer state says
the account may (`getRepo`'s `viewer.labels`), and for a pull the
reviewers to ask, from the owner and collaborators. Opening (`createIssue`,
@@ -867,8 +865,8 @@ pull` and `knotless label`, so a body that follows none of the template is
refused there too, and a label the account may not put on is refused
before anything is written.
A pull's or an issue's page is written on as the account logged in, as
GitHub's is: a comment at the end of the thread, and on someone else's
A pull's or an issue's page is written on as the account logged in:
a comment at the end of the thread, and on someone else's
open pull a review, Comment, Approve or Request changes. Each author edits
and deletes their own comments, and edits the title and body of what they
opened. Each write is a procedure on the server (`createComment`,
@@ -888,8 +886,8 @@ Reporting is in each comment's "…" menu, for anyone. Logged out, the
controls there open the login dialog. A hidden comment leaves the page
at once, with an Undo; a hidden pull or issue leaves with its page.
Each box written in Markdown (a new issue's or pull's body, a comment, a
review, an edit of a body or a comment) has Write and Preview tabs, as
GitHub's has. Preview asks `renderMarkdown`, which draws the text with the
review, an edit of a body or a comment) has Write and Preview tabs.
Preview asks `renderMarkdown`, which draws the text with the
renderer the thread's comments are drawn with, so it shows what will be
shown once posted. It needs no login, reads nothing and fetches nothing,
and takes no more than a body may hold. It is a reading aid for the box
@@ -909,10 +907,9 @@ path and a line, which `dev.knotless.comment` cannot yet.
An account hears of what others did that concerns it: a star of one of
its repositories, a follow of it, a pull or an issue opened on a
repository it looks after, and a comment on one of its pulls or issues, or on one it
commented on, from after it did, as GitHub subscribes a thread's
participants; a pull, an issue or a comment that mentions it; and an
commented on, from after it did; a pull, an issue or a comment that mentions it; and an
invitation to an organization. It hears too of its own pulls' checks,
from the runners its repository names, as GitHub tells the one who pushed:
from the runners its repository names:
`checks-failed` when checks fail at a pull's latest run, and `ejected`
when the merge queue takes the pull out ([merge-queue.md](merge-queue.md)).
A
@@ -925,7 +922,7 @@ mentions the account, not a `mention` one.
The repositories an account looks after are its own, those whose records
name it a collaborator, and every repository of an organization it has
joined, in public or hidden, as GitHub tells a repository's maintainers.
joined, in public or hidden.
The index keeps who each repository's record names in a table, so that
asking does not read every record; the organizations an account joined
it reads from both indexes, as a hidden membership is only in the
@@ -1009,7 +1006,7 @@ the pull has merged or closed: there is nothing left to do about it. An ejection
`dev.knotless.queue` record turning the pull away, with the checks that
failed when it was for them. Neither is any account's doing, so hearing
only of accounts one follows leaves them in. The repository's owner
hears of neither for others' pulls, as GitHub tells only who pushed.
hears of neither for others' pulls: only the author does.
A browser the pages' settings turned it on in shows each new
notification as the system's own while a tab is open but out of sight:
@@ -1053,11 +1050,11 @@ few accounts.
### Settings
`/settings` is the logged-in account's, laid out as GitHub's are, with its
`/settings` is the logged-in account's, with its
sections down the side: its public profile, the addresses its commits show
as its by, its appearance, its notifications, its delegated writes, and
the accounts it blocks. A section of more than one page is a group, as
GitHub's are: Appearance's name opens it, to Themes and Make a theme, and
the accounts it blocks. A section of more than one page is a group:
Appearance's name opens it, to Themes and Make a theme, and
it stays open while one of them is the page; on a phone, where the
sections are a row of tabs, it is one tab. The profile is
its `dev.knotless.profile`, field by field over the Bluesky profile, with
@@ -1084,7 +1081,7 @@ here, and the pages refuse to queue it.
Appearance chooses the colours the pages are drawn in: the system's light
or dark, as with no choice, light or dark always, or a theme after an
editor's (Catppuccin, Dracula) or an app's of the Atmosphere (pckt,
plyr.fm). Or two, Sync with system, as GitHub calls it: a day theme
plyr.fm). Or two, Sync with system: a day theme
while the system is light and a night theme while it is dark, each
chosen in its own tab over the one list of themes, which marks each in
use with a sun or a moon. The browser
@@ -1111,7 +1108,7 @@ Anyone can make a theme of their own colours, and publish it for anyone to
choose. A `dev.knotless.theme` record holds one: a name, and a palette of
colours named for what they colour (`background`, `text`, `textMuted`,
`primary`, `accent`; the states' `success`, `danger`, `attention`, `done`
and `neutral`, as GitHub's Primer names them; and code's), each `#rrggbb`.
and `neutral`; and code's), each `#rrggbb`.
They are named by purpose, never by hue: a theme may draw success in blue,
for those who cannot tell red from green, and `green` would then lie. The
hue names are the pages' own tokens, which the palette is mapped onto
docs/guide/reviews.md+1−1
@@ -1,6 +1,6 @@
# Review agents
`knotless-agent review` reviews your pulls with a model. You ask its account for a review on a pull, and it reads the pull, writes a review with a verdict, a body and comments on lines, and reviews again when you push. It works as GitHub's review bots do, as an atproto account of its own. It ships with `knotless-claude-review`, which has Claude Code do the reading, and this guide sets that up; any other model works through [another command](#use-another-command).
`knotless-agent review` reviews your pulls with a model. You ask its account for a review on a pull, and it reads the pull, writes a review with a verdict, a body and comments on lines, and reviews again when you push. It is an atproto account of its own. It ships with `knotless-claude-review`, which has Claude Code do the reading, and this guide sets that up; any other model works through [another command](#use-another-command).
## What you need
harness/e2e/flows.spec.mjs+25−4
@@ -160,7 +160,7 @@ test('opening a pull from a fork', async () => {
await page.getByLabel('From').selectOption({ label: 'bob.test/tally (yours)' })
await page.getByLabel('Branch').selectOption('main')
await expect(page.locator('.compared')).toContainText('Release from tags when asked')
// The form unfolds once asked for, as GitHub's compare page does.
// The form unfolds once asked for.
await expect(page.getByRole('textbox', { name: 'Title' })).toHaveCount(0)
await page.locator('.compare-ready').getByRole('button', { name: 'Open pull' }).click()
await page.getByRole('textbox', { name: 'Title' }).fill('Bring in the fork: usage, totals and releases')
@@ -169,15 +169,20 @@ test('opening a pull from a fork', async () => {
await page.locator('.reviewer-menu').getByRole('button', { name: 'alice.test' }).click()
await page.locator('.reviewer-menu summary').click()
await expect(page.locator('.side-people')).toContainText('alice.test')
await page.getByRole('button', { name: 'Open pull' }).click()
// Opened as a draft.
await page.getByRole('button', { name: 'Open as draft' }).click()
await page.waitForURL(/\/alice\.test\/tally\/pull\//)
proposed = page.url()
await expect(page.getByRole('heading', { name: 'Bring in the fork: usage, totals and releases' })).toBeVisible()
const pulls = await records('bob', 'dev.knotless.pull')
const made = pulls.find((r) => r.value.title === 'Bring in the fork: usage, totals and releases')
await expect(page.locator('.item-meta .pill')).toHaveText('Draft')
const made = await fork()
expect(made?.value.reviewers?.map((r) => r.did)).toContain(who.alice.did)
expect(made?.value.draft).toBe(true)
})
/** bob's pull from his fork, as his PDS holds it. */
const fork = async () => (await records('bob', 'dev.knotless.pull')).find((r) => r.value.title === 'Bring in the fork: usage, totals and releases')
test('retargeting a pull', async () => {
await go(page, proposed)
await page.locator('.merge-box .more-menu summary').click()
@@ -190,6 +195,22 @@ test('retargeting a pull', async () => {
await anew(page, (expect) => expect(page.getByText(/wants to merge into main from bob\.test\/tally:words$/)).toBeVisible())
})
test('marking a draft ready, and making it a draft again', async () => {
await go(page, proposed)
const box = page.locator('.merge-box')
await box.getByRole('button', { name: 'Ready for review' }).click()
await expect(page.locator('.item-meta .pill')).toHaveText('Open')
expect((await fork())?.value.draft).toBeFalsy()
await box.locator('.more-menu summary').click()
await box.getByRole('button', { name: 'Convert to draft' }).click()
await expect(page.locator('.item-meta .pill')).toHaveText('Draft')
await expect(box.getByText('This pull is still a draft')).toBeVisible()
expect((await fork())?.value.draft).toBe(true)
// Ready once more, for what follows.
await box.getByRole('button', { name: 'Ready for review' }).click()
await anew(page, (expect) => expect(page.locator('.item-meta .pill')).toHaveText('Open'))
})
test('rerunning a check', async () => {
// bob runs his fork's checks: the failed test, asked for again.
await go(page, world.views.check)
web/src/lib/organisms/MergeBox.svelte+13−5
@@ -31,13 +31,14 @@
* 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 edit
* --ready`; those it `requested` to review it show with the reviews.
* --ready`, and its author makes one a draft again from the More
* menu; those it `requested` to review it show with the reviews.
*/
let { repo, pull, base, head, queue, reviews = [], requested = [], draft = false, checks, minutes = [], rerun, in: logged }: { repo: RepoViewDetailed; pull: string; base: string; head?: PullHeadView; queue?: QueueStateView; reviews?: ReviewView[]; requested?: ActorView[]; 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', 'leave', 'ready' or 'retarget'.
// Which is under way: 'merge', 'queue', 'leave', 'ready', 'draft' or 'retarget'.
let busy = $state('');
let keep = $state(false);
let merged = $state('');
@@ -82,7 +83,8 @@
});
const enqueue = () => doing('queue', () => call<typeof queuePull.main>('dev.knotless.queuePull', one));
const leave = () => doing('leave', () => call<typeof leaveQueue.main>('dev.knotless.leaveQueue', one));
const ready = () => doing('ready', () => call<typeof editItem.main>('dev.knotless.editItem', { repo: repo.uri, subject: pull as AtUriString, draft: false }));
/** 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();
@@ -164,7 +166,7 @@
<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}>Ready for review</button>{/if}
{#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}
@@ -183,7 +185,13 @@
{#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}>Retarget…</button></div>
<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>
web/src/lib/organisms/OpenForm.svelte+12−7
@@ -2,25 +2,29 @@
import type { Image } from '#lib/lexicons/dev/knotless/defs.ts';
import { untrack } from 'svelte';
import type { AtUriString } from '@atproto/lex';
import Icon from '#lib/atoms/Icon.svelte';
import MarkdownField from '#lib/molecules/MarkdownField.svelte';
/**
* Opening a pull or an issue: its title, from `title`, and its body in
* Markdown, from `text`, as the repository's template asks. `send` is
* given both, with the pictures uploaded into the body, and answers why it failed, in the server's words, or
* nothing. Its preview resolves relative links inside `repo`. Anyone
* not `in` has no button to send it.
* not `in` has no button to send it. With `draft`, a second button
* of that name sends it as a draft.
*/
let { in: logged, label, title = '', text = '', send, repo }: { in: boolean; label: string; title?: string; text?: string; send: (title: string, body: string, images?: Image[]) => Promise<string | undefined>; repo?: AtUriString } = $props();
let { in: logged, label, draft, title = '', text = '', send, repo }: { in: boolean; label: string; draft?: string; title?: string; text?: string; send: (title: string, body: string, images?: Image[], draft?: boolean) => Promise<string | undefined>; repo?: AtUriString } = $props();
let heading = $state(untrack(() => title));
let body = $state(untrack(() => text));
let images = $state<Image[]>([]);
let busy = $state(false);
/** Which button sent it, while it is under way: 'open' or 'draft'. */
let busy = $state('');
let problem = $state('');
async function submit(e: SubmitEvent) {
e.preventDefault();
busy = true;
problem = (await send(heading, body, images.length ? images : undefined)) ?? '';
busy = false;
const drafted = (e.submitter as HTMLButtonElement | null)?.value === 'draft';
busy = drafted ? 'draft' : 'open';
problem = (await send(heading, body, images.length ? images : undefined, drafted)) ?? '';
busy = '';
}
</script>
@@ -29,6 +33,7 @@
<MarkdownField label="Description" placeholder="What it is about, in Markdown" rows={14} {repo} bind:value={body} bind:images />
{#if problem}<p class="problem" role="alert">{problem}</p>{/if}
<div class="composer-end">
{#if logged}<button class="btn primary" type="submit" disabled={busy || !heading.trim()} aria-busy={busy}>{label}</button>{/if}
{#if logged && draft}<button class="btn" type="submit" value="draft" disabled={!!busy || !heading.trim()} aria-busy={busy === 'draft'}><Icon name="pullDraft" />{draft}</button>{/if}
{#if logged}<button class="btn primary" type="submit" value="open" disabled={!!busy || !heading.trim()} aria-busy={busy === 'open'}>{label}</button>{/if}
</div>
</form>
web/src/lib/organisms/OpenSide.svelte+1−1
@@ -7,7 +7,7 @@
import Person from '#lib/molecules/Person.svelte';
import ReviewerPicker from '#lib/molecules/ReviewerPicker.svelte';
/**
* Beside the form that opens a pull or an issue, as GitHub's is: the
* Beside the form that opens a pull or an issue: the
* labels to put on it, of those `offered`, and with `people` the
* reviewers to ask, from them. What is chosen is bound, `labels` by
* name and `reviewers` by DID, for the form to send as it opens.
web/src/lib/organisms/SettingsLabels.svelte+1−1
@@ -11,7 +11,7 @@
/**
* The labels the repository `uri` names defines, for its pulls and
* issues to carry, listed: each made or changed in a dialog of its own,
* as GitHub's are, and taken away, as `knotless labels add` and
* and taken away, as `knotless labels add` and
* `remove` do.
*/
let { uri, labels }: { uri: AtUriString; labels: LabelDef[] } = $props();
web/src/routes/[authority]/[repo]/pulls/new/+page.svelte+4−4
@@ -22,7 +22,7 @@
const people = $derived([r.owner, ...(r.collaborators ?? [])].filter((p) => p.did !== me));
let labels = $state<string[]>([]);
let reviewers = $state<(typeof r.owner.did)[]>([]);
/** Whether the form to open it shows: once asked for, as GitHub's compare page unfolds it, or at once from Compare & pull. */
/** Whether the form to open it shows: once asked for, or at once from Compare & pull. */
let asked = $state(false);
const writing = $derived(asked || data.expand);
const uriOf = (h: { did: string; name: string }) => `at://${h.did}/dev.knotless.repo/${h.name}`;
@@ -37,8 +37,8 @@
if (e.currentTarget.name === 'head') (form.elements.namedItem('branch') as HTMLSelectElement).value = '';
form.requestSubmit();
}
async function open(title: string, body: string, images?: Image[]) {
const input = { repo: r.uri, base: data.base, head: data.head, branch: data.branch, title, body: body || undefined, images, labels: labels.length ? labels.map((name) => ({ name })) : undefined, reviewers: reviewers.length ? reviewers.map((did) => ({ did })) : undefined };
async function open(title: string, body: string, images?: Image[], draft?: boolean) {
const input = { repo: r.uri, base: data.base, head: data.head, branch: data.branch, title, body: body || undefined, images, draft: draft || undefined, labels: labels.length ? labels.map((name) => ({ name })) : undefined, reviewers: reviewers.length ? reviewers.map((did) => ({ did })) : undefined };
const opened = await call<typeof createPull.main>('dev.knotless.createPull', input).catch((e: Error) => e);
if (opened instanceof Error) return opened.message;
await goto(itemHref(base, opened.uri));
@@ -73,7 +73,7 @@
{#if writing}
<div class="open-layout">
{#key `${data.head} ${data.branch} ${data.base}`}
<OpenForm repo={`at://${data.authority}/dev.knotless.repo/${r.name}`} in={!!me} label="Open pull" {title} text={data.template} send={open} />
<OpenForm repo={`at://${data.authority}/dev.knotless.repo/${r.name}`} in={!!me} label="Open pull" draft="Open as draft" {title} text={data.template} send={open} />
{/key}
{#if me}<OpenSide {offered} bind:labels {people} bind:reviewers />{/if}
</div>
web/src/routes/[authority]/[repo]/pulls/new/+page.ts+1−1