chadtmiller.com/knotless

Git hosted in your atproto account, on any stock PDS, fast enough for agents.

git clone knotless://chadtmiller.com/knotless

needs the knotless git helper. set it up

Settle the lexicons; fill the gaps agents hit in day-to-day work

Lexicons: dev.knotless.state's state is optional, so a record can
retarget a pull alone; getForge pages with limit and cursor; per-item
dev.knotless.hide records replace the gate, and blocks can be scoped to
one repository.

From scenario runs with agents working only through the MCP server:
branches can be created, deleted and renamed (open pulls follow a
rename); new branches start from the base or the default branch tip;
commits carry the author's handle; show_item says whether a pull merges
and what it brings in, and pull_diff shows it; comments come flat with
handles; delegate gives a gateway write access; owners see their
private repositories in listings; clearer errors for writes and reader
lists.

A private fork is shared for reading with its source's owner, so they
can take pulls from it. An item too new for the index is read from its
author's repo unless the appview's takedowns or the owner's hides and
blocks keep it out. harness/scenarios.mjs replays the agent scenarios.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Chad Miller committed on Oct 21, 2026 parent 4de1da8 commit e7f3909
Showing 33 changed files with 1670 additions and 251 deletions
Justfile+7−0
@@ -83,3 +83,10 @@ shots-check *args:
shots-up:
{{cargo}} build --bins
tail -f /dev/null | node harness/shots/world.mjs
# Agents doing a day's git work through the MCP server, one fresh session
# per step: `harness/scenarios/tracks`, or only the tracks named. Reports
# what tripped them up under target/scenarios/. Each run costs model usage.
scenarios *tracks:
{{cargo}} build -q --workspace --bins
node harness/scenarios.mjs {{tracks}}
README.md+19−2
@@ -192,8 +192,9 @@ It acts as the account `knotless login` last stored. To pin another, set
| --- | --- |
| `list_repos`, `refs`, `list_tree`, `read_file`, `search`, `log`, `diff` | read, by `handle/name` |
| `commit` | change files on a branch, optionally from a base and through the gateway |
| `create_repo`, `rename_repo`, `share`, `members`, `transfer_repo`, `accept_transfer` | manage repositories and access |
| `fork`, `open_pull`, `open_issue`, `list_items`, `show_item`, `comment`, `set_state`, `merge`, `retarget_pull` | collaborate |
| `create_branch`, `delete_branch`, `rename_branch` | make, remove or rename a branch; a rename takes the open pulls on it along |
| `create_repo`, `rename_repo`, `share`, `members`, `delegate`, `transfer_repo`, `accept_transfer` | manage repositories and access; `delegate` hands a gateway (the appview, by default) your writes, so collaborators can push |
| `fork`, `open_pull`, `open_issue`, `list_items`, `show_item`, `pull_diff`, `comment`, `set_state`, `merge`, `retarget_pull` | collaborate; `show_item` on a pull says what it brings in and whether it merges cleanly |
| `hide_item`, `block_account`, `report` | moderate your repositories, report to the appview |
| `takedown`, `moderation_status`, `list_reports` | as the appview's operator (`KNOTLESS_ADMIN_PASSWORD`) |
@@ -274,6 +275,7 @@ on one small VPS.
| `knotless ls <handle>` | list an account's repositories |
| `knotless refs <url>` | print refs |
| `knotless commit <url> -b <branch> -m <msg> ...` | commit without a checkout |
| `knotless branch <url> <name> [<from>]` / `--delete` | make or remove a branch without a checkout |
| `knotless delegate <handle> <gateway did>` | let a gateway write for you |
| `knotless share <url> <handle> [--write]` / `unshare` / `members` | access |
| `knotless fork <url> [<name>]` | copy a repository into your account |
@@ -350,6 +352,21 @@ it. Without `shots-up`, each run seeds a world of its own. Baselines are
kept per platform, under `harness/shots/baseline/<platform>`; DIDs, which
the PLC directory makes new for each world, are masked.
Scenarios ([`harness/scenarios`](harness/scenarios)) put agents to a day's
git work through the MCP server. Each track is a few steps by different
people, such as a contributor's fix going through review. Each step is a
fresh Claude session that has only the knotless tools, logged in as that
person, on a fresh PDS and appview. Each agent reports what tripped it up,
and the run collects those reports with every failed call:
```sh
just scenarios # every track; uses the `claude` CLI and costs model usage
just scenarios contribution # or only some
```
Transcripts and `report.md` land in `target/scenarios/<time>/`.
`SCENARIO_MODEL` picks the model.
## Not yet
- OAuth: logins are app passwords, and reading a private repository takes a
crates/knotless-mcp/src/lib.rs+174−20
@@ -9,6 +9,7 @@
use knotless::commit::{self, Change, Spec};
use knotless::env::Ctx;
use knotless::forge;
use knotless::gateway;
use knotless::git::Git;
use knotless::manage::{self, NewRepo};
use knotless::mirror::Mirror;
@@ -149,6 +150,30 @@ pub struct CommitParams {
pub batch: bool,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Delegate {
/// The gateway's DID; the appview's when left out.
pub gateway: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct NewBranch {
/// `handle/name`, `did/name` or `knotless://handle/name`.
pub repo: String,
/// Without refs/heads/.
pub branch: String,
/// A commit or branch to start it at; the default branch when left out.
pub from: Option<String>,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct OldBranch {
/// `handle/name`, `did/name` or `knotless://handle/name`.
pub repo: String,
/// Without refs/heads/.
pub branch: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Create {
/// The new repository's name, in your account.
@@ -289,6 +314,16 @@ pub struct Subject {
pub uri: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct RenameBranch {
/// `handle/name`, `did/name` or `knotless://handle/name`.
pub repo: String,
/// The branch's name now, without refs/heads/.
pub from: String,
/// Its new name.
pub to: String,
}
#[derive(Deserialize, schemars::JsonSchema)]
pub struct Retarget {
pub repo: String,
@@ -340,21 +375,20 @@ impl Server {
#[tool_router]
impl Server {
#[tool(description = "List an account's public repositories.")]
#[tool(
description = "List an account's repositories: its public ones, and your own private ones too when the account is yours."
)]
async fn list_repos(
&self,
Parameters(p): Parameters<Owner>,
) -> Result<CallToolResult, McpError> {
answer(async {
let url = RepoUrl::parse(&format!("knotless://{}/_", p.owner))?;
let remote = Remote::connect(&self.ctx, &url).await?;
let repos = remote.list_repos().await?;
Ok(json!(repos
.into_iter()
.map(|r| json!({"name": r.value.name, "description": r.value.description, "defaultBranch": r.value.default_branch}))
.collect::<Vec<_>>()))
}
.await)
answer(
async {
let repos = manage::list(&self.ctx, &p.owner).await?;
Ok(serde_json::to_value(repos)?)
}
.await,
)
}
#[tool(description = "A repository's branches and tags, with the commit each points at.")]
@@ -448,7 +482,42 @@ impl Server {
}
#[tool(
description = "Commit file changes to a branch without a checkout, creating the branch or repository if needed. If the branch moved meanwhile, the changes are replayed onto its new tip, unless `base` says what they were made against and a path they touch changed since."
description = "Create a branch at a commit or another branch, or at the default branch. The owner and collaborators may."
)]
async fn create_branch(
&self,
Parameters(p): Parameters<NewBranch>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let sha =
forge::branch(&self.ctx, &url, &p.branch, p.from.as_deref(), false).await?;
Ok(json!({"branch": p.branch, "sha": sha}))
}
.await,
)
}
#[tool(
description = "Delete a branch, such as one already merged. Not the default branch. The owner and collaborators may."
)]
async fn delete_branch(
&self,
Parameters(p): Parameters<OldBranch>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
forge::branch(&self.ctx, &url, &p.branch, None, true).await?;
Ok(json!({"deleted": p.branch}))
}
.await,
)
}
#[tool(
description = "Commit file changes to a branch without a checkout, creating the branch or repository if needed: a new branch starts from `base`, or else from the default branch. If the branch moved meanwhile, the changes are replayed onto its new tip, unless `base` says what they were made against and a path they touch changed since. The commit is authored by you."
)]
async fn commit(
&self,
@@ -493,7 +562,9 @@ impl Server {
)
}
#[tool(description = "Create a repository in your account, public or private.")]
#[tool(
description = "Create a repository in your account, public or private. It has no branches until the first commit, which creates the branch it names."
)]
async fn create_repo(
&self,
Parameters(p): Parameters<Create>,
@@ -514,7 +585,7 @@ impl Server {
}
#[tool(
description = "Rename a public repository you own. The old name keeps redirecting to it, and pulls, issues and forks follow it."
description = "Rename a repository you own, public or private. The old name keeps redirecting to it, and pulls, issues and forks follow it."
)]
async fn rename_repo(
&self,
@@ -568,7 +639,7 @@ impl Server {
}
#[tool(
description = "Share a repository you own: let an account read it (private repositories), push to it, or with revoke, take that back."
description = "Share a repository you own: let an account read it and open pulls and issues (private repositories), push to it and merge, or with revoke, take that back. Sharing for writing needs your writes delegated to a gateway first (delegate)."
)]
async fn share(&self, Parameters(p): Parameters<Share>) -> Result<CallToolResult, McpError> {
answer(
@@ -586,7 +657,9 @@ impl Server {
)
}
#[tool(description = "Fork a repository into your account, with all its branches and tags.")]
#[tool(
description = "Fork a repository into your account, with all its branches and tags. A private repository forks into a private one that its owner can read, so they can take pulls from it."
)]
async fn fork(&self, Parameters(p): Parameters<Fork>) -> Result<CallToolResult, McpError> {
answer(async {
let url = forge::fork(&self.ctx, &repo_url(&p.repo)?, p.name.as_deref()).await?;
@@ -658,15 +731,73 @@ impl Server {
)
}
#[tool(description = "A pull request or issue with its comments and reviews.")]
#[tool(
description = "A pull request or issue with its comments and reviews, oldest first. For a pull, `compare` says what it brings in: the base and head commits, the commits the base lacks, and whether it merges (merged, fast-forward, clean or conflict, with the conflicting paths)."
)]
async fn show_item(
&self,
Parameters(p): Parameters<Subject>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let value = forge::show(&self.ctx, &repo_url(&p.repo)?, &p.uri).await?;
Ok(serde_json::to_value(value)?)
let url = repo_url(&p.repo)?;
let thread = forge::show(&self.ctx, &url, &p.uri).await?;
let comments: Vec<Value> = thread
.comments
.iter()
.map(|c| {
json!({
"uri": c.uri,
"author": c.author,
"handle": thread.handles.get(&c.author),
"body": c.value["body"],
"verdict": c.value["verdict"],
"commit": c.value["commit"],
"createdAt": c.value["createdAt"],
})
})
.collect();
let mut out = json!({"item": thread.item, "comments": comments});
if thread.item.kind == "pull" {
out["compare"] = match forge::compare(&self.ctx, &url, &p.uri).await {
Ok(compared) => serde_json::to_value(compared)?,
Err(err) => json!({"error": format!("{err:#}")}),
};
}
Ok(out)
}
.await,
)
}
#[tool(
description = "The unified diff a pull request would apply: its head, in whichever repository it is, against where it left the branch it targets."
)]
async fn pull_diff(
&self,
Parameters(p): Parameters<Subject>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let diff = forge::pull_diff(&self.ctx, &repo_url(&p.repo)?, &p.uri).await?;
Ok(Value::String(diff))
}
.await,
)
}
#[tool(
description = "Rename a branch: the new one starts where the old one is, open pull requests that target it are pointed at the new name, and the old one is deleted. Not the default branch. The owner and collaborators may."
)]
async fn rename_branch(
&self,
Parameters(p): Parameters<RenameBranch>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let url = repo_url(&p.repo)?;
let moved = forge::rename_branch(&self.ctx, &url, &p.from, &p.to).await?;
Ok(json!({"branch": p.to, "retargeted": moved}))
}
.await,
)
@@ -839,7 +970,30 @@ impl Server {
)
}
#[tool(description = "Who may read and push a repository.")]
#[tool(
description = "Let a gateway write to your repositories for the collaborators you share them with, by handing it an app password. Needed once before sharing for writing."
)]
async fn delegate(
&self,
Parameters(p): Parameters<Delegate>,
) -> Result<CallToolResult, McpError> {
answer(
async {
let gateway = match p.gateway {
Some(did) => did,
None => gateway::appview(&self.ctx)?,
};
let me = knotless::session::me(&self.ctx).await?;
gateway::delegate(&self.ctx, &me, &gateway).await?;
Ok(json!({"delegated": gateway}))
}
.await,
)
}
#[tool(
description = "Who may read and push a repository. Only its owner can list a private one's readers."
)]
async fn members(&self, Parameters(p): Parameters<Repo>) -> Result<CallToolResult, McpError> {
answer(
async {
crates/knotless-mcp/tests/mcp.rs+190−2
@@ -153,6 +153,63 @@ fn an_agent_works_on_a_repository() {
"{diff}"
);
// A new branch starts from the default branch, or from `base`, and
// its commits are the committer's.
let feature = c.json("commit", json!({"repo": repo, "branch": "feature", "message": "f", "changes": [{"path": "f", "content": "f\n"}]}));
assert_eq!(feature["parent"], second["sha"]);
let fix = c.json("commit", json!({"repo": repo, "branch": "fix", "message": "x", "changes": [{"path": "x", "content": "x\n"}], "base": first["sha"]}));
assert_eq!(fix["parent"], first["sha"]);
let log = c.json("log", json!({"repo": repo, "rev": "feature", "limit": 1}));
assert_eq!(log[0]["author"]["name"], s.account.handle.as_str());
// Branches are made and deleted without a commit.
let develop = c.json("create_branch", json!({"repo": repo, "branch": "develop"}));
assert_eq!(develop["sha"], second["sha"]);
let old = c.json(
"create_branch",
json!({"repo": repo, "branch": "old", "from": first["sha"]}),
);
assert_eq!(old["sha"], first["sha"]);
c.json("delete_branch", json!({"repo": repo, "branch": "old"}));
c.json(
"create_branch",
json!({"repo": repo, "branch": "gone", "from": "main"}),
);
c.json("delete_branch", json!({"repo": repo, "branch": "gone"}));
// A deleted branch can be made again.
c.json("create_branch", json!({"repo": repo, "branch": "gone"}));
c.json("delete_branch", json!({"repo": repo, "branch": "gone"}));
let refs_now = c.json("refs", json!({"repo": repo}))["refs"].to_string();
assert!(
refs_now.contains("develop") && !refs_now.contains("refs/heads/old"),
"{refs_now}"
);
for (tool, args, why) in [
(
"create_branch",
json!({"repo": repo, "branch": "develop"}),
"already has",
),
(
"create_branch",
json!({"repo": repo, "branch": "y", "from": "nope"}),
"no commit or branch",
),
(
"delete_branch",
json!({"repo": repo, "branch": "old"}),
"has no branch",
),
(
"delete_branch",
json!({"repo": repo, "branch": "main"}),
"default branch",
),
] {
let (text, err) = c.call(tool, args);
assert!(err && text.contains(why), "{tool}: {text}");
}
let repos = c.json("list_repos", json!({"owner": s.account.handle}));
assert_eq!(repos[0]["name"], "agentic");
assert_eq!(repos[0]["description"], "made by a tool");
@@ -160,6 +217,7 @@ fn an_agent_works_on_a_repository() {
assert_eq!(members[0]["access"], "owner");
// Renamed, it answers to both names.
let refs = c.json("refs", json!({"repo": repo}));
let renamed = c.json("rename_repo", json!({"repo": repo, "name": "agentic2"}));
let moved = format!("knotless://{}/agentic2", s.account.handle);
assert_eq!(renamed["url"], moved);
@@ -192,6 +250,8 @@ fn an_agent_works_on_a_repository() {
),
("share", json!({"repo": repo, "with": s.account.handle})),
("rename_repo", json!({"repo": "not a repo", "name": "x"})),
// With no appview, there is no gateway to assume.
("delegate", json!({})),
] {
let (text, err) = c.call(tool, args.clone());
assert!(err, "{tool} {args}: {text}");
@@ -210,9 +270,35 @@ fn sharing_from_an_agent() {
let repo = created["repo"].as_str().unwrap().to_string();
c.json("commit", json!({"repo": repo, "branch": "main", "message": "m", "changes": [{"path": "a", "content": "a"}]}));
assert_eq!(c.json("refs", json!({"repo": repo}))["private"], true);
let other = account();
// Its owner sees it listed, once, under the name it has now.
let mine = |c: &Client| -> Vec<Value> {
c.json("list_repos", json!({"owner": s.account.handle}))
.as_array()
.unwrap()
.iter()
.filter(|r| r["private"] == true)
.cloned()
.collect()
};
assert_eq!(mine(&c)[0]["name"], "secret");
c.json("rename_repo", json!({"repo": repo, "name": "hidden"}));
let listed = mine(&c);
assert_eq!(listed.len(), 1, "{listed:?}");
assert_eq!(listed[0]["name"], "hidden");
c.json(
"rename_repo",
json!({"repo": format!("{}/hidden", s.account.handle), "name": "secret"}),
);
let reader = logged_in();
let other = reader.account.clone();
let members = c.json("share", json!({"repo": repo, "with": other.handle}));
assert_eq!(members[1], json!({"did": other.did, "access": "read"}));
assert_eq!(
members[1],
json!({"did": other.did, "handle": other.handle, "access": "read"})
);
// A reader may not list the others.
let (text, err) = Client::new(&reader).call("members", json!({"repo": repo}));
assert!(err && text.contains("owner can list"), "{text}");
let members = c.json(
"share",
json!({"repo": repo, "with": other.handle, "revoke": true}),
@@ -307,8 +393,11 @@ fn agents_fork_review_and_merge() {
let (base, _appview) = appview(&rt);
let mut owner = logged_in();
owner.set("KNOTLESS_APPVIEW", &base);
// The appview is a gateway too, at a did:web served over http.
owner.set("KNOTLESS_DID_WEB_SCHEME", "http");
let mut contrib = logged_in();
contrib.set("KNOTLESS_APPVIEW", &base);
contrib.set("KNOTLESS_DID_WEB_SCHEME", "http");
let (o, c) = (Client::new(&owner), Client::new(&contrib));
let repo = c_repo(&o.json("create_repo", json!({"name": "reviewed"})));
@@ -317,6 +406,35 @@ fn agents_fork_review_and_merge() {
assert_eq!(fork, format!("{}/reviewed", contrib.account.handle));
let change = c.json("commit", json!({"repo": fork, "branch": "main", "message": "b", "changes": [{"path": "b", "content": "b\n"}]}));
// A collaborator needs the owner's writes delegated to a gateway: the
// appview, unless another is named.
let (text, err) = o.call(
"share",
json!({"repo": repo, "with": contrib.account.handle, "write": true}),
);
assert!(err && text.contains("`delegate` tool"), "{text}");
let (text, err) = c.call("create_branch", json!({"repo": repo, "branch": "theirs"}));
assert!(err && text.contains("only its owner can"), "{text}");
let gateway = o.json("delegate", json!({}))["delegated"].clone();
assert!(
gateway
.as_str()
.unwrap()
.starts_with("did:web:localhost%3A"),
"{gateway}"
);
o.json("delegate", json!({"gateway": gateway}));
o.json(
"share",
json!({"repo": repo, "with": contrib.account.handle, "write": true}),
);
c.json("create_branch", json!({"repo": repo, "branch": "theirs"}));
c.json("delete_branch", json!({"repo": repo, "branch": "theirs"}));
o.json(
"share",
json!({"repo": repo, "with": contrib.account.handle, "revoke": true}),
);
let pull = c.json("open_pull", json!({"repo": repo, "base": "main", "head_repo": fork, "head_branch": "main", "title": "Add b", "body": "small"}));
let pull = pull["uri"].as_str().unwrap().to_string();
let issue = c.json("open_issue", json!({"repo": repo, "title": "Missing c"}))["uri"]
@@ -333,6 +451,8 @@ fn agents_fork_review_and_merge() {
.clone()
};
wait_until(|| listed("open").len() == 1);
// Authors come with their handles.
assert_eq!(listed("open")[0]["handle"], contrib.account.handle.as_str());
wait_until(|| {
o.json("list_items", json!({"repo": repo}))
.as_array()
@@ -358,6 +478,7 @@ fn agents_fork_review_and_merge() {
== 1
});
let shown = o.json("show_item", json!({"repo": repo, "uri": pull}));
assert_eq!(shown["item"]["handle"], contrib.account.handle.as_str());
assert_eq!(shown["item"]["approvals"], 1);
// The pull's author points it at the head branch it already names.
@@ -374,6 +495,20 @@ fn agents_fork_review_and_merge() {
);
assert!(err, "{text}");
// Before merging: what it brings in, that it merges, and the review.
let shown = o.json("show_item", json!({"repo": repo, "uri": pull}));
assert_eq!(shown["compare"]["mergeable"], "fast-forward");
assert_eq!(shown["compare"]["head"], change["sha"]);
assert_eq!(shown["compare"]["commits"].as_array().unwrap().len(), 1);
assert_eq!(shown["comments"][0]["body"], "good");
assert_eq!(shown["comments"][0]["verdict"], "approve");
assert_eq!(
shown["comments"][0]["handle"],
owner.account.handle.as_str()
);
let diff = o.ok("pull_diff", json!({"repo": repo, "uri": pull}));
assert!(diff.contains("+b"), "{diff}");
assert_eq!(
o.json("merge", json!({"repo": repo, "uri": pull}))["sha"],
change["sha"]
@@ -381,7 +516,59 @@ fn agents_fork_review_and_merge() {
wait_until(|| listed("merged").len() == 1);
let (text, err) = o.call("merge", json!({"repo": repo, "uri": pull}));
assert!(err, "{text}");
let shown = o.json("show_item", json!({"repo": repo, "uri": pull}));
assert_eq!(shown["compare"]["mergeable"], "merged");
// A pull that conflicts says where.
o.json("commit", json!({"repo": repo, "branch": "main", "message": "mine", "changes": [{"path": "a", "content": "owner\n"}]}));
c.json("commit", json!({"repo": fork, "branch": "clash", "message": "theirs", "changes": [{"path": "a", "content": "contrib\n"}]}));
let clash = c.json("open_pull", json!({"repo": repo, "base": "main", "head_repo": fork, "head_branch": "clash", "title": "Clash"}));
let clash = clash["uri"].as_str().unwrap().to_string();
let compared = &o.json("show_item", json!({"repo": repo, "uri": clash}))["compare"];
assert_eq!(compared["mergeable"], "conflict");
assert_eq!(compared["conflicts"], json!(["a"]));
let (text, err) = o.call("merge", json!({"repo": repo, "uri": clash}));
assert!(err && text.contains("conflicts in a"), "{text}");
// One that touches other files merges cleanly though main moved on.
c.json("commit", json!({"repo": fork, "branch": "aside", "message": "c", "changes": [{"path": "c", "content": "c\n"}]}));
let aside = c.json("open_pull", json!({"repo": repo, "base": "main", "head_repo": fork, "head_branch": "aside", "title": "Aside"}));
let aside = aside["uri"].as_str().unwrap().to_string();
let compared = &o.json("show_item", json!({"repo": repo, "uri": aside}))["compare"];
assert_eq!(compared["mergeable"], "clean");
// Renaming a branch takes the pulls on it along.
o.json("create_branch", json!({"repo": repo, "branch": "develop"}));
let onto = c.json("open_pull", json!({"repo": repo, "base": "develop", "head_repo": fork, "head_branch": "clash", "title": "Onto develop"}));
let onto = onto["uri"].as_str().unwrap().to_string();
wait_until(|| listed("open").iter().any(|i| i["uri"] == onto.as_str()));
let renamed = o.json(
"rename_branch",
json!({"repo": repo, "from": "develop", "to": "next"}),
);
assert_eq!(renamed["retargeted"], json!([onto]));
wait_until(|| {
o.json("show_item", json!({"repo": repo, "uri": onto}))["item"]["base"] == "next"
});
let (text, err) = o.call(
"rename_branch",
json!({"repo": repo, "from": "main", "to": "trunk"}),
);
assert!(err && text.contains("default branch"), "{text}");
// A pull whose head is gone still shows, and says so.
c.json("delete_branch", json!({"repo": fork, "branch": "clash"}));
let compared = &o.json("show_item", json!({"repo": repo, "uri": clash}))["compare"];
assert!(
compared["error"]
.as_str()
.unwrap()
.contains("has no branch clash"),
"{compared}"
);
// An issue has nothing to compare.
let shown = o.json("show_item", json!({"repo": repo, "uri": issue}));
assert!(shown.get("compare").is_none(), "{shown}");
assert_eq!(
o.json(
"set_state",
@@ -418,6 +605,7 @@ fn agents_fork_review_and_merge() {
),
("open_issue", json!({"repo": "nobody.test/x", "title": "t"})),
("list_items", json!({"repo": "nobody.test/x"})),
("pull_diff", json!({"repo": repo, "uri": issue})),
(
"show_item",
json!({"repo": repo, "uri": "at://did:plc:nobody/dev.knotless.issue/x"}),
crates/knotless-serve/src/appview.rs+17−4
@@ -18,10 +18,13 @@ fn index(app: &App) -> Result<&crate::index::Index, AppError> {
pub struct ForgeParams {
repo: String,
key: String,
limit: Option<usize>,
cursor: Option<String>,
}
/// Every pull, issue, comment and state change about a repository: the
/// one its owner made with `key`, whatever it has been named.
/// The pulls, issues, comments and state changes about a repository: the
/// one its owner made with `key`, whatever it has been named. A page at a
/// time, by URI.
pub async fn get_forge(
State(app): State<Arc<App>>,
Query(p): Query<ForgeParams>,
@@ -29,11 +32,21 @@ pub async fn get_forge(
let did = knotless::forge::parse_uri(&p.repo)
.map_err(|e| AppError(StatusCode::BAD_REQUEST, e.to_string()))?
.0;
let entries = index(&app)?
let mut entries = index(&app)?
.forge(did, &p.key)
.map_err(anyhow::Error::from)
.map_err(AppError::internal)?;
Ok(Json(json!({ "entries": entries })))
entries.sort_by(|a, b| a.uri.cmp(&b.uri));
let after = p.cursor.unwrap_or_default();
let limit = p.limit.unwrap_or(500).clamp(1, 1000);
let mut page: Vec<_> = entries.into_iter().filter(|e| e.uri > after).collect();
let more = page.len() > limit;
page.truncate(limit);
let mut out = json!({ "entries": page });
if more {
out["cursor"] = page[limit - 1].uri.clone().into();
}
Ok(Json(out))
}
#[derive(Deserialize)]
crates/knotless-serve/src/gateway.rs+1−1
@@ -328,7 +328,7 @@ async fn attempt(
loop {
let (remote, session, snap) = writable(app, caller, &req.repo, &req.name).await?;
let mirror = mirror(app, &remote, &snap).await?;
let start = snap.find_ref(&refname).and_then(|r| r.value.target.clone());
let start = commit::start(&snap, &batch[0].spec);
let mut tip = start.clone();
let mut built: Vec<Result<Committed, String>> = Vec::new();
for pending in batch {
crates/knotless-serve/src/web.rs+5−13
@@ -489,22 +489,14 @@ async fn handle(app: &App, did: &str) -> String {
if let Some(known) = HANDLES.lock().unwrap().get(did) {
return known.clone();
}
let doc = knotless::identity::document(&app.ctx, did).await.ok();
let claimed = doc
.as_ref()
.and_then(|d| d["alsoKnownAs"][0].as_str())
.and_then(|a| a.strip_prefix("at://"))
.unwrap_or(did);
let verified = knotless::identity::resolve_handle(&app.ctx, claimed)
let shown = knotless::identity::handle(&app.ctx, did)
.await
.ok();
let resolves = |_: &&str| verified.as_deref() == Some(did);
let shown = Some(claimed).filter(resolves).unwrap_or(did);
.unwrap_or(did.to_string());
HANDLES
.lock()
.unwrap()
.insert(did.to_string(), shown.to_string());
shown.to_string()
.insert(did.to_string(), shown.clone());
shown
}
/// A repository as `handle/name`, under the name it has now if the index
@@ -1581,7 +1573,7 @@ async fn item(
let entries = assembled(&app, &repo)?;
let record = repo.record(&app).unwrap_or_default();
let uri = format!("at://{did}/{collection}/{rkey}");
let Thread { item, comments } =
let Thread { item, comments, .. } =
forge::thread(&record, &repo.did, &entries, &uri).map_err(not_found)?;
let tab = if kind == "pull" {
Tab::Pulls
crates/knotless-serve/tests/forge.rs+52−1
@@ -223,10 +223,18 @@ fn forks_pulls_reviews_and_merges() {
let sha = ok(&contrib, &["merge", &url, &third]);
let (_, refs) = owner.cli(&["refs", &url], b"");
assert!(refs.contains(&format!("{sha}\trefs/heads/main")), "{refs}");
// And makes and deletes branches the same way, as the owner does.
assert_eq!(ok(&contrib, &["branch", &url, "topic"]), sha);
assert_eq!(
ok(&contrib, &["branch", &url, "topic", "--delete"]),
"deleted topic"
);
assert_eq!(ok(&owner, &["branch", &url, "pinned", &two]), two);
ok(&owner, &["branch", &url, "pinned", "--delete"]);
// Someone else's commit, elsewhere on the firehose, changes nothing.
av.app.observe(knotless_serve::firehose::Event::default());
assert_eq!(av.app.events_for(&account().did), 0);
assert_eq!(av.app.events_for("did:plc:nobody"), 0);
// A record gone before the index fetched it is an error to skip.
let gone = knotless_serve::firehose::Op {
@@ -433,6 +441,31 @@ fn issues_and_who_may_close_them() {
))
.unwrap();
assert_eq!(res.status().as_u16(), 400);
// Every record comes back, a page at a time.
let key = owner.record("dev.knotless.repo", "tracked")["key"].clone();
let repo = format!("at://{}/dev.knotless.repo/tracked", owner.account.did);
let forge = |cursor: &str| -> Value {
reqwest::blocking::get(format!(
"{}/xrpc/dev.knotless.getForge?repo={repo}&key={}&limit=1&cursor={cursor}",
av.base,
key.as_str().unwrap()
))
.unwrap()
.json()
.unwrap()
};
let (mut paged, mut cursor) = (0, String::new());
loop {
let page = forge(&cursor);
assert!(page["entries"].as_array().unwrap().len() <= 1);
paged += page["entries"].as_array().unwrap().len();
match page["cursor"].as_str() {
Some(next) => cursor = next.to_string(),
None => break,
}
}
assert!(paged > 1, "{paged}");
}
#[test]
@@ -497,6 +530,15 @@ fn private_repositories_keep_their_issues_in_the_space() {
assert!(refs.contains(&format!("{fix}\trefs/heads/main")), "{refs}");
let anonymous = Sandbox::new();
assert_eq!(anonymous.cli(&["refs", &fork], b"").0, 1);
// Its source's owner reads it, to take a pull from it.
let (code, refs) = owner.cli(&["refs", &fork], b"");
assert_eq!(code, 0, "{refs}");
let readers = ok(&reader, &["members", &fork]);
assert!(readers.contains(owner.account.did.as_str()), "{readers}");
// A fork of one's own is shared with nobody more.
let own = ok(&owner, &["fork", &url, "inner-copy"]);
let readers = ok(&owner, &["members", &own]);
assert_eq!(readers.lines().count(), 1, "{readers}");
}
#[test]
@@ -655,6 +697,7 @@ fn moderating() {
// The owner hides an issue, and what was said about it goes with it.
assert_eq!(ok(&owner, &["hide", &url, fair]), "hidden");
assert_eq!(ok(&owner, &["hide", &url, fair]), "hidden");
unlisted(&reporter, &url, fair);
assert_eq!(reporter.cli(&["show", &url, fair], b"").0, 1);
ok(&owner, &["hide", &url, reply]);
@@ -681,6 +724,8 @@ fn moderating() {
ok(&owner, &["block", &troll.account.handle, &url]);
ok(&owner, &["block", &troll.account.handle, &url]);
unlisted(&reporter, &url, trolling);
// Nor does it show by its URI, however new.
assert_eq!(reporter.cli(&["show", &url, trolling], b"").0, 1);
fails(&reporter, &["block", &troll.account.handle, &url]);
ok(&owner, &["unblock", &troll.account.handle, &url]);
listed(&reporter, &url, trolling);
@@ -894,4 +939,10 @@ fn moderating() {
// Labels follow from where they were read to.
let labels = other.app.index.as_ref().unwrap();
assert!(labels.labeler_seq(&av.did).unwrap().is_some());
// One taken down as soon as it is opened does not show by its URI,
// indexed yet or not.
let fresh = ok(&troll, &["issue", &url, "Again"]);
let fresh = fresh.trim();
ok(&admin, &["admin", "takedown", fresh]);
assert_eq!(reporter.cli(&["show", &url, fresh], b"").0, 1);
}
crates/knotless-serve/tests/gateway.rs+1−1
@@ -263,7 +263,7 @@ fn collaborators_push_and_commit_through_a_gateway() {
owner.git(&work, &["push", "-q", &lonely, "main"]);
let (ok, _, err) = outsider.git_raw(&elsewhere, &["push", &lonely, "main"]);
assert!(!ok);
assert!(err.contains("names no gateway"), "{err}");
assert!(err.contains("only its owner can"), "{err}");
let (code, _) = outsider.cli(
&["commit", &lonely, "-b", "main", "-m", "m", "--delete", "f"],
b"",
crates/knotless/src/cli.rs+21−13
@@ -80,6 +80,9 @@ const USAGE: &str = "usage: knotless <command>
comment <url> <at-uri> <text> [--approve | --changes]
close <url> <at-uri>
reopen <url> <at-uri>
branch <url> <name> [<from>] create a branch, at the default branch
or a commit or branch
branch <url> <name> --delete
retarget <url> <at-uri> [--base <branch>] [--head <branch>]
point a pull at other branches; the head
only by the pull's author
@@ -212,18 +215,10 @@ async fn command(
writeln!(out, "created {url}")?;
}
"ls" => {
let authority = args.next("<handle>")?;
let remote = Remote::connect(
ctx,
&RepoUrl {
authority: authority.into(),
name: "_".into(),
},
)
.await?;
for repo in remote.list_repos().await? {
let v = repo.value;
writeln!(out, "{}\t{}", v.name, v.description.unwrap_or_default())?;
for repo in manage::list(ctx, args.next("<handle>")?).await? {
let private = if repo.private { "\tprivate" } else { "" };
let about = repo.description.unwrap_or_default();
writeln!(out, "{}\t{about}{private}", repo.name)?;
}
}
"delegate" => {
@@ -367,7 +362,8 @@ async fn command(
let url = args.url()?;
let t = forge::show(ctx, &url, args.next("<at-uri>")?).await?;
let i = &t.item;
writeln!(out, "{} {} by {}: {}", i.state, i.kind, i.author, i.title)?;
let by = i.handle.as_deref().unwrap_or(&i.author);
writeln!(out, "{} {} by {by}: {}", i.state, i.kind, i.title)?;
if let Some(body) = &t.item.body {
writeln!(out, "\n{body}")?;
}
@@ -400,6 +396,18 @@ async fn command(
forge::set_state(ctx, &url, subject, state).await?;
writeln!(out, "{state}")?;
}
"branch" => {
let url = args.url()?;
let name = args.next("<name>")?;
let (from, delete) = match args.rest.next().map(String::as_str) {
Some("--delete") => (None, true),
from => (from, false),
};
match forge::branch(ctx, &url, name, from, delete).await? {
Some(sha) => writeln!(out, "{sha}")?,
None => writeln!(out, "deleted {name}")?,
}
}
"retarget" => {
let url = args.url()?;
let pull = args.next("<at-uri>")?.to_string();
crates/knotless/src/commit.rs+27−4
@@ -13,8 +13,8 @@ use crate::git::Git;
use crate::mirror::Mirror;
use crate::push::{self, Update};
use crate::read::BadInput;
use crate::remote::Remote;
use crate::session;
use crate::remote::{Remote, Snapshot};
use crate::session::{self, Session};
use crate::url::RepoUrl;
use anyhow::{Result, bail};
use std::path::PathBuf;
@@ -58,14 +58,37 @@ pub struct Committed {
pub changed: bool,
}
/// Where a commit to `spec.branch` starts: the branch's tip; for a new
/// branch, the commit the changes were made against, or else the tip of the
/// repository's default branch.
pub fn start(snap: &Snapshot, spec: &Spec) -> Option<String> {
let tip = |branch: &str| {
snap.find_ref(&format!("refs/heads/{branch}"))
.and_then(|r| r.value.target.clone())
};
tip(&spec.branch).or_else(|| spec.base.clone()).or_else(|| {
let repo = snap.repo.as_ref()?;
tip(repo.value.default_branch.as_deref().unwrap_or("main"))
})
}
/// The commit's author: as given, or whoever is committing.
fn signed(spec: &Spec, me: &Session) -> Spec {
let mut spec = spec.clone();
spec.author
.get_or_insert_with(|| (me.handle.clone(), format!("{}@knotless.invalid", me.did)));
spec
}
pub async fn commit(ctx: &Ctx, url: &RepoUrl, spec: &Spec) -> Result<Committed> {
let remote = Remote::locate(ctx, url).await?.0;
if let Some(me) = session::reader(ctx, &remote.identity).await
&& (me.did != remote.did() || spec.via_gateway)
{
return gateway::commit(ctx, &remote, &me, spec).await;
return gateway::commit(ctx, &remote, &me, &signed(spec, &me)).await;
}
let session = session::ensure(ctx, &remote.identity).await?;
let spec = &signed(spec, &session);
let refname = format!("refs/heads/{}", spec.branch);
let mut attempts = 0;
loop {
@@ -84,7 +107,7 @@ pub async fn commit(ctx: &Ctx, url: &RepoUrl, spec: &Spec) -> Result<Committed>
(Git::init_bare(&dir).await?, Some(Scratch(dir)))
}
};
let parent = snap.find_ref(&refname).and_then(|r| r.value.target.clone());
let parent = start(&snap, spec);
conflicts(&git, spec, parent.as_deref()).await?;
let built = build(ctx, &git, parent.as_deref(), spec, remote.did()).await?;
let Some(sha) = built else {
crates/knotless/src/forge.rs+388−87
@@ -24,7 +24,7 @@ use crate::manage::{self, NewRepo};
use crate::mirror::Mirror;
use crate::push::{self, Update};
use crate::records::{
self, BlockRecord, CommentRecord, GateRecord, IssueRecord, PullHead, PullRecord, RepoRecord,
self, BlockRecord, CommentRecord, HideRecord, IssueRecord, PullHead, PullRecord, RepoRecord,
RepoRef, StateRecord, nsid,
};
use crate::remote::{self, Remote, Snapshot};
@@ -35,6 +35,7 @@ use crate::xrpc::Xrpc;
use anyhow::{Context, Result, bail};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::BTreeMap;
/// The `at://` URI of `did`'s repository record for `name`.
pub fn repo_uri(did: &str, name: &str) -> String {
@@ -87,6 +88,9 @@ impl Entry {
pub struct Item {
pub uri: String,
pub author: String,
/// The author's handle, when it is known to resolve back to them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub handle: Option<String>,
/// `pull` or `issue`.
pub kind: String,
pub title: String,
@@ -107,11 +111,14 @@ pub struct Item {
pub struct Thread {
pub item: Item,
pub comments: Vec<Entry>,
/// The handles of those who wrote them, by DID.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub handles: BTreeMap<String, String>,
}
/// `entries` without what the owner of `did`'s repository `key` keeps out:
/// what accounts in `blocked`, or blocked from this repository alone,
/// wrote, and what its gate hides, with what was said about that.
/// wrote, and what they hide, with what was said about that.
pub fn moderate(did: &str, key: &str, blocked: &[String], entries: Vec<Entry>) -> Vec<Entry> {
let owners = |collection: &'static str| {
entries
@@ -121,9 +128,9 @@ pub fn moderate(did: &str, key: &str, blocked: &[String], entries: Vec<Entry>) -
})
.map(|e| e.value.clone())
};
let hidden: Vec<String> = owners(nsid::GATE)
.filter_map(|v| serde_json::from_value::<GateRecord>(v).ok())
.flat_map(|g| g.hidden)
let hidden: Vec<String> = owners(nsid::HIDE)
.filter_map(|v| serde_json::from_value::<HideRecord>(v).ok())
.map(|h| h.subject)
.collect();
let blocked: Vec<String> = owners(nsid::BLOCK)
.filter_map(|v| serde_json::from_value::<BlockRecord>(v).ok())
@@ -138,6 +145,38 @@ pub fn moderate(did: &str, key: &str, blocked: &[String], entries: Vec<Entry>) -
.collect()
}
/// A page of `dev.knotless.getForge`.
#[derive(Deserialize, Debug, Default)]
pub struct Page {
pub entries: Vec<Entry>,
pub cursor: Option<String>,
}
/// The `dev.knotless.getForge` parameters for `repo`'s page after `cursor`.
fn page_params<'a>(repo: &'a RepoRef, cursor: Option<&'a str>) -> Vec<(&'static str, &'a str)> {
let mut params = vec![("repo", repo.uri.as_str()), ("key", repo.key.as_str())];
params.extend(cursor.map(|c| ("cursor", c)));
params
}
/// Every entry, fetching pages from each cursor until there is none.
async fn every_page<F, Fut>(mut fetch: F) -> Result<Vec<Entry>>
where
F: FnMut(Option<String>) -> Fut,
Fut: std::future::Future<Output = Result<Page>>,
{
let mut entries = Vec::new();
let mut cursor = None;
loop {
let page = fetch(cursor).await?;
entries.extend(page.entries);
match page.cursor {
Some(next) => cursor = Some(next),
None => return Ok(entries),
}
}
}
/// Who may change an item's state: its author, and the repository's owner
/// and collaborators. Only the latter may merge.
/// The owner the repository had before it moved here counts too, for
@@ -184,6 +223,7 @@ pub fn assemble(repo: &RepoRecord, owner: &str, entries: &[Entry]) -> Vec<Item>
items.push(Item {
uri: e.uri.clone(),
author: e.author.clone(),
handle: None,
kind: kind.into(),
title,
body,
@@ -211,9 +251,12 @@ pub fn assemble(repo: &RepoRecord, owner: &str, entries: &[Entry]) -> Vec<Item>
states.sort_by(|a, b| a.0.cmp(b.0));
for item in &mut items {
for (at, e, s) in states.iter().filter(|(_, _, s)| s.subject == item.uri) {
if allowed(repo, owner, &item.author, &e.author, &s.state, at) {
item.state = s.state.clone();
item.merged = s.commit.clone().filter(|_| s.state == "merged");
let state = s.state.as_deref().unwrap_or_default();
if allowed(repo, owner, &item.author, &e.author, state, at) {
if let Some(state) = &s.state {
item.state = state.clone();
item.merged = s.commit.clone().filter(|_| state == "merged");
}
if let (Some(base), Some(_)) = (&s.base, &item.base) {
item.base = Some(base.clone());
}
@@ -237,14 +280,20 @@ pub fn thread(repo: &RepoRecord, owner: &str, entries: &[Entry], subject: &str)
let item = assemble(repo, owner, entries)
.into_iter()
.find(|i| i.uri == subject)
.with_context(|| format!("no pull or issue {subject}"))?;
.with_context(|| {
format!("no pull or issue {subject}: it may have been deleted, or hidden by the repository's owner")
})?;
let mut comments: Vec<Entry> = entries
.iter()
.filter(|e| e.collection() == nsid::COMMENT && e.value["subject"] == subject)
.cloned()
.collect();
comments.sort_by(|a, b| a.uri.rsplit('/').next().cmp(&b.uri.rsplit('/').next()));
Ok(Thread { item, comments })
Ok(Thread {
item,
comments,
handles: BTreeMap::new(),
})
}
/// The repository `record`, named `name`, says it was moved from, if the
@@ -330,22 +379,19 @@ impl Target {
}
async fn indexed_entries(&self, ctx: &Ctx) -> Result<Vec<Entry>> {
#[derive(Deserialize)]
struct Out {
entries: Vec<Entry>,
}
let appview = ctx.env.appview.as_deref().context(
"pulls and issues on public repositories come from an appview: set KNOTLESS_APPVIEW",
)?;
let repo = self.reference();
let out: Out = Xrpc::new(ctx.http.clone(), appview)
.query(
"dev.knotless.getForge",
&[("repo", &repo.uri), ("key", &repo.key)],
None,
)
.await?;
Ok(out.entries)
let xrpc = Xrpc::new(ctx.http.clone(), appview);
every_page(|cursor| {
let (xrpc, repo) = (&xrpc, &repo);
async move {
let params = page_params(repo, cursor.as_deref());
xrpc.query("dev.knotless.getForge", &params, None).await
}
})
.await
}
/// The private repository's space, joined as whoever is logged in:
@@ -481,6 +527,33 @@ impl Target {
/// The item `subject` names as it stands, with what its author's repo
/// has now rather than what the index last saw.
async fn item(&self, ctx: &Ctx, subject: &str) -> Result<(Item, Value)> {
let (entries, value) = self.around(ctx, subject).await?;
let item = thread(&self.record, self.remote.did(), &entries, subject)?.item;
Ok((item, value))
}
/// Whether the owner hides `subject`, or blocks its author from this
/// repository or all of theirs.
async fn kept_out(&self, ctx: &Ctx, subject: &str) -> Result<bool> {
let (author, _, _) = parse_uri(subject)?;
if taken_down(ctx, subject, author).await {
return Ok(true);
}
let key = self.reference().key;
let hides = self.remote.list_all::<HideRecord>(nsid::HIDE).await?;
let blocks = self.remote.list_all::<BlockRecord>(nsid::BLOCK).await?;
let hidden = hides
.iter()
.any(|h| h.value.subject == subject && h.value.repo.key == key);
let blocked = blocks.iter().any(|b| {
b.value.subject == author && b.value.repo.as_ref().is_none_or(|r| r.key == key)
});
Ok(hidden || blocked)
}
/// The entries, with `subject` as its author's repo has it now, not as
/// the index last saw it: the index may not have it yet.
async fn around(&self, ctx: &Ctx, subject: &str) -> Result<(Vec<Entry>, Value)> {
let (_, value) = self.subject(ctx, subject).await?;
let mut entries = self.entries(ctx).await?;
entries.retain(|e| e.uri != subject);
@@ -489,8 +562,7 @@ impl Target {
author: parse_uri(subject)?.0.to_string(),
value: value.clone(),
});
let item = thread(&self.record, self.remote.did(), &entries, subject)?.item;
Ok((item, value))
Ok((entries, value))
}
}
@@ -507,28 +579,34 @@ pub async fn hide(ctx: &Ctx, target: &RepoUrl, subject: &str, hidden: bool) -> R
bail!("only {}'s owner can hide what is said about it", t.url);
}
let reference = t.reference();
let found = mine
.get_record::<GateRecord>(nsid::GATE, &reference.key)
.await?;
let mut gate = found.as_ref().map_or_else(
|| GateRecord {
repo: reference.clone(),
hidden: Vec::new(),
created_at: records::now(),
},
|f| f.value.clone(),
);
gate.repo = reference.clone();
gate.hidden.retain(|h| h != subject);
if hidden {
gate.hidden.push(subject.to_string());
}
let value = records::typed(nsid::GATE, &gate);
let write = match found {
Some(_) => remote::update(nsid::GATE, &reference.key, value),
None => remote::create(nsid::GATE, &reference.key, value),
let existing: Vec<String> = mine
.list_all::<HideRecord>(nsid::HIDE)
.await?
.into_iter()
.filter(|h| h.value.subject == subject && h.value.repo.key == reference.key)
.map(|h| h.rkey)
.collect();
let record = HideRecord {
repo: reference,
subject: subject.to_string(),
created_at: records::now(),
};
mine.apply(vec![write], &me.access_jwt).await
let writes: Vec<Value> = match (hidden, existing.is_empty()) {
(true, true) => vec![remote::create(
nsid::HIDE,
&keys::tid(),
records::typed(nsid::HIDE, &record),
)],
(true, false) => Vec::new(),
(false, _) => existing
.iter()
.map(|rkey| remote::delete(nsid::HIDE, rkey))
.collect(),
};
if !writes.is_empty() {
mine.apply(writes, &me.access_jwt).await?;
}
Ok(())
}
pub async fn open_pull(
@@ -643,7 +721,7 @@ async fn write_state(
let record = StateRecord {
repo: t.reference(),
subject: subject.to_string(),
state: state.to_string(),
state: Some(state.to_string()),
commit,
base: None,
created_at: records::now(),
@@ -660,12 +738,48 @@ pub async fn merge(ctx: &Ctx, target: &RepoUrl, pull: &str) -> Result<String> {
// The pull as its author's repo has it now, not as the index last saw
// it: the index may not have it yet.
let (item, _) = t.item(ctx, pull).await?;
let (Some(base), Some(head)) = (&item.base, &item.head) else {
bail!("{pull} is an issue");
};
if item.state != "open" {
bail!("{pull} is {}", item.state);
}
let sides = sides(ctx, &t, &item).await?;
let (git, base, head) = (&sides.git, &sides.base, &sides.head);
let me = session::me(ctx).await?;
let merged = if git.is_ancestor(base, head).await? {
head.clone()
} else {
match merge_tree(git, base, head).await? {
Ok(tree) => merge_commit(git, &tree, base, head, &item, &me).await?,
Err(files) => bail!("merging conflicts in {}", files.join(", ")),
}
};
let update = Update {
src: Some(merged.clone()),
dst: sides.refname.clone(),
force: false,
};
let report = if me.did == t.remote.did() {
push::push(ctx, &t.remote, &t.snap, git, &me.access_jwt, &[update]).await?
} else {
gateway::push(ctx, &t.remote, &t.snap, git, &me, &[update]).await?
};
accepted(report)?;
write_state(ctx, &t, pull, "merged", Some(merged.clone())).await?;
Ok(merged)
}
/// A pull's two sides, in the target's mirror, with the head fetched from
/// wherever it is.
struct Sides {
git: Git,
refname: String,
base: String,
head: String,
}
async fn sides(ctx: &Ctx, t: &Target, item: &Item) -> Result<Sides> {
let (Some(base), Some(head)) = (&item.base, &item.head) else {
bail!("{} is an issue", item.uri);
};
let key = t.snap.key().expect("an opened repository has a key");
let mirror = Mirror::open(ctx, t.remote.did(), key).await?;
mirror.sync(&t.remote, &t.snap).await?;
@@ -690,31 +804,143 @@ pub async fn merge(ctx: &Ctx, target: &RepoUrl, pull: &str) -> Result<String> {
.snap
.find_ref(&refname)
.and_then(|r| r.value.target.clone())
.with_context(|| format!("{target} has no branch {base}"))?;
let me = session::me(ctx).await?;
let merged = if git.is_ancestor(&base_sha, &head_sha).await? {
head_sha
.with_context(|| format!("{} has no branch {base}", t.url))?;
Ok(Sides {
git,
refname,
base: base_sha,
head: head_sha,
})
}
/// What a pull brings in, and whether it merges.
#[derive(Serialize, Debug, Clone, PartialEq)]
pub struct Comparison {
/// The tip of the branch it targets.
pub base: String,
/// The tip of the branch it brings in.
pub head: String,
/// What the head has that the base does not, newest first.
pub commits: Vec<crate::read::Commit>,
/// `merged` when the base has all of it already, `fast-forward`,
/// `clean` for a merge commit without conflicts, or `conflict`.
pub mergeable: String,
/// The paths that conflict.
#[serde(skip_serializing_if = "Vec::is_empty")]
pub conflicts: Vec<String>,
}
/// Compares a pull's head with its target, merging nothing.
pub async fn compare(ctx: &Ctx, target: &RepoUrl, pull: &str) -> Result<Comparison> {
let t = Target::open(ctx, target).await?;
let (item, _) = t.item(ctx, pull).await?;
let sides = sides(ctx, &t, &item).await?;
let (git, base, head) = (&sides.git, &sides.base, &sides.head);
let commits = crate::read::log(git, &format!("{base}..{head}"), "", 250).await?;
let (mergeable, conflicts) = if git.is_ancestor(head, base).await? {
("merged", Vec::new())
} else if git.is_ancestor(base, head).await? {
("fast-forward", Vec::new())
} else {
merge_commit(&git, &base_sha, &head_sha, &item, &me).await?
match merge_tree(git, base, head).await? {
Ok(_) => ("clean", Vec::new()),
Err(files) => ("conflict", files),
}
};
Ok(Comparison {
base: base.clone(),
head: head.clone(),
commits,
mergeable: mergeable.into(),
conflicts,
})
}
/// What a pull changes: its head against where it left its target.
pub async fn pull_diff(ctx: &Ctx, target: &RepoUrl, pull: &str) -> Result<String> {
let t = Target::open(ctx, target).await?;
let (item, _) = t.item(ctx, pull).await?;
let sides = sides(ctx, &t, &item).await?;
crate::read::diff(&sides.git, &sides.base, &sides.head).await
}
/// Creates `branch` at `from`, a commit or branch, or the default branch
/// when `None`; or, with `delete`, deletes it. Directly as the owner,
/// through the gateway as a collaborator. Returns the commit it points at.
pub async fn branch(
ctx: &Ctx,
url: &RepoUrl,
branch: &str,
from: Option<&str>,
delete: bool,
) -> Result<Option<String>> {
let t = Target::open(ctx, url).await?;
let refname = format!("refs/heads/{branch}");
// A deleted ref keeps its record, with no target.
let exists = t
.snap
.find_ref(&refname)
.is_some_and(|r| r.value.target.is_some());
let default = t.record.default_branch.as_deref().unwrap_or("main");
match (delete, exists) {
(true, false) => bail!("{} has no branch {branch}", t.url),
(true, true) if branch == default => {
bail!("{branch} is {}'s default branch", t.url)
}
(false, true) => bail!("{} already has a branch {branch}", t.url),
_ => {}
}
let key = t.snap.key().expect("an opened repository has a key");
let mirror = Mirror::open(ctx, t.remote.did(), key).await?;
mirror.sync(&t.remote, &t.snap).await?;
let git = mirror.git;
let src = match delete {
true => None,
false => {
let rev = crate::read::rev(Some(from.unwrap_or(default)))?;
let found = git.rev_parse(&format!("{rev}^{{commit}}")).await;
Some(found.with_context(|| format!("{} has no commit or branch {rev}", t.url))?)
}
};
let update = Update {
src: Some(merged.clone()),
dst: refname.clone(),
src: src.clone(),
dst: refname,
force: false,
};
let me = session::me(ctx).await?;
let report = if me.did == t.remote.did() {
push::push(ctx, &t.remote, &t.snap, &git, &me.access_jwt, &[update]).await?
} else {
gateway::push(ctx, &t.remote, &t.snap, &git, &me, &[update]).await?
};
accepted(report)?;
write_state(ctx, &t, pull, "merged", Some(merged.clone())).await?;
Ok(merged)
Ok(src)
}
/// Renames `from` to `to`: makes `to` where `from` is, points the open
/// pulls that target `from` at `to`, then deletes `from`, so a rename cut
/// short loses nothing. Not the default branch. Returns the pulls moved.
pub async fn rename_branch(ctx: &Ctx, url: &RepoUrl, from: &str, to: &str) -> Result<Vec<String>> {
let t = Target::open(ctx, url).await?;
if from == t.record.default_branch.as_deref().unwrap_or("main") {
bail!("{from} is {}'s default branch", t.url);
}
branch(ctx, url, to, Some(from), false).await?;
let mut moved = Vec::new();
for pull in list(ctx, url).await? {
if pull.state == "open" && pull.base.as_deref() == Some(from) {
retarget(ctx, url, &pull.uri, Some(to), None).await?;
moved.push(pull.uri);
}
}
branch(ctx, url, from, None, true).await?;
Ok(moved)
}
/// Points a pull at other branches. The target branch changes for anyone
/// who may change the pull's state, by a state record; the head branch only
/// for the pull's author, who rewrites the pull record.
/// who may change the pull's state, by a state record that leaves the state
/// as it is; the head branch only for the pull's author, who rewrites the
/// pull record.
pub async fn retarget(
ctx: &Ctx,
target: &RepoUrl,
@@ -737,7 +963,7 @@ pub async fn retarget(
let record = StateRecord {
repo: t.reference(),
subject: pull.to_string(),
state: item.state.clone(),
state: None,
commit: None,
base: Some(base.to_string()),
created_at: records::now(),
@@ -779,23 +1005,31 @@ fn accepted(report: push::Report) -> Result<()> {
}
}
/// The tree merging `head` into `base` makes, or the paths that conflict.
async fn merge_tree(git: &Git, base: &str, head: &str) -> Result<Result<String, Vec<String>>> {
let out = git
.output_of(&["merge-tree", "--write-tree", "--name-only", base, head])
.await?;
let text = String::from_utf8_lossy(&out.stdout);
let mut lines = text.lines();
let tree = lines.next().unwrap_or_default().to_string();
Ok(match out.status.success() {
true => Ok(tree),
false => Err(lines
.take_while(|l| !l.is_empty())
.map(str::to_string)
.collect()),
})
}
async fn merge_commit(
git: &Git,
tree: &str,
base: &str,
head: &str,
item: &Item,
me: &Session,
) -> Result<String> {
let out = git
.output_of(&["merge-tree", "--write-tree", "--name-only", base, head])
.await?;
let text = String::from_utf8_lossy(&out.stdout);
let mut lines = text.lines();
let tree = lines.next().unwrap_or_default().to_string();
if !out.status.success() {
let files: Vec<&str> = lines.take_while(|l| !l.is_empty()).collect();
bail!("merging conflicts in {}", files.join(", "));
}
let mut git = git.clone();
let email = format!("{}@knotless.invalid", me.did);
git.envs.extend([
@@ -806,14 +1040,15 @@ async fn merge_commit(
]);
let message = format!("Merge {}\n\n{}", item.title, item.uri);
git.text(
&["commit-tree", &tree, "-p", base, "-p", head, "-m", &message],
&["commit-tree", tree, "-p", base, "-p", head, "-m", &message],
None,
)
.await
}
/// Forks a repository into my account, under `name` or its own. A private
/// repository forks into a private one.
/// repository forks into a private one, which its owner can read, to take
/// pulls from it.
pub async fn fork(ctx: &Ctx, source: &RepoUrl, name: Option<&str>) -> Result<RepoUrl> {
let src = Target::open(ctx, source).await?;
let me = session::me(ctx).await?;
@@ -827,7 +1062,11 @@ pub async fn fork(ctx: &Ctx, source: &RepoUrl, name: Option<&str>) -> Result<Rep
fork_of: Some(src.reference()),
..Default::default()
};
let upstream = (new.private && src.remote.did() != me.did).then_some(src.remote.did());
copy(ctx, &src, &dest, new, &me).await?;
if let Some(owner) = upstream {
manage::share(ctx, &dest, owner, false).await?;
}
Ok(dest)
}
@@ -883,16 +1122,52 @@ async fn copy(ctx: &Ctx, src: &Target, dest: &RepoUrl, new: NewRepo, me: &Sessio
}
/// Pulls or issues on a repository, newest first.
/// The pulls and issues on a repository, newest first, with their authors'
/// handles.
pub async fn list(ctx: &Ctx, target: &RepoUrl) -> Result<Vec<Item>> {
let t = Target::open(ctx, target).await?;
let entries = t.entries(ctx).await?;
Ok(assemble(&t.record, t.remote.did(), &entries))
let mut items = assemble(&t.record, t.remote.did(), &entries);
let handles = identity::handles(ctx, items.iter().map(|i| i.author.as_str())).await;
for item in &mut items {
item.handle = handles.get(&item.author).cloned();
}
Ok(items)
}
/// Whether the appview has taken `subject` or its author down. One it
/// cannot ask counts as taken down: it waits for the index.
async fn taken_down(ctx: &Ctx, subject: &str, author: &str) -> bool {
#[derive(Deserialize)]
struct Out {
labels: Vec<Value>,
}
let appview = ctx.env.appview.as_deref().unwrap_or_default();
let xrpc = Xrpc::new(ctx.http.clone(), appview);
let params = [("uriPatterns", subject), ("uriPatterns", author)];
let out = xrpc.query("com.atproto.label.queryLabels", &params, None);
out.await.map_or(true, |o: Out| !o.labels.is_empty())
}
/// A pull or issue and what was said about it, with handles for everyone
/// who wrote any of it.
pub async fn show(ctx: &Ctx, target: &RepoUrl, subject: &str) -> Result<Thread> {
let t = Target::open(ctx, target).await?;
let entries = t.entries(ctx).await?;
thread(&t.record, t.remote.did(), &entries, subject)
let mut entries = t.entries(ctx).await?;
// One the appview's index lacks is kept out, or too new for it yet:
// the newest are read from their authors' repos, unless the appview's
// takedowns or the owner's own hides and blocks keep them out.
let (_, _, rkey) = parse_uri(subject)?;
let age = keys::tid_micros(rkey).map(|t| keys::now_micros().saturating_sub(t));
let missing = t.remote.space.is_none() && !entries.iter().any(|e| e.uri == subject);
if missing && age.is_some_and(|a| a < 60_000_000) && !t.kept_out(ctx, subject).await? {
entries = t.around(ctx, subject).await?.0;
}
let mut thread = thread(&t.record, t.remote.did(), &entries, subject)?;
let authors = thread.comments.iter().map(|c| c.author.as_str());
thread.handles = identity::handles(ctx, authors.chain([thread.item.author.as_str()])).await;
thread.item.handle = thread.handles.get(&thread.item.author).cloned();
Ok(thread)
}
#[cfg(test)]
@@ -934,6 +1209,31 @@ mod tests {
));
}
#[tokio::test]
async fn pages() {
let one = |n: &str| entry("did:a", nsid::ISSUE, n, json!({}));
let got = every_page(|cursor| async move {
Ok(match cursor.as_deref() {
None => Page {
entries: vec![one("1")],
cursor: Some("1".into()),
},
Some(_) => Page {
entries: vec![one("2")],
cursor: None,
},
})
})
.await
.unwrap();
assert_eq!(got, [one("1"), one("2")]);
let failed = every_page(|_| async { anyhow::bail!("down") }).await;
assert!(failed.is_err());
let repo = RepoRef::new("did:o", "r", "k");
assert_eq!(page_params(&repo, None).len(), 2);
assert_eq!(page_params(&repo, Some("c"))[2], ("cursor", "c"));
}
#[test]
fn what_the_owner_keeps_out() {
let issue =
@@ -946,12 +1246,12 @@ mod tests {
json!({"repo": r(), "subject": on}),
)
};
let gate = |author: &str, hidden: &[&str]| {
let hide = |author: &str, rkey: &str, subject: &str| {
entry(
author,
nsid::GATE,
"k",
json!({"repo": r(), "hidden": hidden, "createdAt": "t"}),
nsid::HIDE,
rkey,
json!({"repo": r(), "subject": subject, "createdAt": "t"}),
)
};
let block = |author: &str, subject: &str| {
@@ -973,9 +1273,9 @@ mod tests {
kept_out,
comment("4", &hidden.uri),
comment("5", &shown.uri),
// Only the owner's gate and blocks count.
gate("did:a", &[&shown.uri]),
gate("did:o", &[&hidden.uri]),
// Only the owner's hides and blocks count.
hide("did:a", "h1", &shown.uri),
hide("did:o", "h2", &hidden.uri),
block("did:a", "did:o"),
block("did:o", "did:c"),
];
@@ -988,8 +1288,8 @@ mod tests {
[
shown.uri.clone(),
"at://did:a/dev.knotless.comment/5".into(),
"at://did:a/dev.knotless.gate/k".into(),
"at://did:o/dev.knotless.gate/k".into(),
// The owner's hide goes with what it hides.
"at://did:a/dev.knotless.hide/h1".into(),
"at://did:a/dev.knotless.block/did:o".into(),
"at://did:o/dev.knotless.block/did:c".into(),
]
@@ -1062,12 +1362,13 @@ mod tests {
state("did:a", "a3", p2, "merged"),
state("did:o", "a4", p2, "merged"),
state("did:c", "a5", i3, "closed"),
// A collaborator points the pull at another branch.
// A collaborator points the pull at another branch, leaving
// its state as it is.
entry(
"did:c",
nsid::STATE,
"a6",
json!({"repo": r(), "subject": p1, "state": "closed", "base": "next", "createdAt": "t"}),
json!({"repo": r(), "subject": p1, "base": "next", "createdAt": "t"}),
),
entry(
"did:x",
crates/knotless/src/gateway.rs+19−3
@@ -45,9 +45,8 @@ fn gateway_of<'a>(remote: &Remote, snap: &'a Snapshot) -> Result<&'a str> {
match snap.repo.as_ref().and_then(|r| r.value.gateway.as_deref()) {
Some(gateway) => Ok(gateway),
None => bail!(
"{} belongs to {}, and names no gateway to write through",
remote.url,
remote.did()
"you cannot write to {}: only its owner can, until they share it for writing",
remote.url
),
}
}
@@ -309,6 +308,23 @@ fn gateway_path(ctx: &Ctx, did: &str) -> std::path::PathBuf {
.join(did.replace(':', "_"))
}
/// The appview's DID, for delegating to it as a gateway: the `did:web`
/// of its host.
pub fn appview(ctx: &Ctx) -> Result<String> {
let base = ctx
.env
.appview
.as_deref()
.context("name a gateway's DID, or set KNOTLESS_APPVIEW")?;
let host = base
.split_once("://")
.map_or(base, |(_, rest)| rest)
.split('/')
.next()
.unwrap_or_default();
Ok(format!("did:web:{}", host.replace(':', "%3A")))
}
/// The gateway `did` last delegated to.
pub fn delegated(ctx: &Ctx, did: &str) -> Option<String> {
std::fs::read_to_string(gateway_path(ctx, did)).ok()
crates/knotless/src/identity.rs+50−0
@@ -60,6 +60,56 @@ pub async fn resolve(ctx: &Ctx, authority: &str) -> Result<Identity> {
Ok(Identity { did, pds })
}
#[derive(Serialize, Deserialize)]
struct CachedHandle {
handle: Option<String>,
at: u64,
}
/// The handle `did` claims, if it resolves back to `did`; `None` when it
/// claims none, or one that does not. Cached on disk, as identities are.
pub async fn handle(ctx: &Ctx, did: &str) -> Option<String> {
let path = ctx.env.cache_dir.join("handles.json");
let mut cache: BTreeMap<String, CachedHandle> = store::read(&path).unwrap_or_default();
if let Some(hit) = cache.get(did)
&& now_secs().saturating_sub(hit.at) < ctx.env.identity_ttl.as_secs()
{
return hit.handle.clone();
}
let doc = document(ctx, did).await.ok();
let claimed = doc
.as_ref()
.and_then(|d| d["alsoKnownAs"][0].as_str())
.and_then(|a| a.strip_prefix("at://"))
.unwrap_or(did);
let verified = resolve_handle(ctx, claimed).await.ok();
let handle = (verified.as_deref() == Some(did)).then_some(claimed.to_string());
cache.insert(
did.to_string(),
CachedHandle {
handle: handle.clone(),
at: now_secs(),
},
);
let _ = store::write(&path, &cache, false);
handle
}
/// The handles of `dids` that have one, by DID.
pub async fn handles<'a>(
ctx: &Ctx,
dids: impl IntoIterator<Item = &'a str>,
) -> BTreeMap<String, String> {
let mut dids: Vec<&str> = dids.into_iter().collect();
dids.sort();
dids.dedup();
let found = futures::future::join_all(dids.iter().map(|d| handle(ctx, d))).await;
dids.into_iter()
.zip(found)
.filter_map(|(did, h)| Some((did.to_string(), h?)))
.collect()
}
/// Drops `authority` from the cache, for a caller that found it stale.
pub fn forget(ctx: &Ctx, authority: &str) {
let path = ctx.env.cache_dir.join("identity.json");
crates/knotless/src/manage.rs+86−21
@@ -11,7 +11,7 @@ use crate::session;
use crate::session::Session;
use crate::space::{Admin, Space};
use crate::url::RepoUrl;
use anyhow::{Result, bail};
use anyhow::{Context, Result, bail};
#[derive(Clone, Debug, Default)]
pub struct NewRepo {
@@ -115,6 +115,62 @@ async fn pointers(remote: &Remote, repo: &Stored<RepoRecord>) -> Result<Vec<Stri
.collect())
}
/// A repository, as a listing shows it.
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Listed {
pub name: String,
pub description: Option<String>,
pub default_branch: Option<String>,
pub private: bool,
}
/// `owner`'s repositories by name: the public ones, and the private ones
/// too when `owner` is who is logged in.
pub async fn list(ctx: &Ctx, owner: &str) -> Result<Vec<Listed>> {
let url = RepoUrl::parse(&format!("knotless://{owner}/_"))?;
let remote = Remote::connect(ctx, &url).await?;
let listed = |r: Stored<RepoRecord>, private| Listed {
name: r.value.name,
description: r.value.description,
default_branch: r.value.default_branch,
private,
};
let mut all: Vec<Listed> = remote
.list_repos()
.await?
.into_iter()
.map(|r| listed(r, false))
.collect();
if let Some(me) = session::reader(ctx, &remote.identity).await
&& me.did == remote.did()
{
let admin = Admin {
xrpc: &remote.xrpc,
session: &me,
};
// A PDS without spaces has no private repositories to list.
let spaces = admin.spaces().await.unwrap_or_default();
// A renamed repository's record stays in the space of its first
// name; the spaces of later names hold only a redirect.
for uri in spaces {
let inside = Remote {
space: Some(Space::owner(&uri, &me)),
..remote.clone()
};
all.extend(
inside
.list_repos()
.await?
.into_iter()
.map(|r| listed(r, true)),
);
}
}
all.sort_by(|a, b| a.name.cmp(&b.name));
Ok(all)
}
/// Lets `who` read a private repository, and with `write`, push to any
/// repository through its gateway.
pub async fn share(ctx: &Ctx, url: &RepoUrl, who: &str, write: bool) -> Result<()> {
@@ -161,7 +217,9 @@ async fn set_access(ctx: &Ctx, url: &RepoUrl, who: &str, access: Option<bool>) -
match gateway::delegated(ctx, remote.did()) {
Some(gateway) => record.gateway = Some(gateway),
None if record.gateway.is_some() => {}
None => bail!("collaborators push through a gateway: run `knotless delegate` first"),
None => bail!(
"collaborators push through a gateway: delegate your writes to one first (`knotless delegate`, or the `delegate` tool)"
),
}
}
remote
@@ -179,12 +237,24 @@ async fn set_access(ctx: &Ctx, url: &RepoUrl, who: &str, access: Option<bool>) -
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
pub struct Member {
pub did: String,
/// When it is known to resolve back to `did`.
#[serde(skip_serializing_if = "Option::is_none")]
pub handle: Option<String>,
/// `owner`, `write` or `read`.
pub access: &'static str,
}
/// Who may do what: the owner, collaborators, and a private repository's
/// readers.
async fn readers_of(ctx: &Ctx, remote: &Remote, space: &str) -> Result<Vec<crate::space::Member>> {
let auth = session::ensure(ctx, &remote.identity).await?;
let admin = Admin {
xrpc: &remote.xrpc,
session: &auth,
};
admin.members(space).await
}
pub async fn members(ctx: &Ctx, url: &RepoUrl) -> Result<Vec<Member>> {
let remote = Remote::open(ctx, url).await?;
let Some(repo) = remote.repo().await? else {
@@ -198,31 +268,26 @@ pub async fn members(ctx: &Ctx, url: &RepoUrl) -> Result<Vec<Member>> {
.collect();
let mut readers = Vec::new();
if let Some(space) = &remote.space {
let auth = session::ensure(ctx, &remote.identity).await?;
let admin = Admin {
xrpc: &remote.xrpc,
session: &auth,
};
readers = admin.members(&space.uri).await?;
let listed = readers_of(ctx, &remote, &space.uri).await;
readers = listed.with_context(|| format!("only {url}'s owner can list who reads it"))?;
}
let mut all = vec![Member {
did: remote.did().to_string(),
access: "owner",
}];
all.extend(writers.iter().map(|did| Member {
did: did.clone(),
access: "write",
}));
let mut all = vec![(remote.did().to_string(), "owner")];
all.extend(writers.iter().map(|did| (did.clone(), "write")));
all.extend(
readers
.into_iter()
.filter(|m| !writers.contains(&m.did))
.map(|m| Member {
did: m.did,
access: "read",
}),
.map(|m| (m.did, "read")),
);
Ok(all)
let handles = crate::identity::handles(ctx, all.iter().map(|(did, _)| did.as_str())).await;
Ok(all
.into_iter()
.map(|(did, access)| Member {
handle: handles.get(&did).cloned(),
did,
access,
})
.collect())
}
/// Moves a public repository to a new name. Its records are keyed by its
crates/knotless/src/moderation.rs+2−2
@@ -3,8 +3,8 @@
//! A repository's owner moderates what others write about it: accounts they
//! block ([`BlockRecord`](crate::records::BlockRecord)) from all their
//! repositories or from that one are left out, and what they hide in the
//! repository's [`GateRecord`](crate::records::GateRecord) is too, as a
//! thread's author hides replies with a threadgate.
//! repository with a [`HideRecord`](crate::records::HideRecord) is too, as a
//! post's author detaches quotes with a postgate.
//!
//! Anyone can report an account, a repository or what someone wrote about
//! one to a moderation service (`com.atproto.moderation.createReport`, with
crates/knotless/src/records.rs+12−10
@@ -19,12 +19,12 @@ pub mod nsid {
pub const ISSUE: &str = "dev.knotless.issue";
pub const COMMENT: &str = "dev.knotless.comment";
pub const STATE: &str = "dev.knotless.state";
pub const GATE: &str = "dev.knotless.gate";
pub const HIDE: &str = "dev.knotless.hide";
pub const BLOCK: &str = "dev.knotless.block";
/// What people write about a repository, in their own accounts, and
/// what its owner hides of it and whom they keep out. A block names no
/// repository when it keeps someone out of all of them.
pub const FORGE: [&str; 6] = [PULL, ISSUE, COMMENT, STATE, GATE, BLOCK];
pub const FORGE: [&str; 6] = [PULL, ISSUE, COMMENT, STATE, HIDE, BLOCK];
}
pub const PACK_MIME: &str = "application/x-git-packed-objects";
@@ -250,7 +250,9 @@ pub struct CommentRecord {
pub struct StateRecord {
pub repo: RepoRef,
pub subject: String,
pub state: String,
/// Absent when the record only retargets a pull: the state stands.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<String>,
/// The merge, for `merged`.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub commit: Option<String>,
@@ -261,16 +263,16 @@ pub struct StateRecord {
pub created_at: String,
}
/// What a repository's owner hides of what others wrote about it, as
/// `app.bsky.feed.threadgate` hides replies: one per repository, keyed by its
/// key, in the owner's account.
/// A pull, issue or comment a repository's owner hides, with what was said
/// about it, as `app.bsky.feed.postgate` detaches quotes: one per hidden
/// record, keyed by a TID, in the owner's account; for a private
/// repository, in their repo inside its space.
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)]
#[serde(rename_all = "camelCase")]
pub struct GateRecord {
pub struct HideRecord {
pub repo: RepoRef,
/// Pulls, issues and comments, by `at://` URI.
#[serde(default)]
pub hidden: Vec<String>,
/// The pull, issue or comment, by `at://` URI.
pub subject: String,
pub created_at: String,
}
crates/knotless/src/space.rs+36−16
@@ -282,27 +282,47 @@ impl Admin<'_> {
}
pub async fn members(&self, uri: &str) -> Result<Vec<Member>> {
let params = [("space", uri), ("limit", "1000")];
self.every("com.atproto.simplespace.listMembers", &params, "members")
.await
}
/// The owner's spaces for repositories: one per private repository,
/// and the pointers renames leave.
pub async fn spaces(&self) -> Result<Vec<String>> {
#[derive(Deserialize)]
struct Out {
members: Vec<Member>,
cursor: Option<String>,
struct Listed {
uri: String,
}
let did = self.session.did.as_str();
let params = [("spaceType", SPACE_TYPE), ("did", did), ("limit", "100")];
let listed: Vec<Listed> = self
.every("com.atproto.space.listSpaces", &params, "spaces")
.await?;
Ok(listed.into_iter().map(|s| s.uri).collect())
}
/// Every page of a listing, following its cursor until a page is empty
/// or comes without one.
async fn every<T: serde::de::DeserializeOwned>(
&self,
nsid: &str,
params: &[(&str, &str)],
field: &str,
) -> Result<Vec<T>> {
let mut all = Vec::new();
let mut cursor = None::<String>;
loop {
let mut params = vec![("space", uri), ("limit", "1000")];
if let Some(c) = &cursor {
params.push(("cursor", c));
}
let out: Out = self
.xrpc
.query("com.atproto.simplespace.listMembers", &params, self.auth())
.await?;
let done = out.members.is_empty();
all.extend(out.members);
match out.cursor {
Some(next) if !done => cursor = Some(next),
_ => return Ok(all),
let mut page = params.to_vec();
// An empty cursor reads as none: the first page.
page.push(("cursor", cursor.as_deref().unwrap_or("")));
let mut out: Value = self.xrpc.query(nsid, &page, self.auth()).await?;
let items: Vec<T> = serde_json::from_value(out[field].take())?;
let done = items.is_empty();
all.extend(items);
cursor = out["cursor"].as_str().map(str::to_string);
if done || cursor.is_none() {
return Ok(all);
}
}
}
crates/knotless/tests/cli.rs+5−1
@@ -49,8 +49,12 @@ fn repository_lifecycle() {
let (code, _) = s.cli(&["create", &url], b"");
assert_eq!(code, 1);
assert_eq!(s.cli(&["create", &url, "--bogus"], b"").0, 1);
let aside = s.url("aside");
assert_eq!(s.cli(&["create", &aside], b"").0, 0);
// Listed by name.
let (_, out) = s.cli(&["ls", &s.account.handle], b"");
assert!(out.contains("life\ta thing"), "{out}");
assert!(out.starts_with("aside\t\nlife\ta thing"), "{out}");
assert_eq!(s.cli(&["delete", &aside, "--yes"], b"").0, 0);
let file = s.path("hello.txt");
std::fs::write(&file, "hi\n").unwrap();
crates/knotless/tests/spaces.rs+4−1
@@ -50,8 +50,11 @@ fn private_repositories() {
let (_, refs) = owner.cli(&["refs", &url], b"");
assert_eq!(refs, format!("{head}\trefs/heads/main"));
// Nothing of it is public.
// Nothing of it is public: its owner sees it listed, marked private,
// and nobody else does.
let (_, listed) = owner.cli(&["ls", &owner.account.handle], b"");
assert_eq!(listed, "secret\t\tprivate");
let (_, listed) = Sandbox::new().cli(&["ls", &owner.account.handle], b"");
assert!(!listed.contains("secret"), "{listed}");
let public = std::process::Command::new("curl")
.args([
docs/design.md+33−16
@@ -34,8 +34,8 @@ dev.knotless.redirect <old name> to, space?
dev.knotless.pull <tid> repo, base, head {repo, branch}, title, body?
dev.knotless.issue <tid> repo, title, body?
dev.knotless.comment <tid> repo, subject, body, verdict?, commit?
dev.knotless.state <tid> repo, subject, state, commit?, base?
dev.knotless.gate <key> repo, hidden
dev.knotless.state <tid> repo, subject, state?, commit?, base?
dev.knotless.hide <tid> repo, subject
dev.knotless.block <tid> subject, repo?
```
@@ -45,7 +45,7 @@ minted when the repository is created. Every record carries it, so a rename
rewrites only the `repo` record.
The first five live in the owner's repo. The pull, issue, comment and
state records live in each author's repo, and the gate in the owner's; they
state records live in each author's repo, and hides and blocks in the owner's; they
are described under [Pulls and issues](#pulls-and-issues).
### Identity
@@ -145,7 +145,11 @@ the commit there through a temporary index (`read-tree`, `hash-object`,
Put and delete name paths, so replaying them onto a tip that moved cannot
conflict. A lost race backs off with exponential jitter and replays. The
claim pins the parent's generation, so these pushes skip git's
fast-forward check.
fast-forward check. A commit to a branch that does not exist yet starts
it from the commit the changes were made against, or else from the
default branch. Commits are authored by whoever makes them, by handle.
`knotless branch` makes and removes branches the same way, without a
commit.
Mirrors are shared by every process using the cache. Packs are indexed
without a lock, because each lands under its own name. The ref transaction
@@ -218,13 +222,14 @@ which may be a fork. A comment with a verdict is a review and records the
head commit it saw. An item's state is the latest `state` record, ordered
by key, from someone allowed to set it: the item's author, the owner, or a
collaborator. Only the owner and collaborators can set `merged`. A `state`
with a `base` moves an open pull to another target branch, as after a
branch is renamed; the pull's author changes its head branch by rewriting
the pull record.
with a `base` and no `state` moves a pull to another target branch, as
after a branch is renamed, and leaves its state alone, so it cannot undo a
merge it raced; the pull's author changes its head branch by rewriting the
pull record.
To list them, a client needs every author's records about one repository.
For public repositories an appview indexes them from the firehose
(`dev.knotless.getForge`); for private ones the client enumerates the
(`dev.knotless.getForge`, a page at a time); for private ones the client enumerates the
space's member repos (`com.atproto.space.listRepos`) and reads each. A
comment reads the item it is about straight from its author's repo, so it
does not wait for an index.
@@ -233,21 +238,32 @@ does not wait for an index.
source's packs, and fast-forwards when it can. Otherwise it builds a merge
commit with `git merge-tree --write-tree`, and stops on conflicts. It pushes
directly as the owner or through the gateway as a collaborator, then writes
a `merged` state naming the commit. A fork copies every ref into a new
a `merged` state naming the commit. Comparing a pull does the same up to
the push: the commits it brings in, and whether it is merged already,
fast-forwards, merges cleanly or conflicts, and on which paths. The diff
of a pull is its head against where it left its target, wherever the head
lives. Renaming a branch makes the new one, retargets the open pulls on
the old one, then deletes it, so a rename cut short loses nothing.
A pull or issue just opened may not be indexed yet, so showing one the
appview lacks reads it from its author's repo if it is under a minute
old, and neither the appview's takedowns (asked of its `queryLabels`) nor
the owner's own hides and blocks keep it out. A fork copies every ref into a new
repository whose `repo` record names its source; a fork of a private
repository is private.
repository is private, and shared for reading with the source's owner so
they can take pulls from it.
### Moderation
Moderation is atproto's. A repository's owner keeps out what accounts they
block write about it, and hides pulls, issues and comments by listing them
in the gate, a record keyed by the repository's key, as a threadgate's
`hiddenReplies` hides replies. What was said about a hidden item goes with
it. A block is the forge's own record, shaped as `app.bsky.graph.block`:
block write about it, and hides pulls, issues and comments with a `hide`
record for each, as a postgate detaches quotes of one post: one record
each, so there is no list to outgrow. What was said about a hidden item
goes with it. A block is the forge's own record, shaped as `app.bsky.graph.block`:
blocking someone on Bluesky says nothing about their pulls, and a block
can name one repository, keeping its subject out of that one alone. A
block naming a private repository is kept in the space with it. Readers
apply both: the appview indexes owners' blocks and gates; in a space the
apply both: the appview indexes owners' blocks and hides; in a space the
client reads the owner's blocks from their repo and the space.
Anyone can report an account, a repository or a record with
@@ -291,7 +307,8 @@ stands, or removing it; `--backfill` walks `com.atproto.sync.listRepos`
on a PDS or relay once at start. The index answers
`dev.knotless.listRepos` (newest first, matched on name and description, paged by cursor)
and `dev.knotless.getForge`, which takes the repository's URI and key and
returns the records naming that owner and key under any name. The index is
returns the records naming that owner and key under any name, paged by
URI. The index is
a cache: one built for another version of its tables is dropped and rebuilt
from the firehose and `--backfill`.
harness/scenarios.mjsnew+307−0
@@ -0,0 +1,307 @@
// Runs the scenario tracks under harness/scenarios/tracks: each is a day of
// ordinary git work split into steps, and each step is a fresh Claude
// session that can use only the knotless MCP server, logged in as that
// step's account. The steps of a track run in order, each told what the
// earlier ones handed off; tracks run side by side, each with its own
// accounts, against a fresh reference PDS and appview.
//
// node harness/scenarios.mjs [track ...]
//
// Tracks marked `<!-- needs: spaces -->` run on the spaces alpha build named
// by KNOTLESS_SPACES_DEV_ENV, the rest on the npm reference PDS. Each run
// writes transcripts, report.json and report.md under
// target/scenarios/<time>/. SCENARIO_MODEL picks the model, and
// SCENARIO_TIMEOUT_SECS bounds each step (default 1200).
import { spawn } from 'node:child_process'
import { createServer } from 'node:net'
import { mkdirSync, mkdtempSync, openSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { createInterface } from 'node:readline'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const here = join(root, 'harness', 'scenarios')
const bin = join(root, 'target', 'debug')
const timeout = Number(process.env.SCENARIO_TIMEOUT_SECS ?? 1200) * 1000
const adminPassword = 'scenario-admin'
/** A track: its steps, each a role and what it is asked to do. */
function track(name) {
const text = readFileSync(join(here, 'tracks', `${name}.md`), 'utf8')
const [intro, ...parts] = text.split(/^## /m)
const steps = parts.map((part) => {
const [role, ...rest] = part.split('\n')
return { role: role.trim(), task: rest.join('\n').trim() }
})
return {
name,
spaces: intro.includes('<!-- needs: spaces -->'),
about: intro.replace(/<!--.*?-->/g, '').trim(),
steps,
roles: [...new Set(steps.map((s) => s.role))],
}
}
const handle = (t, role) => `${role}-${t.name}.test`
/** Fills `{role}` with that role's handle in this track. */
const fill = (t, text) =>
text.replace(/\{([a-z]+)\}/g, (m, role) =>
t.roles.includes(role) ? handle(t, role) : m,
)
const freePort = () =>
new Promise((ok) => {
const s = createServer()
s.listen(0, '127.0.0.1', () => {
const { port } = s.address()
s.close(() => ok(port))
})
})
/** A reference PDS with every track's accounts, and an appview on it. */
async function world(tracks, spaces, dir) {
const names = tracks.flatMap((t) =>
t.roles.map((r) => handle(t, r).replace(/\.test$/, '')),
)
const env = { ...process.env }
delete env.KNOTLESS_DEV_ENV
if (spaces) {
env.KNOTLESS_DEV_ENV =
process.env.KNOTLESS_SPACES_DEV_ENV ?? '../atproto-space-ref/packages/dev-env'
}
const pds = spawn('node', [join(root, 'harness', 'pds.mjs'), '--accounts', names.join(',')], {
cwd: root,
env,
stdio: ['pipe', 'pipe', openSync(join(dir, 'pds.log'), 'w')],
})
const line = await new Promise((ok, fail) => {
createInterface({ input: pds.stdout }).once('line', ok)
pds.once('exit', (code) => fail(new Error(`pds.mjs exited ${code}`)))
})
const net = JSON.parse(line)
if (spaces && !net.spaces) throw new Error('the spaces alpha is not running')
const port = await freePort()
const appview = `http://localhost:${port}`
const serve = spawn(
join(bin, 'knotless-serve'),
[
'--listen', `127.0.0.1:${port}`,
'--index', join(dir, 'index.db'),
'--firehose', net.pds.replace('http://', 'ws://'),
'--public-url', appview,
],
{
env: {
...process.env,
...common(net),
KNOTLESS_CACHE_DIR: join(dir, 'appview-cache'),
KNOTLESS_CONFIG_DIR: join(dir, 'appview-config'),
KNOTLESS_ADMIN_PASSWORD: adminPassword,
},
stdio: ['ignore', 'ignore', openSync(join(dir, 'appview.log'), 'w')],
},
)
for (let i = 0; ; i++) {
const up = await fetch(`${appview}/xrpc/dev.knotless.listRepos`).then(
(r) => r.ok,
() => false,
)
if (up) break
if (i > 100) throw new Error('the appview did not start')
await new Promise((ok) => setTimeout(ok, 100))
}
return {
net,
appview,
close: () => {
serve.kill()
pds.stdin.end()
},
}
}
const common = (net) => ({
KNOTLESS_HANDLE_RESOLVER: net.pds,
KNOTLESS_PLC_URL: net.plc,
KNOTLESS_DID_WEB_SCHEME: 'http',
KNOTLESS_COMPACT: 'off',
})
/** Runs one step as a fresh Claude session; its transcript goes to `out`. */
async function step(t, i, w, dir, handoffs) {
const { role, task } = t.steps[i]
const me = handle(t, role)
const account = w.net.accounts.find((a) => a.handle === me)
const home = join(dir, 'people', role)
const config = join(dir, `${i + 1}-${role}.mcp.json`)
writeFileSync(
config,
JSON.stringify({
mcpServers: {
knotless: {
command: join(bin, 'knotless-mcp'),
env: {
...common(w.net),
PATH: `${bin}:${process.env.PATH}`,
KNOTLESS_IDENTIFIER: me,
KNOTLESS_PASSWORD: account.password,
KNOTLESS_APPVIEW: w.appview,
KNOTLESS_CONFIG_DIR: join(home, 'config'),
KNOTLESS_CACHE_DIR: join(home, 'cache'),
GIT_AUTHOR_NAME: role,
GIT_AUTHOR_EMAIL: `${role}@example.com`,
GIT_COMMITTER_NAME: role,
GIT_COMMITTER_EMAIL: `${role}@example.com`,
GIT_CONFIG_GLOBAL: '/dev/null',
},
},
},
}),
)
const others = t.roles.filter((r) => r !== role).map((r) => handle(t, r))
let prompt = readFileSync(join(here, 'brief.md'), 'utf8')
.replaceAll('{me}', me)
.replaceAll('{others}', others.length ? others.join(', ') : 'nobody else today')
prompt += `\n## Your part today\n\n${fill(t, task)}\n`
if (handoffs.length) {
prompt += '\n## What the others handed on\n\n'
prompt += handoffs.map((h) => `- ${h.who}: ${h.handoff}`).join('\n') + '\n'
}
const args = [
'-p',
'--output-format', 'stream-json',
'--verbose',
'--no-session-persistence',
'--strict-mcp-config',
'--mcp-config', config,
'--tools', '',
'--allowedTools', 'mcp__knotless',
]
if (process.env.SCENARIO_MODEL) args.push('--model', process.env.SCENARIO_MODEL)
// Outside the repository, so no project instructions reach the session.
const cwd = mkdtempSync(join(tmpdir(), 'knotless-scenario-'))
const claude = spawn('claude', args, { cwd, stdio: ['pipe', 'pipe', 'pipe'] })
claude.stdin.end(prompt)
const lines = []
createInterface({ input: claude.stdout }).on('line', (l) => lines.push(l))
let stderr = ''
claude.stderr.on('data', (d) => (stderr += d))
const timer = setTimeout(() => claude.kill(), timeout)
const code = await new Promise((ok) => claude.on('close', ok))
clearTimeout(timer)
writeFileSync(join(dir, `${i + 1}-${role}.jsonl`), lines.join('\n') + '\n')
writeFileSync(join(dir, `${i + 1}-${role}.prompt.md`), prompt)
return { role, me, code, stderr, ...read(lines) }
}
/** What a transcript says: the calls made, those that failed, and the
* report at the end of the reply. */
function read(lines) {
const events = lines.flatMap((l) => {
try {
return [JSON.parse(l)]
} catch {
return []
}
})
const calls = []
const names = new Map()
for (const e of events) {
for (const c of e.message?.content ?? []) {
if (c.type === 'tool_use') names.set(c.id, c.name.replace(/^mcp__knotless__/, ''))
if (c.type === 'tool_result') {
const text = [c.content].flat().map((x) => x?.text ?? x).join('')
calls.push({ tool: names.get(c.tool_use_id), error: !!c.is_error, text })
}
}
}
const result = events.findLast((e) => e.type === 'result') ?? {}
const blocks = [...(result.result ?? '').matchAll(/```json\s*([\s\S]*?)```/g)]
let report = { done: false, summary: 'no report', handoff: '', tripped: [] }
try {
report = { ...report, ...JSON.parse(blocks.at(-1)[1]) }
} catch {}
return {
calls,
errors: calls.filter((c) => c.error),
turns: result.num_turns,
cost: result.total_cost_usd ?? 0,
report,
}
}
async function run(t, w, dir) {
const out = join(dir, t.name)
mkdirSync(out, { recursive: true })
const steps = []
for (let i = 0; i < t.steps.length; i++) {
const handoffs = steps.map((s) => ({ who: s.me, handoff: s.report.handoff }))
const s = await step(t, i, w, out, handoffs)
console.log(
`${t.name} ${i + 1}/${t.steps.length} ${s.role}: ${s.report.done ? 'done' : 'NOT DONE'}, ` +
`${s.calls.length} calls, ${s.errors.length} errors, ${s.report.tripped.length} tripped`,
)
steps.push(s)
}
return { track: t.name, about: t.about, steps }
}
function markdown(results, when) {
let md = `# Scenarios, ${when}\n\n`
md += '| track | step | done | calls | errors | tripped | cost |\n|---|---|---|---|---|---|---|\n'
for (const r of results) {
r.steps.forEach((s, i) => {
md += `| ${r.track} | ${i + 1} ${s.role} | ${s.report.done ? 'yes' : '**no**'} | ${s.calls.length} | ${s.errors.length} | ${s.report.tripped.length} | $${s.cost.toFixed(2)} |\n`
})
}
for (const r of results) {
md += `\n## ${r.track}\n\n${r.about}\n`
r.steps.forEach((s, i) => {
md += `\n### ${i + 1}. ${s.role} (${s.me})\n\n${s.report.summary}\n`
if (s.code !== 0) md += `\nThe session exited ${s.code}: ${s.stderr.trim().slice(0, 500)}\n`
for (const tr of s.report.tripped) {
md += `\n- **${tr.tool}**: ${tr.what}\n - expected: ${tr.expected}\n - workaround: ${tr.workaround}\n`
}
for (const e of s.errors) {
md += `\n- error from \`${e.tool}\`: ${e.text.replace(/\s+/g, ' ').slice(0, 300)}\n`
}
})
}
return md
}
const all = readdirSync(join(here, 'tracks'))
.filter((f) => f.endsWith('.md'))
.map((f) => f.slice(0, -3))
.sort()
const chosen = process.argv.slice(2).length ? process.argv.slice(2) : all
const tracks = chosen.map(track)
const when = new Date().toISOString().replace(/[:.]/g, '-')
const dir = join(root, 'target', 'scenarios', when)
mkdirSync(dir, { recursive: true })
const groups = [false, true]
.map((spaces) => ({ spaces, tracks: tracks.filter((t) => t.spaces === spaces) }))
.filter((g) => g.tracks.length)
const results = (
await Promise.all(
groups.map(async (g) => {
const wdir = join(dir, g.spaces ? 'spaces' : 'stock')
mkdirSync(wdir, { recursive: true })
const w = await world(g.tracks, g.spaces, wdir)
try {
return await Promise.all(g.tracks.map((t) => run(t, w, dir)))
} finally {
w.close()
}
}),
)
).flat()
results.sort((a, b) => a.track.localeCompare(b.track))
writeFileSync(join(dir, 'report.json'), JSON.stringify(results, null, 2))
writeFileSync(join(dir, 'report.md'), markdown(results, when))
console.log(`\n${join(dir, 'report.md')}`)
process.exit(0)
harness/scenarios/brief.mdnew+42−0
@@ -0,0 +1,42 @@
You are a software developer at work. Your team hosts its git repositories
with knotless, and today you reach them only through the `knotless` MCP
tools: there is no shell, no local clone and no editor. Do your part of
the day's work the way a developer would: look before you change things,
check that what you did took effect, and leave things tidy for whoever
comes next.
You are logged in as **{me}**. The others on the team are {others}.
Everyone works on the same network, so a repository is named
`handle/name`, as in `{me}/notes`.
What you are given below is all you know. Nobody can answer questions, so
when something is unclear, decide as a sensible developer would and carry
on. Some steps may not be possible: if so, say so rather than pretending
they worked.
As you go, notice anything that tripped you up: a tool that did not do what
its description said, an error you had to puzzle over, something you had to
retry or work around, a step that took more calls than it should have, or
something you expected to find and could not. Small things count.
When you are done, end your reply with one fenced `json` block and nothing
after it:
```json
{
"done": true,
"summary": "what you did, in a sentence or two",
"handoff": "what the next person needs: repository names, at:// URIs of pulls, issues and comments, branch names",
"tripped": [
{
"tool": "the tool, or none",
"what": "what happened",
"expected": "what you expected instead",
"workaround": "how you got past it, or none"
}
]
}
```
`done` is false if you could not finish your part. `tripped` is empty if
nothing did.
harness/scenarios/tracks/contribution.mdnew+37−0
@@ -0,0 +1,37 @@
An outside contributor fixes a bug in someone else's project, through a
fork, a pull and a review.
## alice
You maintain a small library, `slugify`, which turns titles into URL
slugs. Create the repository and commit a README and `slugify.py` with a
function `slugify(title)` that lowercases the title and replaces spaces
with hyphens. It has a bug: punctuation such as `!` or `?` stays in the
slug. Leave the bug in. Open an issue describing it, with an example input
and the output you would expect.
## bob
You use {alice}'s `slugify` library and have hit the punctuation bug. Find
the issue about it and read the code. You cannot push to their repository,
so fix it the usual way for an outside contributor: in your own copy, on a
branch, then ask {alice} to take the change, referring to the issue. Say
on the issue that you have sent a fix.
## alice
Someone has sent a fix for the punctuation bug in `slugify`. Review it:
read what it changes. Whatever it looks like, ask for one change before
you accept it: a test file, `test_slugify.py`, covering the example from
the issue. Request the change as a review, not just a comment.
## bob
{alice} reviewed your fix to `slugify` and asked for something. Find out
what, do it on the same branch, and let them know it is ready.
## alice
Check the updated fix to `slugify`. If it now has the test, approve and
merge it, close the issue saying it was fixed, and confirm `main` has the
fix. If not, say what is missing.
harness/scenarios/tracks/moderation.mdnew+26−0
@@ -0,0 +1,26 @@
A popular repository that attracts a spammer.
## alice
You maintain `widgets`, a public project. Create it with a README, and
open one genuine issue yourself about a missing feature.
## dave
You are a spammer. Open three issues on {alice}'s `widgets` repository
advertising a crypto giveaway at `https://example.com/free`, and comment
the same link on the genuine issue.
## alice
Your `widgets` repository has been spammed. Clean it up: make the spam
disappear from the project without deleting anyone's genuine work, and
make sure the spammer cannot do it again on this repository. They have
not done anything to your other repositories, so leave those alone. Then
report the account to the moderators, and check the issue list looks
right.
## bob
You follow {alice}'s `widgets` project. Look at its issues and comments
and say whether anything looks like spam. If you see any, report it.
harness/scenarios/tracks/private.mdnew+27−0
@@ -0,0 +1,27 @@
<!-- needs: spaces -->
A private repository shared with one person.
## alice
You are drafting a grant proposal in a repository nobody else should see
yet. Create a private repository `proposal` with `draft.md` holding an
outline. Give {bob} access to read it and file issues, but not to push.
Check who can see it.
## bob
{alice} shared their private `proposal` repository with you. Read the
draft and open an issue suggesting one improvement. Then try to commit a
fix to `draft.md` yourself, and report what happened.
## alice
Read {bob}'s issue on `proposal` and make the change they suggest in
`draft.md`, then close the issue with a comment. The project now has a
name, so rename the repository to `river-grant`, and check {bob} can still
reach it under the new name.
## bob
{alice} said `proposal` was renamed. Check you can still read it and your
issue, under the new name and the old one.
harness/scenarios/tracks/solo.mdnew+20−0
@@ -0,0 +1,20 @@
One developer starting and looking after a small project on their own.
## alice
You are starting a small command-line tool called `tally`, which counts
words in text files. Create a repository for it with a one-line
description. Put in a first commit with a README explaining what it does,
a `src/main.py` that reads a file named on the command line and prints its
word count, and a `.gitignore` for Python.
Then work on a feature branch, `count-lines`: add a `--lines` flag that
counts lines instead, and mention it in the README. Make it two commits,
one for the code and one for the docs. Check the branch's history and the
difference between it and `main`, and bring the feature into `main`.
Finally, look back: find where the word count is computed by searching the
code, read the file to confirm `main` has the flag, and look at the log of
`main`. The name `tally` turns out to clash with another tool, so rename
the repository to `wordtally` and check it is still all there under the
new name.
harness/scenarios/tracks/team.mdnew+31−0
@@ -0,0 +1,31 @@
A maintainer and a teammate working in one repository, with the branch
churn of an ordinary week.
## alice
You lead a small service, `inventory`. Create the repository with a README
and `app.py` holding a function `total(items)` that sums item prices.
Make a long-lived `develop` branch from `main`. Your teammate {carol} will
work in this repository directly, not from a fork: give them what they
need to push branches and merge pulls here.
## carol
{alice} has given you access to their `inventory` repository. On a new
branch off `develop`, add a function `count(items)` to `app.py` and open a
pull into `develop`. Then add a second commit on the same branch that
documents `count` in the README, and check the pull now brings in both
commits.
## alice
The team has decided to stop using `develop` and to call it `next`
instead. Make that so in `inventory` without losing anything: open pulls
that targeted `develop` should target `next`. Check {carol}'s pull still
merges cleanly, then let them know.
## carol
{alice} renamed a branch in `inventory`. Check your pull is pointed where
it should be, then merge it yourself and check the result on its target
branch.
lexicons/dev/knotless/createCommit.json+1−1
@@ -14,7 +14,7 @@
"properties": {
"repo": { "type": "string", "format": "did", "description": "The repository's owner." },
"name": { "type": "string", "maxLength": 100, "description": "The repository's name: its dev.knotless.repo record key." },
"branch": { "type": "string", "maxLength": 255, "description": "Without refs/heads/. Created when absent." },
"branch": { "type": "string", "maxLength": 255, "description": "Without refs/heads/. Created when absent, from `base`, or else from the default branch's tip." },
"message": { "type": "string", "maxLength": 20000 },
"author": { "type": "ref", "ref": "#author" },
"base": { "type": "string", "minLength": 40, "maxLength": 64, "description": "The hex object ID of the commit the changes were made against. Without it the changes apply to whatever the branch holds." },
lexicons/dev/knotless/gate.jsondeleted+0−26
@@ -1,26 +0,0 @@
{
"lexicon": 1,
"id": "dev.knotless.gate",
"description": "What a repository's owner hides of what others wrote about it, as app.bsky.feed.threadgate hides replies.",
"defs": {
"main": {
"type": "record",
"description": "Kept in the owner's account, keyed by the repository's `key`; for a private repository, in their repo inside its space. Readers honor it only from the owner. Accounts the owner blocks (dev.knotless.block) are left out as well, without a gate.",
"key": "any",
"record": {
"type": "object",
"required": ["repo", "createdAt"],
"properties": {
"repo": { "type": "ref", "ref": "dev.knotless.defs#repoRef", "description": "The repository." },
"hidden": {
"type": "array",
"maxLength": 1000,
"items": { "type": "string", "format": "at-uri" },
"description": "Pulls, issues and comments to leave out. Comments and state changes on a hidden pull or issue go with it."
},
"createdAt": { "type": "string", "format": "datetime" }
}
}
}
}
}
lexicons/dev/knotless/getForge.json+6−3
@@ -5,13 +5,15 @@
"defs": {
"main": {
"type": "query",
"description": "Every pull, issue, comment and state change anyone has written about a public repository, as an appview has indexed them from the firehose. No authentication; responses are not personalized. Private repositories are never indexed: their readers list the members' records in the space.",
"description": "Every pull, issue, comment, state change, hide and block anyone has written about a public repository, as an appview has indexed them from the firehose, less what its owner and the appview keep out. A page at a time, ordered by URI: follow `cursor` until it is absent. No authentication; responses are not personalized. Private repositories are never indexed: their readers list the members' records in the space.",
"parameters": {
"type": "params",
"required": ["repo", "key"],
"properties": {
"repo": { "type": "string", "format": "at-uri", "description": "The repository's dev.knotless.repo record; only its owner is matched." },
"key": { "type": "string", "format": "tid", "description": "The repository's `key`. Records naming it are returned whatever name their URI has, with those naming a repository it was moved from with its owner's agreement." }
"key": { "type": "string", "format": "tid", "description": "The repository's `key`. Records naming it are returned whatever name their URI has, with those naming a repository it was moved from with its owner's agreement." },
"limit": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 500 },
"cursor": { "type": "string", "format": "at-uri", "description": "The last URI of the page before." }
}
},
"output": {
@@ -20,6 +22,7 @@
"type": "object",
"required": ["entries"],
"properties": {
"cursor": { "type": "string", "format": "at-uri", "description": "Absent on the last page." },
"entries": { "type": "array", "items": { "type": "ref", "ref": "#entry" } }
}
}
@@ -32,7 +35,7 @@
"properties": {
"uri": { "type": "string", "format": "at-uri", "description": "The record's URI." },
"author": { "type": "string", "format": "did", "description": "The account the record is in." },
"value": { "type": "unknown", "description": "The record verbatim: a dev.knotless.pull, issue, comment or state." }
"value": { "type": "unknown", "description": "The record verbatim: a dev.knotless.pull, issue, comment, state, hide or block." }
}
}
}
lexicons/dev/knotless/hide.jsonnew+21−0
@@ -0,0 +1,21 @@
{
"lexicon": 1,
"id": "dev.knotless.hide",
"description": "A pull, issue or comment a repository's owner hides, as app.bsky.feed.postgate detaches quotes of a post.",
"defs": {
"main": {
"type": "record",
"description": "Kept in the owner's account, keyed by a TID, one per hidden record; for a private repository, in their repo inside its space. Readers honor it only from the owner. Comments and state changes on a hidden pull or issue go with it. Accounts the owner blocks (dev.knotless.block) are left out as well, without one.",
"key": "tid",
"record": {
"type": "object",
"required": ["repo", "subject", "createdAt"],
"properties": {
"repo": { "type": "ref", "ref": "dev.knotless.defs#repoRef", "description": "The repository." },
"subject": { "type": "string", "format": "at-uri", "description": "The dev.knotless.pull, dev.knotless.issue or dev.knotless.comment to leave out." },
"createdAt": { "type": "string", "format": "datetime" }
}
}
}
}
}
lexicons/dev/knotless/state.json+3−3
@@ -9,13 +9,13 @@
"key": "tid",
"record": {
"type": "object",
"required": ["repo", "subject", "state", "createdAt"],
"required": ["repo", "subject", "createdAt"],
"properties": {
"repo": { "type": "ref", "ref": "dev.knotless.defs#repoRef", "description": "The repository the pull or issue is on." },
"subject": { "type": "string", "format": "at-uri", "description": "The dev.knotless.pull or dev.knotless.issue whose state changed." },
"state": { "type": "string", "maxLength": 64, "knownValues": ["open", "closed", "merged"], "description": "`merged` applies to pulls only." },
"state": { "type": "string", "maxLength": 64, "knownValues": ["open", "closed", "merged"], "description": "`merged` applies to pulls only. Absent when the record only sets `base`: the state stands." },
"commit": { "type": "string", "minLength": 40, "maxLength": 64, "description": "The hex object ID of the merge commit, for `merged`." },
"base": { "type": "string", "maxLength": 255, "description": "For a pull, the branch it targets from here on, without refs/heads/: set when the branch it targeted was renamed. `state` repeats the pull's state." },
"base": { "type": "string", "maxLength": 255, "description": "For a pull, the branch it targets from here on, without refs/heads/: set when the branch it targeted was renamed." },
"createdAt": { "type": "string", "format": "datetime" }
}
}