Collaboration: pulls, issues, merging and moderation
A knotless repository is a set of records in its owner’s atproto account (see 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
Section titled “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). There, each author writes into their own repo inside the repository’s space.
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 covers both.
Details:
- The forge collections are
pull,issue,comment,state,hide,blockandqueue, underdev.knotless, each keyed by a TID, a time-ordered key its writer mints.labelis the exception, keyed per item (see Labels). A runner’s checks take part in no thread.
Pulls, issues and comments
Section titled “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
Section titled “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
Section titled “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) without --force,
and the merge queue then lands the pull as any other.
Review agents
Section titled “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).
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, as its account may not write to the repository: it is advice, unless the branch names it as a gate, 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 sets one up.
Comments on lines
Section titled “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
Section titled “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). 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
Section titled “Pictures”A pull’s, an issue’s or a comment’s body shows a picture as Markdown does,
, 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). 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).
Mentions
Section titled “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 @handles 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).
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 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.
openWritten by reopen
closedWritten by close, or a merge
mergedWritten by 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.
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
mergedrecord carriescommit, where the target branch now points, andfrom, where it pointed before. Pages credit the commits infrom..committo the pull’s author. - A
closedrecord on an issue may carrypull, the pull that closed it.
Labels
Section titled “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.
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:
setAtis 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
Section titled “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), 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
Section titled “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
Section titled “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). 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
Section titled “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
Section titled “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), 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:
- Read the pull fresh from its author’s account; refuse unless
open, and refuse a draft. Into a branch whose rule saysqueue, unless the owner forces it, queue it instead (queue-only branches). - Sync the target’s mirror, fetch the head commit, read the base tip.
- Squash if the target branch’s rule asks for linear history.
- Run
git merge-treeon base and head; stop on a conflict, naming the paths, before any check is consulted. - Unless the merge is forced, pass the branch’s required reviews, if its rule asks for any; then, if the repository names runners, the merge gate. Only the owner may force.
- Build the commit.
- 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.
- In one
applyWritesin the merger’s account, write themergedstate 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
Section titled “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) 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).
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
Section titled “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). - 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, sogit logreads 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-branchkeeps it.
Updating a pull’s branch
Section titled “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
Section titled “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:
- Refuse unless the pull is
mergedand the target branch still holdscommit. - Make a commit on
commitwhose tree isfrom’s, and merge it with the branch’s tip usinggit merge-tree. Their merge base iscommit, 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. - 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. - 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 asreverts.
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
Section titled “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:
approvalsof 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).
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.
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).
changesblocks it at any head, until the gate reviews again.approveof its head lets it go. It is no approval towardapprovals.
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), 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).
Closing issues from a pull
Section titled “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
Section titled “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, lowest first.
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
Section titled “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.
knotlessrefuses 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. 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
Section titled “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
Section titled “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.
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.
Commands and flags are in ../reference/cli.md, and the MCP tools in ../reference/mcp.md.