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

johnandersen777.bsky.social/knotless

git that lives in your Atmosphere account

Forked from chadtmiller.com/knotless

0
0
986 lines · 52.4 KB Raw History
# Collaboration: pulls, issues, merging and moderation

A knotless repository is a set of records in its owner's atproto account
(see [storage.md](storage.md)). Those records live on the owner's PDS, the
server that holds and signs them, and only the owner can write there. The
conversation around a repository involves many people, and none of them can
write to the owner's account or to each other's.

So each person writes what they say into their own account, and readers
put the conversation together. State is then a record anyone may write but
readers believe only from some. Listing a repository's pulls means finding
records spread over many accounts. Merging writes in two places. Moderation
cannot delete what others wrote, so it decides what readers leave out.

## Each record lives with its author

Pulls, issues, comments, reviews and state changes are records in the
account of whoever wrote them. Nothing is written to the repository's own
records, so anyone who can read a repository can open a pull or issue on
it. The owner's hides and blocks are the exception: they live in the
owner's account, because only the owner's say counts for them.

For a public repository these records sit in each author's public repo. A
private repository lives in a space, a permissioned area of an account that
only its members may read (see [access.md](access.md)). There, each author
writes into their own repo inside the repository's space.

```mermaid
flowchart LR
  subgraph owner["Owner's account"]
    repo["dev.knotless.repo"]
    mod["dev.knotless.hide, block"]
  end
  subgraph author["Contributor's account"]
    pull["dev.knotless.pull"]
  end
  subgraph maint["Collaborator's account"]
    state["dev.knotless.comment, state"]
  end
  pull -- repo --> repo
  state -- subject --> pull
  mod -- subject --> pull
```

Every record names its repository with a `repoRef`: the repository
record's AT-URI (`at://<did>/<collection>/<rkey>`, one record's address) as
named when written, plus the repository's permanent `key`. Readers match on
the owner's DID in that URI and on `key`, never on the name, so a rename
does not orphan a repository's pulls, and a transfer carries them over.
[identity.md](identity.md) covers both.

Details:

- The forge collections are `pull`, `issue`, `comment`, `state`, `hide`,
  `block` and `queue`, under `dev.knotless`, each keyed by a TID, a
  time-ordered key its writer mints. `label` is the exception, keyed per
  item (see [Labels](#labels)). A runner's checks take part in no
  thread.

## Pulls, issues and comments

A pull (`dev.knotless.pull`) asks to merge a `head` branch into a `base`
branch of the target. The head names a repository, the target itself or a
fork, and a branch; opening a pull checks that both branches exist. A pull
is named by its AT-URI, which carries its author's DID, not the owner's. An
issue (`dev.knotless.issue`) is a title and an optional Markdown body.
Issues have no short numbers: they are named by URI or page URL.

A comment (`dev.knotless.comment`) names its `subject`, a pull or issue. A
comment on a pull with a `verdict`, `approve` or `changes`, is a review. A
review records the commit the head branch points at as it is written, read
from the head repository's ref record, so readers know which version was
reviewed. Commenting reads the subject straight from its author's account,
so a comment on a pull opened a moment ago never waits for an index.

### Reviews

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
a verdict this version does not know.

An approval is of the head it recorded. Once the pull moves, readers show it
as an approval of an earlier version. A request for changes stands until its
reviewer reviews again. The pages show where each reviewer stands under
the pull's title, as a mark on each face that opens to their verdicts, and
again in its merge box; `knotless pull show` lists them as `review:` lines.

### Dismissing a review

The owner or a collaborator dismisses a review that should no longer hold
the pull, as GitHub's maintainers do: `knotless pull dismiss-review <url> <pull>
<reviewer> <reason>`, MCP's `pull_dismiss_review`, or Dismiss review in the
menu beside the review in the pull's merge box. It writes a comment on the
pull, in their own account, whose `dismisses` names the review and whose
body is the reason, so the thread keeps who overruled whom and why.

Readers then mark the review dismissed, and it counts for nothing, for or
against the pull, until its reviewer reviews again: their next verdict
stands as any would. A comment naming a review from anyone else changes
nothing. A dismissal is no approval, so it works on a maintainer's own
pull, where their approval never counts: it is how the owner goes past a
review agent that asked for changes ([Gates](#gates)) without `--force`,
and the merge queue then lands the pull as any other.

### Review agents

A review needs no person behind it. `knotless-agent review` is an account
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
is pushed to, giving the command its earlier reviews, until it has asked
for changes `--rounds` times, and not again at a head it reviewed, or at
one whose changes are those it last reviewed, which keeps its verdict.
Only requests for changes count toward `--rounds`, since the limit is
there to end a pull argued back and forth: a pull it approved is
reviewed at each later head, and the command is given the head it
approved, to read what came since. After asking for changes, it reads
the pull again once its author changes the title or body, at that head
or the next, rather than keeping its verdict, since what it asked may be
answered in what the pull says, as a line a check reads. An approval
is not read again for an edit. What it reviewed it reads back
from its own records, so it keeps nothing else. Named an `--owner`, it
watches every repository of that account it may read: the public ones as
the owner's PDS lists them, and the private ones the owner shared with
it, by the invite sharing leaves at the appview
([Members and writers](access.md#members-and-writers)).

Each review spends its operator's model usage, and anyone may ask any
account for a review, so it reviews only pulls the owner, a collaborator
or a member of the owner's organization opened. Its approval counts for
nothing toward a branch's [required reviews](#required-reviews), as its
account may not write to the repository: it is advice, unless the
branch names it as a [gate](#gates), when pulls wait for its review and
its request for changes blocks them.

What a pull carries is written by its author, and a reviewer reading it
must not be steered by it into acting. So the model has nothing to act
with. Claude reads through `knotless-mcp --read-only`, which offers no
tool that writes whatever login it has, with none of Claude Code's own
(no shell, files or web), in an empty directory; it answers JSON in a
fixed shape, and the agent, not the model, writes the one review of that
pull. A pull that talks it round gets at most a misleading review of
itself. Its login is the agent's, so it reads the private repositories
shared with the agent, and its review stays with their comments.

A repository says what it wants of its reviews in `.knotless/review.md`,
read from the branch the pull merges into, as `.knotless/pull.md` and
`CODEOWNERS` are: never the pull's own, so a pull cannot change how it is
reviewed.
[Review agents](../guide/reviews.md) sets one up.

### Comments on lines

A comment on lines of a pull's diff is a `dev.knotless.comment` on the pull
with a `line`: the file's `path`, the `side` (`old` for lines the pull
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. 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
second to take the lines between) writes one: "Add single comment" sends
it, and "Start a review" keeps it in the browser until the "Review changes"
menu sends them all with what the review says. Over the command line and
MCP they are `--line` and `lines`.

Each starts a thread. An answer is a comment whose `reply` is the comment
starting it and whose `subject` is still the pull, so that readers that
know nothing of threads show it as a comment. An answer to an answer goes
in the same thread.

A thread is resolved by a `dev.knotless.state` record whose subject is the
comment starting it: `resolved`, or `open` to open it again. Readers take
the latest from that comment's author, the pull's author, the owner or a
collaborator, and leave such records out of the feed.

A comment on lines is outdated once that side of the file differs from
what it named: on the side the pull brings, the file at its head against
the file at the `commit` it recorded; on the side it removes, the file
where each leaves the base. The pages show an outdated thread only in the
conversation, under its quote of the lines as they were, and a current one
in the diff too. A comment sent with a review is heard of with the review,
not one notification each: the review's notification counts them, and
says when the review mentions whom it notifies.
MCP's `pull_show` gives `resolved` only on the comments of a thread on
lines, and marks `superseded` a review its reviewer's later one stands in
place of.

### Drafts and requested reviewers

A pull can be a draft (`draft` on its record): open, but not ready to merge.
A merge refuses a draft, even the owner's forced one, and so does the
merge queue, in the same words: `<pull> is a draft: its author marks it
ready with knotless pull edit <url> <pull> --ready`. A pull made a draft again
after it was queued waits out of line, and goes back to its place once it
is ready. Runners check drafts as they check any other pull, so the author
sees whether it passes while still working on it.

A pull can also ask accounts to review it (`reviewers`, each by DID, with
when it was asked). Each one asked hears of it as a `review-requested`
notification once the pull is not a draft, as of when it was asked or,
for a draft, when it was marked ready. The pages show those not yet
reviewed under the pull's title and in the merge box, beside the reviews. MCP's `pull_show` says of
each asked whether the request is `pending`: while the pull is open and
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 create --draft --reviewer <handle>`) and with `knotless
pull edit` (`--draft`, `--ready`, `--reviewer`, `--no-reviewer`). 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.

The owner and collaborators ask for reviews of someone else's pull in a
record of their own, the one they assign it with
([Assignees](#assignees)). A pull's reviewers are those its author asks,
then those the owner and collaborators ask, each once, and never its
author. Each asker stops asking only those it asked: the author in the
pull, and the owner and collaborators in their records, where the latest
of them to say so stands. `knotless pull ask-review` (MCP `pull_ask_review`) asks, and with
`--undo` stops asking, as whoever is asking. On the
pages, the author asks the owner and collaborators to review it as it
opens, from Add beside the buttons that open it, and once it is open, the author, the owner and collaborators ask
from its reviewers under the title (or Add, when it asks no one yet),
where they also stop asking one who has not reviewed it.

### Pictures

A pull's, an issue's or a comment's body shows a picture as Markdown does,
`![what it shows](link)`, linking to a blob in its author's account. The
record lists each such blob in `attachments` (`dev.knotless.defs#image`),
since a PDS deletes a blob no record names. `attachments` is an open
union, so a later kind, such as a file, can join pictures without
changing the record.

A picture is uploaded first, to the author's PDS, as
`com.atproto.repo.uploadBlob` takes it: pasted or dropped into a box on
the pages (`dev.knotless.uploadImage`), or named by `--image` on the
command line and `images` over MCP. A PNG, JPEG, WebP or GIF of at most
1,000,000 bytes goes as it is. A larger one is drawn again as a JPEG, at
most 2000 pixels on its longest side, at the best quality that fits, as
grain makes photos fit; a moving GIF keeps its first frame. Profile
pictures are made to fit the same way. Uploading gives the blob and its
`com.atproto.sync.getBlob` address on that PDS, which the body links to,
so any Markdown reader shows it.

A picture is also measured as it is uploaded, for the size it is drawn
at. A PNG that says its density in a `pHYs` chunk, as a Mac's Retina
screenshot says 144 pixels to the inch, counts as that many times 72, from
1x to 4x. A phone's screenshot taken at 3x then shows at the phone's
width rather than three times it. Any other picture counts as 1x. The
attachment keeps that `width` and `height`, in CSS pixels, and the pages
draw the picture at it, no wider than the column. `just look` marks its
phone pictures as 2x, so a pull's pictures from it come out the size
they were seen.

Writing the record attaches the uploaded pictures its body links to, by
CID, each once and 32 at most. An edit attaches what the new body links to,
of those it had and any uploaded for it, so a picture taken out of the
body is let go; kinds this client does not know are kept. A private
repository's pictures are uploaded the same way and attached by a record
in the author's repo in its space.

The pages show a picture its record attaches from the appview
(`dev.knotless.getAttachment`), not the author's PDS: a page never runs it,
and a private repository's, which its PDS hands only to members, shows to
whoever may read the repository. Links to anything else are drawn as
they are.

Anyone may react to a pull, an issue or a comment with one of GitHub's
eight emoji (`dev.knotless.reaction`, in the reacting account, naming its
subject by a strong ref). An account has one reaction per subject and
content, and the index counts them, leaving out the accounts the owner
blocked ([serve.md](serve.md#reactions)). On a private repository a
reaction is in the reacting member's repo in its space, as a comment is,
and counted from there.

Only an author changes what they wrote, since it is in their account. An
edit stamps `updatedAt` and rewrites the record as JSON, keeping fields the
client does not know. Authors may delete their own comments. Pulls and
issues are closed, not deleted, so what was said about them stays. The
web pages write all of this as the account logged in to them, through
the same code as the CLI ([serve.md](serve.md#pulls-and-issues)).

### Mentions

`@handle` in a pull's or an issue's title or body, or in a comment,
mentions that account, as on Bluesky: a full handle, with a dot
(`@alice.example.com`). One in code, in a link, inside a word (an email
address) or with no dot (`@alice`) is only text.

Whoever writes the record resolves each handle as it writes, and keeps
what it found in the record as facets, shaped as Bluesky's
`app.bsky.richtext.facet`: `facets` over the body, `titleFacets` over the
title, each a byte range of the text and a `dev.knotless.defs#mention`
naming the account by DID. At most 50 a text; a handle that resolves to
no one gets none, and stays a word. An edit writes them again from the
new text.

Why at writing, not as an index reads the record: a mention keeps naming
the account after its handle changes, or after another account takes the
handle up, since the DID is in the record; an index reads who is
mentioned from the record alone, with no lookups as it follows the
network, and any index of it gets the same answer; and it is how Bluesky
keeps mentions in posts, so the shape is one readers know. What it costs:
a record written by another client without facets mentions no one, though
the pages still link its `@handle`s by handle.

The pages link each mention to the account's profile, by the DID its
facet names, else by the handle. The mentioned account hears of it
([serve.md](serve.md#notifications)).

## State

A maintainer cannot write to an author's pull record to mark it merged. So
a state change is a record of its own, `dev.knotless.state`, in the account
of whoever makes it. Its `subject` names the item and its `state` is
`open`, `closed` or `merged`.

Anyone can write such a record; what makes it count is who wrote it.
Readers believe one only from the item's author, the repository's owner or
a collaborator: one named on the repository record, or a member of the
[organization](gateway.md#organizations) the owner is, as the reader can
see them; and a `merged` or `closed` one also from a runner the
repository names. On a public repository an organization's member counts
only when public, so knotless refuses a hidden member's merge, label,
queueing, close or reopen there before writing, which no reader would
count. Close and reopen read the item first, from its author's repo, with
the newest states of those who may write them and of the caller, and
refuse one that is not there, a caller no reader would count, a merged
pull, and a state the item is in already, so they write nothing a reader
would ignore. Anyone can still write a state record with other tools, and
readers ignore one from anyone else.

| State    | Item's author | Owner | Collaborator | Named runner | Written by        |
| -------- | ------------- | ----- | ------------ | ------------ | ----------------- |
| `open`   | yes           | yes   | yes          | no           | reopen            |
| `closed` | yes           | yes   | yes          | yes          | close, or a merge |
| `merged` | no            | yes   | yes          | yes          | a merge only      |

A repository that was transferred also counts its owner before the move,
for state records whose key is older than the move.

Every item starts `open`. Readers take the state records about an item in
record-key order across all authors, and each from an allowed author
replaces the state, so the latest allowed record wins.

```mermaid
stateDiagram-v2
  [*] --> open
  open --> closed: close
  closed --> open: reopen
  open --> merged: merge (pulls only)
  merged --> [*]
```

A state record can also carry `base`, the branch the pull targets from then
on. One with `base` and no `state` changes the target and leaves the state
alone, so a retarget racing a merge cannot undo it.

Details:

- A `merged` record carries `commit`, where the target branch now points,
  and `from`, where it pointed before. Pages credit the commits in
  `from..commit` to the pull's author.
- A `closed` record on an issue may carry `pull`, the pull that closed it.

## Labels

The owner defines a repository's labels in its repo record: each a name,
and optionally a description, a color and `exclusive`. A name's scope is
everything up to its last `/`, so `kind/bug` is in scope `kind`, and a name
with no `/` is in no scope. Of a scope's exclusive labels, such as
`priority/high` and `priority/low`, one stands at a time. A label in no
scope rivals none, exclusive or not. The definitions
live in the repo record rather than in a file in the tree, because a file
would differ from branch to branch and any pull could propose changing it.

The owner and collaborators put labels on items. Each of them keeps one
`dev.knotless.label` record per item in their own account, keyed by the
repository's `key` and a hash of the item's AT-URI, and rewrites it as
labels change. Each entry in it says when a label was set or taken off.
Setting an exclusive label also takes off the others of its scope.

A reader takes, for each label the repository defines, the latest entry
from anyone allowed, by `setAt` and then by DID. Of a scope's exclusive
labels still standing, only the one set last counts. So two people
labelling at once lose neither's label, a label taken off stays off until
someone sets it again, and taking off the exclusive label that stands
brings back none set before it.

```mermaid
sequenceDiagram
  participant A as owner
  participant B as collaborator
  participant R as reader
  A->>A: label: kind/bug, priority/high (t1)
  B->>B: label: priority/low (t2), priority/high removed (t2)
  B->>B: label: priority/low removed (t3)
  R->>R: kind/bug stands, and no priority label
```

The repository record may also name `triagers`: accounts, such as a
triage bot, each with the scopes it may label. A triager's entries count
only for labels of those scopes, and below people's: for each label, an
entry from the owner or a collaborator comes before any triager's,
whenever either was set, and the latest decides only among entries of the
same rank. So a person who takes off a bot's label keeps it off however
often the bot sets it again, and a bot's exclusive label never displaces
one a person chose. An account named as a triager labels as one even if
it is a collaborator too, so an agent that pushes as a collaborator can
still be held to its scopes. A triager's record may say what it judged
(`judged`): the version of the item it read, by CID, what chose its labels,
and why, so it judges again only when that changes.

Who may label is read from the repository record at read time, as for
states. A collaborator or triager who is removed, or a label no longer
defined, takes those labels off with them, and they come back if the
collaborator, triager or label does. Labels from anyone else, and names
the repository does not define, count for nothing.

A triage bot, `knotless-triage`, is such a triager. It watches
repositories and hands each open issue to a classifier, which chooses
among the repository's labels of the scopes the bot was started with. The
bot writes what was chosen as its own label record, with `judged`.

The classifier is meant to be a decision model, such as TypeSafe's Jev or
Cloudflare's Clef, which share one API: given the issue and typed
questions, each a choice among a scope's labels or whether one label fits,
it answers each with a probability and writes no text. An issue's text can
then shift probabilities but never put on a label the repository did not
offer, and a probability short of the bot's threshold puts on nothing, so
the bot abstains rather than guesses. Any model speaking that API can be
swapped in by its URL and name. Anything else, such as a script asking a
language model, can be run as a command instead. It asks only about an issue it has not
judged at its current CID, and only for scopes where nobody else has put a
label on, so it never second-guesses a person and pays for a model only when
an issue changes. Labels it chose before and no longer chooses, it takes
off.

With `--discover` it also finds, as knotless-run finds the repositories
naming it a runner, every repository whose record names it a triager, and
triages each in the scopes it was named for, among those it was started
with: the appview lists the public ones (`listRepos?triager=`), Jetstream
brings one named since, and a private one invites it, since naming a
triager of a private repository makes it a member of the repository's
space. One that no longer names it is left. A private repository's issues
reach no Jetstream, so it reads those each `--sweep`.

The bot writes its labels whether or not the repository names it, and
until the owner does they count for nothing. That is how it is tried out:
`knotless triager eval <url> <handle>` compares its labels with everyone
else's, scope by scope, worked out as if it had labelled nothing. The owner
grants a scope once the bot agrees with people there often enough: the
threshold decides only whether the bot says anything, and agreement with
people decides whether what it says counts. A scope such as `priority`
is better left to people.

Details:

- `setAt` is the clock of whoever set the label. An entry more than five minutes ahead
  of the reader's clock waits until it is not, so a clock far ahead cannot
  win over every later change.
- The answer to labelling an item is worked out from the index with the
  new record of whoever labelled in it, so labels someone else set a moment before
  may be missing from it until the index has them.

## Assignees

The owner and collaborators assign a pull or an issue to accounts: whom it
waits on, as they see it. Each of them keeps one `dev.knotless.assignment`
record per item in their own account, keyed as their label record is, and
rewrites it as they change it. It holds two lists of entries, `assignees`
and, for a pull, `reviewers` (see
[Drafts and requested reviewers](#drafts-and-requested-reviewers)), each
entry an account by DID, when it was said, and whether it was taken off.

A reader takes, for each account, the latest entry from the owner or a
collaborator, by `setAt` and then by DID, as it does a label's; anyone
else's record counts for nothing, and an entry more than five minutes
ahead of the reader's clock waits. So one maintainer takes off whom
another assigned, and two assigning at once lose neither. An item shows
those it is assigned to in the order they were assigned.

Whom an item is assigned to hears of it as an `assigned` notification,
and one asked to review a pull, as a `review-requested`, once it is not a
draft, unless its author asked them already. Neither is silenced by
muting the item. `knotless pull assign` (MCP `pull_assign`)
assigns, and with `--undo` takes off, as the owner or a collaborator; `knotless pull show` lists the
assignees. On the pages, the item's assignees show under its title,
where the owner and collaborators assign it to more of them or take one
off, or from Add when it has none.

## Filtering pulls and issues

A list of pulls or issues is filtered by a query in the words GitHub's
search uses, since they are the ones people already type:
`label:kind/bug -label:area/web author:alice.test crash`. The words are
`label:` (each must be on the item), `-label:`, `no:label`, `author:` by
handle or DID (any of them), `state:` or `is:` with `open`, `closed` or
`merged`, `is:pull` or `is:issue`, `is:draft` or `-is:draft`,
`review-requested:` by handle, DID or `@me` (the account asking; no one
when no one is logged in), each of which the pull must ask, `assignee:`
the same way, each of whom the item must be assigned to, `no:assignee`,
and anything else, which the title or body must hold, ignoring case. Double quotes keep a value or words
together, and a qualifier it does not know is a word. There is no `OR`.

One parser (`crates/knotless/src/query.rs`) reads the query everywhere:
`dev.knotless.listItems`'s `q`, whose open and closed counts are of the
items it matches; `knotless pull list -q`; and the MCP's `pull_list`. 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, so the menus teach the words.

## Finding everything said about a repository

Listing a repository's pulls and issues needs every author's records about
it, and no one place holds them all.

For a public repository the answer is an appview, a service that indexes
records across accounts: knotless-serve run with `--index` (see
[serve.md](serve.md)). It follows Jetstream or the firehose, the public
streams of every account's record changes, and backfills accounts it has
not seen. It indexes any forge record with a `repoRef` under the owner's
DID and key, and drops it when its delete arrives. A client pages through
`dev.knotless.getForge` until no cursor is left. Without an appview,
listing a public repository fails.

A private repository's records never reach the firehose. The client instead
joins the space as the logged-in account, lists the space's member repos,
and reads every forge collection in each, keeping the records about the
repository. Its web pages read the same way, through the server's session.

An index lags behind the network, and someone who has just opened a pull
wants to see it. So actions that start from one item read it straight from
its author's account: commenting, merging, comparing, diffing, retargeting,
editing and queueing. Showing an item does the same when the repository is
public, the appview lacks it, and its key is under a minute old, as long as
it is not kept out: no label names it or its author, and the owner has
neither hidden it nor blocked its author. An appview that cannot be asked
counts as having taken it down. The web pages show an item once indexed.

## Comparing and diffing a pull

Comparing does everything a merge does up to the push. It syncs the
target's mirror, a local bare git repository kept in step with the PDS, and
fetches the head commit from the head repository's packs, wherever that
repository lives. It answers with the two tips, the commits in
`base..head`, and whether the pull is `merged` (the head is in the base),
`fast-forward`, `clean` (`git merge-tree` succeeds) or in `conflict`, with
the conflicting paths. A merged pull's branch may be gone or have moved
on, so it compares as its `merged` state records it instead: `from` and
`commit` as the two tips, the commits in `from..commit`, and `merged`.

A pull's diff is `git diff base...head`, the head against the merge base.
For a merged pull the pages show `from..commit`.

## Merging

A merge writes in two places. The new commit goes to the target branch, a
ref record in the owner's account, and the `merged` state goes in the
merger's own. The owner writes the ref straight to their PDS. A collaborator
cannot, so they push through the gateway the owner delegated to (see
[gateway.md](gateway.md)), signing with a service token, a short-lived
token their PDS issues for calling another service as them. The gateway
checks them against the repository's `collaborators` and writes as the
owner. The merge itself does not check the merger's role; the push does.

The steps:

1. Read the pull fresh from its author's account; refuse unless `open`,
   and refuse a draft.
   Into a branch whose rule says `queue`, unless the owner forces it,
   queue it instead ([queue-only
   branches](merge-queue.md#queue-only-branches)).
2. Sync the target's mirror, fetch the head commit, read the base tip.
3. Squash if the target branch's rule asks for linear history.
4. Run `git merge-tree` on base and head; stop on a conflict, naming the
   paths, before any check is consulted.
5. Unless the merge is forced, pass the branch's [required
   reviews](#required-reviews), if its rule asks for any; then, if the
   repository names runners, the merge gate. Only the owner may force.
6. Build the commit.
7. Push the branch update, then, when the head branch is spent (see
   below), its deletion in a push of its own, so a delete that is refused
   leaves the branch and not the pull open.
8. In one `applyWrites` in the merger's account, write the `merged` state
   and any closing states.

The commit is a merge commit with parents base then head, even when the
base is the head's ancestor and a fast-forward was possible, so that the
branch's history names every pull it took. On a linear branch it is a
squash commit. A pull whose head the base has already, pushed there by
hand, is marked merged as it stands, and the branch does not move. A branch with a rule is linear unless the rule's
history is `merges`, so a rule that names no history is linear too.

The gateway holds collaborators to a branch's rule: it refuses deleting the
branch, moving it backwards, a merge commit on a linear one, on one
that takes pulls only through the merge queue any move but the queue's
lander's, and on one that asks for reviews any move but the merge of a
pull reviewed as it asks. In a repository that names runners, it holds a
collaborator's merge to the merge gate. The owner
writes past the gateway, so the rule holds them only as far as their client
keeps to it, and the client squashes into linear branches for them too.
Merge and squash messages end with the pull's AT-URI. The pages link a
commit to its pull by that line, or else by the `merged` states: a commit
in a merged pull's `from..commit` is that pull's, the one bringing in
least when two did, so commits that landed by a push link too.

### Merge when checks pass

On a branch with no merge queue, the owner or a collaborator can set a
pull to merge by itself once it would: `knotless pull merge --auto`, or
Merge when checks pass on its page. It is a `dev.knotless.autoMerge`
record in their account, `set`, naming the pull's head and target branch
as they were. A later `cancelled` one takes it back, and counts from the
pull's author too. For each pull the latest record that counts wins, as
for the queue.

A set stands at a later head only when the pull's head repository
belongs to the owner or a collaborator, so a maintainer's fix carries on.
A push to anyone else's fork cancels it: the author could otherwise wait
for a maintainer to set it, then push something else to merge. Moving the
pull to another branch cancels it too. Nothing is written for either.
Every reader works it out from the record and the pull as it is now.

The runner that lands the repository's merge queues
([who lands a queue](merge-queue.md#who-lands-a-queue)) tries each set
pull on each pass, merging it as `knotless pull merge` would, as itself.
That needs its checks passed at its head merged with the base, the
reviews its branch asks for, and no conflicts. Until then nothing
happens, and it stays set, through a failed check or a conflict, until a
push fixes it. It keeps the head branch. A runner that may not push
merges through the gateway, which takes from it a commit only if it is
the merge of a pull whose set stands
([gateway.md](gateway.md#runners-landing-a-queue)).

A branch with the queue holds a pull until its checks pass anyway, so
setting a pull to merge there is refused, naming `--queue`.

### Merged by a push

A push that lands a pull's head on the branch it targets merges it. After
a push lands, `git-remote-knotless` reads the open pulls into each branch it moved and marks merged each whose head the
branch has now and did not before, writing the `merged` state (`from` the
branch's old tip, `commit` the pull's head) and closing what the pull
names, as `knotless pull merge` would. It says so on git's stderr (`knotless:
<pull> merged, as main now has its head`). A pull from a fork counts when
the pushing checkout has its head. The push has landed either way: a
failure to read or mark the pulls is said and left.

Details:

- A squash commit keeps the author and date of the head's tip. The
  committer, and a merge commit's author, is the merger, as git commits on
  their machine, or else as `<did>@knotless.invalid`; the merge queue's
  is the runner (see [Commit authors](identity.md#commit-authors)).
- A squash commit's message is the pull's title, its body and its
  AT-URI. The body leaves out the `<details>` and `<summary>` tags that
  fold a part of it away on its page, outside fenced code, keeping each
  summary's words as a line of their own, so `git log` reads it whole.
- A head branch is spent when it is in the target repository, is neither
  the pull's base nor the default branch, is not protected, and no other
  open pull targets or brings it in. A fork's branch is never spent. `--keep-branch` keeps it.

## Updating a pull's branch

A pull cut from an older base can conflict with what landed since, and
a pull that conflicts is turned away by a merge and by the merge queue.
`knotless pull update-branch` and the MCP `pull_update_branch` tool bring its branch
up to date, as GitHub's Update branch button does: a merge commit of the
base's tip into the branch, built in the target's mirror with no
checkout, and pushed to the branch. The owner and collaborators may, for
a pull whose branch is in the repository itself; a fork's branch is its
owner's to update.

Two pulls that each add a section to the end of one docs page conflict
in git, though neither side's lines are in doubt. So where every
conflict in a file is lines both sides added at the same place, with
none of the common ancestor's between them, the base's lines are kept
first, as they landed first, and the pull's after. The merge commit's
message names those files. Any other conflict, such as both sides
changing the same line, one side deleting the file, a binary file or a
change of mode, is the author's: nothing is pushed, and the error names
the paths.

The push is not forced, so a push to the branch after it was read fails
the update rather than losing that push. A pull that holds its base
already is left as it is. The merge is a new head, so the pull's checks
run again on it.

## Reverting a pull

`knotless pull revert`, the MCP `pull_revert` tool and a merged pull's Revert button
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.

The pull's `merged` state says what it brought in: the commits from `from`
to `commit`, whether it fast-forwarded, merged or squashed. The revert
undoes all of them in one commit, built in the target's mirror with no
checkout:

1. Refuse unless the pull is `merged` and the target branch still holds
   `commit`.
2. Make a commit on `commit` whose tree is `from`'s, and merge it with the
   branch's tip using `git merge-tree`. Their merge base is `commit`, so
   what the branch took since stays and what the pull brought goes. On a
   conflict, stop and name the paths. If the tree is the tip's, the pull
   was undone already.
3. Commit that tree on the tip, titled `Revert "<title>"`, and push it as
   a new branch, `revert/<the pull's rkey>`: directly as the owner,
   through the gateway as a collaborator. A branch of that name already
   there stops it.
4. Open a pull of that branch into the target branch, titled
   `Revert "<title>"` (cut to fit), its body linking the pull by its page
   and its AT-URI, then the reason if one was given. Its record names the
   pull as `reverts`.

Once that revert pull is merged, readers count it: the pull it undid
shows `reverted by` and the revert (`revertedBy` in `getThread` and the
MCP `pull_show`, and a line on its page). An open or closed revert
counts for nothing, so anyone may open a pull naming another in
`reverts`, and nothing shows until someone who may merge lands it.

## Required reviews

A branch's rule may ask for reviews (`knotless branch protect --reviews <n>
[--code-owners] [--dismiss-stale]`), as the `reviews` object of its entry
in the repository record. A review is a comment with a verdict, `approve`
or `changes`, and each reviewer's newest one stands; the pull's author
reviews nothing of their own. Only those who may write to the repository
count: the owner and its collaborators.

A pull merges into such a branch only when:

- `approvals` of them approve it;
- with `codeOwners`, for each path it changes that the base's CODEOWNERS
  gives owners, one of those owners approves it;
- none of them has a standing request for changes that is not dismissed;
- with `gates`, each gate has reviewed its head and asks for no changes
  ([Gates](#gates)).

With `dismissStale`, only an approval of the pull's head as it is now
counts; otherwise any standing approval does. A field of `reviews` the
client does not know blocks its merges until it is updated, so a rule is
never held more loosely than it was written. The owner's `--force` merges
past it.

### Gates

A review agent may not write to the repository, so its reviews count
toward nothing above. A rule's `gates` (`knotless branch protect --gate
<handle>`) name accounts whose reviews hold the pull anyway. Each gate's
newest verdict stands. Each gate is a required reviewer: every pull into
the branch asks it for a review, after those its author and maintainers
ask, and the pages mark it so. It stays asked until the rule stops
naming it, so `knotless pull ask-review --undo` refuses it.

- With none yet, or one of an earlier head, the pull waits. Merging
  refuses it, and the merge queue holds it until the gate reviews its
  head (see [the merge queue](merge-queue.md#pre-flights)).
- `changes` blocks it at any head, until the gate reviews again.
- `approve` of its head lets it go. It is no approval toward
  `approvals`.

An approval of the head from the owner or a collaborator, given after a
gate's newest review, stands in for that gate's, and so does dismissing
that review ([Dismissing a review](#dismissing-a-review)), which works
on the maintainer's own pull too. That is how a pull goes on when its
gate is wrong, has stopped reviewing it, or is down. A
gate that is also a collaborator counts both ways: its approval counts
toward `approvals`, and its changes block as any collaborator's do.

A field of a gate the client does not know blocks merges as one of
`reviews` does.

CODEOWNERS is read from the pull's base, the first of
`.knotless/CODEOWNERS`, `CODEOWNERS` and `.github/CODEOWNERS` there, in
GitHub's syntax: a pattern then its owners per line, `#` comments, and the
last line that matches a path decides its owners. A pattern is
gitignore's: one with a `/` before its end matches from the root, one
without at any depth, and one ending in `/` everything under that
directory. A line whose pattern has no owners leaves the paths it matches
with none. An owner is an `@handle`, resolved to its DID as the file is
read, or a DID. One naming a team (`@org/team`), an email address, or a
handle that does not resolve is skipped with a warning, which `merge
--check` prints, and a line left with no owners is skipped whole. Teams,
and a bare organisation meaning its members, can come once organisations
do.

As an author's approval never counts, a pull changing a path whose only
owner is its author can never have a code owner's approval: `merge
--check` says so (`owners of api/: @alice, its author alone, whose own
approval never counts, so api/ needs another owner in CODEOWNERS`), and
only the owner's `--force` merges it. MCP's `branch_protect` and
`branch_rules` warn ahead of that when a rule asks for code owners and
CODEOWNERS on its branch gives the repository's owner a path alone.

`pull merge`, `pull merge --check`, the pages' merge box, the merge queue taking a
pull in, and its runner putting one in a batch all read this the same way:
`pull merge --check` lists what is missing (`reviews 1 of 2`, `owners of web/:
@alice or @bob`, `changes asked by @carol`) and each approval that does
not count, and why: its reviewer may not write to the repository, or,
with `dismissStale`, it is of an earlier head. A merge's refusal says the
same, so an approval left uncounted is never silent. `pull merge` and `pull merge
--check` read the newest comments of the owner, the collaborators and
whoever asks from their own repos, beside the appview's index, so a review
written a moment ago counts before the appview hears of it. Queueing refuses a pull that
lacks it, and the runner ejects one queued that has come to lack it. The
gateway holds a collaborator to it too: into such a branch it takes only
the merge of a pull that may merge, read as `pull merge --check` reads it, and
refuses any other push or commit ([gateway.md](gateway.md#protected-branches)).

## Closing issues from a pull

A merge into the repository's default branch (`defaultBranch`, else
`main`) closes the issues the pull names after a closing keyword, so an
issue's author need not close it by hand. Merges into other branches close
nothing, since the fix has not reached the default branch.

The keywords are close, closes, closed, fix, fixes, fixed, resolve,
resolves and resolved, in any case, searched for in the title and body. A
keyword must be the word just before the reference. A reference is an
issue's AT-URI with a DID authority, or an issue page's URL on any host,
`<host>/<owner>/<repo>/issue/<did>/<rkey>`. Markdown links count by their
target. `#12` closes nothing, since issues have no numbers.

An issue closes only if it is this repository's own, still `open`, and the
merger may close it. Each close is a `closed` state with `pull` set to the
pull's URI, in the same `applyWrites` as `merged`. A pull's page lists the
issues it names with their states; an issue's page links the pull that
closed it.

A pull that names an issue the same way, with or without a keyword before
it, mentions it. The issue's page shows each such pull on its timeline
from the time it was opened, with its state, and says which will close it
once merged; `knotless pull show` lists them too. Only the pull's title and
body count, not its comments. Wherever a pull, issue or comment names one
of the repository's pulls or issues by its bare `at://` URI, its page
shows the URI as a link to that pull or issue, with its title.

## Keeping pulls attached to their branches

A pull names its branches by name, and branches move. Retargeting changes
either one. The target branch changes by a state record carrying only
`base`, so anyone allowed to change the pull's state may retarget it
without touching the pull. The head branch is part of the pull record, so
only its author can change it. The head may change branch but not
repository, and each new branch must exist. Issues and merged pulls cannot
be retargeted; closed pulls can.

Renaming a branch creates the new name at the old one's commit, retargets
each open pull based on the old name, and only then deletes the old name,
so a rename cut short loses nothing. The default branch cannot be renamed.
Pulls whose head is the renamed branch stay as they are, because only their
authors may change a head.

A pull can be based on another pull's branch, so each shows only its own
layer. When the lower pull lands and its branch goes, each open pull based
on that branch moves to the lower pull's target, by a state record with
`base` and `since`, the commit the lower pull's head was at. A squash leaves
the pull above holding the lower pull's original commits, so `since` is where
its diff, commits and merge start. It stops counting once the branch is
rebased or takes in its base, as the pull's own history then says where it
starts. A merge asked to keep its branch moves nothing.

These pulls make a stack. A pull's stack is followed down through the open
pull whose branch it targets, or the pull it was moved off, which the moving
record names in `pull`, and up the same way. Where several pulls target one
branch, the oldest is the one followed. A retarget by hand takes a pull out.
The pull's page shows the chain, and its files page the pulls either side.
Queued, a stack [lands in turn](merge-queue.md#stacks), lowest first.

## Forks

A fork is a repository in the forker's account whose record names its
source in `forkOf`. Forking copies the source's description and default
branch and pushes every live ref into the fork. Merging a pull from a fork
fetches the head from the fork's packs. The appview lists a repository's
forks. Forking your own repository is refused (`you own <url>: a fork is
of someone else's repository`). A fork of a private repository is private
too, and is shared for reading with the source's owner so they can take
pulls from it.

## Archiving

`knotless repo archive <url>` makes a repository read only, and `--undo`
takes that back. Its owner can do the same from its settings' Danger
zone, and the MCP server's `repo_archive`. It sets `archivedAt` on the repository's record, and
everyone who writes about the repository reads it there:

- A client pushes and commits nothing to it. `knotless` refuses every
  ref of a push, the owner's own included.
- A client writes nothing more said about it: no pull, issue, comment,
  label, state or queue record, and no edit, deletion or retarget of
  one. `knotless`, the MCP server and the pages all write these through
  the same code, which refuses. Hides, blocks, stars and reactions are
  not refused, so the owner can still moderate it.
- A gateway takes no pushes or commits for it, from anyone.
- Runners leave it, and watch it again once it is unarchived.
- The pages show it as archived, under its name and above each page.

What is enforced and what is not follows from where each record lives.
The gateway is the only way a collaborator writes to the owner's
account, so its refusal holds. The owner writes to their own account
directly, so they are held only by their own client, as they are by
[branch rules](#the-merge-gate-and-the-merge-queue). Anyone can still
write a pull or issue about an archived repository in their own account
with a client that does not know `archivedAt`. Readers show it as they
would any other, since the owner can unarchive the repository and
answer it.

## Moderation

The owner cannot delete what others write. Moderation tells readers what to
leave out instead, at two levels: the owner, for their repositories, and
the service running an appview, for what it shows.

A hide (`dev.knotless.hide`) names one pull, issue or comment, one record
per hidden thing, so there is no list to outgrow. Everything said about a
hidden item goes with it: comments, states and queue records. A block
(`dev.knotless.block`) names an account's DID, and with a repository keeps
that account out of it; without one, out of all the owner's repositories.
Readers leave out everything a blocked account wrote about the repository.
Hides and blocks count only from the owner, and a Bluesky block says
nothing about pulls.

Anyone may report an account, repository or record: on the pages, from
the "…" menu of a profile, a repository's header, a pull, an issue or a
comment, once logged in. The report goes, as
`com.atproto.moderation.createReport` signed with a service token, to the
moderator: the labeler named in the configured moderator's DID document,
or else the appview's. The server's admin reads reports and may take a
subject down. A takedown applies at once and goes out as a `!takedown`
label, a statement about a subject published on the server's label stream.
An appview may also follow another labeler and honour its `!takedown` and
`!hide` labels.

| Applied by       | Applies to                 | Public repositories           | Private repositories |
| ---------------- | -------------------------- | ----------------------------- | -------------------- |
| Owner's hide     | one item and its thread    | appview, pages and client     | client and pages     |
| Owner's block    | one repository, or all     | appview, pages and client     | client and pages     |
| Service takedown | an account, repo or record | appview: index, pages, clones | not applied          |
| Followed label   | an account or record       | appview: index, pages, clones | not applied          |

A taken-down repository answers 404 to pages and clones. A private
repository's records never pass through the appview, hence the last column.

Details:

- For a private repository, hides and repository blocks are in the owner's
  repo in the space; account-wide blocks are in their public repo.
- Takedowns, the labels this server gives out for them, and reports
  survive a rebuild of the index. Labels from followed labelers do not:
  they are read again from the start of each labeler's stream.

## The merge gate and the merge queue

When a repository names runners, a merge must pass its gate: each workflow
that runs on a pull into the base needs a passing check at the pull's head
and the base as it is now. Unless the merge fast-forwards, the checks run on
a fixed merge commit of the two that runner and merger both derive, even
when the merge will squash. Only the owner may force past the gate. A pull
is checked as it moves when both its author and its head repository's
owner are the owner or a collaborator; anyone else's is checked once the
owner asks for a rerun. See [runner.md](runner.md).

The merge queue lets a maintainer queue a pull rather than merge it. A
`dev.knotless.queue` record counts only from the owner or a collaborator,
and the repository must name runners. A runner the repository names that
Any runner the repository names may also write `merged` and `closed`
states, and its `dev.knotless.queue` records count only as ejections. A runner lands queued pulls in
batches, one squash commit per pull, each with its `merged` record's `from`
set to the commit before it. It closes issues as a plain merge does and
deletes spent head branches. A branch protected with `queue` takes pulls
only that way: a merge into it queues the pull, unless the owner forces
it. See [merge-queue.md](merge-queue.md).

Commands and flags are in [../reference/cli.md](../reference/cli.md), and
the MCP tools in [../reference/mcp.md](../reference/mcp.md).