# 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:////`, 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: ` is a draft: its author marks it ready with knotless edit --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 `) 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 eval ` 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 `@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 ""`, 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).