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 in the
pull’s merge box, and knotless show lists them as review: lines.
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, 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, 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 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 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
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 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
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 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.
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. 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 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:
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.
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, 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
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 and, when spent (see below), the head branch’s deletion, in one push.
- In one
applyWritesin the merger’s account, write themergedstate 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-branchkeeps it.
Reverting a pull
Section titled “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:
- 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 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
Section titled “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:
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.
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).
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 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 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 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.
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.