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

chadtmiller.com/knotless

git that lives in your Atmosphere account

7
788 lines · 41.0 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 in the
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: 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, up to `--rounds`
times, and not again at a head it reviewed, or at one whose changes are
those it last reviewed, which keeps its verdict without spending a round. What it reviewed it reads back
from its own records, so it keeps nothing else.

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.

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 `show_item` 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 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 in the merge box, beside the reviews. MCP's `show_item` 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 --draft --reviewer <handle>`) and with `knotless
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.

### 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 or
queueing there before writing, which no reader would count. The tools do
not otherwise check permission when writing, and readers ignore a record
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. 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 triagers <url> eval <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.

## 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, 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 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, 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 and, when spent (see below), the head branch's
   deletion, in one push.
8. In one `applyWrites` in the merger's account, write the `merged` state
   and any closing states.

The commit is a fast-forward to the head when the base is its ancestor and
the branch is not linear. On a linear branch it is a squash commit, even
when a fast-forward was possible. Otherwise it is a merge commit with
parents base then head. 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, and pages link such a
commit to its pull.

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, with email
  `<did>@knotless.invalid`.
- A head branch is spent when it is in the target repository, is neither
  the pull's base nor the default branch, and no other open pull targets or
  brings it in. A fork's branch is never spent. `--keep-branch` keeps it.

## Reverting a pull

`knotless revert`, the MCP `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 `show_item`, 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 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.

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.

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 `protect_branch` and
`list_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.

`merge`, `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:
`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. `merge` and `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 `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 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.

## 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 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 `archive_repo`. 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).