chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
An agent watching an owner finds their private repositories shared with it
.changeset/owner-watches-private.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: minor | ||
--- | ||
| ||
# An agent watching an owner finds their private repositories shared with it | ||
| ||
`knotless repo share` leaves the account shared with an invite at the appview, and `knotless-agent --owner <handle>` watches, besides that account's public repositories, every private one it shared with the agent's account, so a reviewer need not be told each private repository by name. | ||
.knotless/workflows/deploy.yml+4−3
| @@ -98,9 +98,10 @@ env: | ||
# Reviews pulls with Claude Code, as review.knotless.dev, once | ||
# /etc/knotless/review.env holds its logins (deploy/deploy.sh): every | ||
# open pull by the owner or a collaborator, asked or not (--all), in | ||
# every public repository the owner holds, and grain.social's (--owner); | ||
# a private one only when named, as group-host is. | ||
KNOTLESS_REVIEW_REPOS: --owner chadtmiller.com --owner grain.social chadtmiller.com/group-host | ||
# every repository the owner holds, and grain.social's (--owner): the | ||
# public ones, and the private ones shared with review.knotless.dev, | ||
# whose sharing left it an invite at the appview. | ||
KNOTLESS_REVIEW_REPOS: --owner chadtmiller.com --owner grain.social | ||
# Five rounds, where a merge of the base into a pull spends none. | ||
KNOTLESS_REVIEW_ARGS: --all --rounds 5 | ||
# Brings the pulls the merge queue turned away for a conflict up to | ||
crates/knotless/src/manage.rs+8−1
| @@ -506,9 +506,16 @@ async fn set_access(ctx: &Ctx, url: &RepoUrl, who: &str, access: Option<bool>) - | ||
records::typed(nsid::REPO, &record), | ||
)]; | ||
remote.apply(written, &auth.auth()).await?; | ||
// Whom it is shared with, for an appview to hear them of it. | ||
// Whom it is shared with, for an appview to hear them of it; and the | ||
// account shared with is left an invite there, which is how an agent | ||
// that watches the owner's repositories finds a private one | ||
// ([`crate::invites`]): a hint, like showing it, so it is not one | ||
// that fails the share. | ||
if remote.space.is_some() { | ||
crate::space::show_appview(ctx, remote.did(), &name).await; | ||
if access.is_some() { | ||
let _ = crate::invites::invite(ctx, &auth, &who.did, &name).await; | ||
} | ||
} | ||
Ok(()) | ||
} | ||
crates/knotless-agent/src/lib.rs+52−8
| @@ -24,8 +24,11 @@ | ||
//! not, as a GitHub workflow that reviews each pull does. | ||
//! | ||
//! With `--owner <handle|did>` it watches, besides the repositories named, | ||
//! every public repository that account holds: listed from its PDS at | ||
//! each read, so one made meanwhile is watched from then on. | ||
//! every repository that account holds that it may read: the public ones, | ||
//! listed from its PDS at each read, so one made meanwhile is watched from | ||
//! then on, and the private ones shared with its account, which `knotless | ||
//! repo share` leaves it an invite to at the appview, since a PDS lists | ||
//! no one another's private repositories. | ||
//! | ||
//! A pull is reviewed once at each head, and only one its repository's | ||
//! owner or a collaborator opened: anyone may ask any account for a | ||
| @@ -329,22 +332,63 @@ impl Agent { | ||
all.into_values().collect() | ||
} | ||
| ||
/// `owner`'s public repositories, from its PDS. | ||
/// `owner`'s repositories: its public ones, from its PDS, and the | ||
/// private ones it invited this account to, as the appview has their | ||
/// invites, each that this account reads as a member. | ||
async fn list(&self, owner: &str) -> Result<Owned> { | ||
let url = RepoUrl::parse(&format!("{SCHEME}{owner}/_"))?; | ||
let remote = Remote::connect(&self.ctx, &url).await?; | ||
let did = remote.did().to_string(); | ||
let repos = remote.list_repos().await?.into_iter().map(|r| { | ||
let public = remote.list_repos().await?.into_iter().map(|r| { | ||
let url = RepoUrl { | ||
authority: did.clone(), | ||
name: r.value.name, | ||
}; | ||
((did.clone(), r.value.key), url) | ||
}); | ||
Ok(Owned { | ||
repos: repos.collect(), | ||
did, | ||
}) | ||
let mut repos: BTreeMap<_, _> = public.collect(); | ||
// Invites go through the appview, which only a gateway gives a DID | ||
// to ask with: without them its public ones are still watched. | ||
let invited = match self.invited(&did).await { | ||
Ok(found) => found, | ||
Err(err) => { | ||
let f = Fields { | ||
did: Some(owner), | ||
error: Some(format!("{err:#}")), | ||
..Default::default() | ||
}; | ||
telemetry::note(Level::WARN, "listing the invites left it failed", &f); | ||
Vec::new() | ||
} | ||
}; | ||
for url in &invited { | ||
// One it no longer reads, unshared since, is passed by. | ||
if let Ok(t) = Target::open(&self.ctx, url).await { | ||
repos.insert((did.clone(), t.reference().key), t.url); | ||
} | ||
} | ||
Ok(Owned { repos, did }) | ||
} | ||
| ||
/// The private repositories of `owner` (a DID) that invited this | ||
/// account, as `knotless repo share` leaves an invite: read from the | ||
/// first each time, as it keeps nothing. | ||
async fn invited(&self, owner: &str) -> Result<Vec<RepoUrl>> { | ||
let me = session::me(&self.ctx).await?; | ||
let mut found = Vec::new(); | ||
let mut cursor = None; | ||
loop { | ||
let page = knotless::invites::list(&self.ctx, &me, cursor.as_deref()).await?; | ||
if page.invites.is_empty() { | ||
return Ok(found); | ||
} | ||
let theirs = page.invites.into_iter().filter(|i| i.did == owner); | ||
found.extend(theirs.map(|i| RepoUrl { | ||
authority: i.did, | ||
name: i.name, | ||
})); | ||
cursor = page.cursor; | ||
} | ||
} | ||
| ||
/// Does its job for each of `url`'s pulls waiting for it. | ||
crates/knotless-agent/tests/review.rs+27−3
| @@ -689,13 +689,37 @@ fn an_agent_reviews_a_private_repositorys_pulls() { | ||
owners: Vec::new(), | ||
attempts: 3, | ||
}; | ||
rt.block_on(knotless_agent::run(opts, common::ctx(&bot))) | ||
rt.block_on(knotless_agent::run(opts.clone(), common::ctx(&bot))) | ||
.unwrap(); | ||
let given = given(&bot, "Hidden change"); | ||
assert_eq!(given["instructions"], "Keep it secret.\n"); | ||
let read = given(&bot, "Hidden change"); | ||
assert_eq!(read["instructions"], "Keep it secret.\n"); | ||
// Its review is kept with the repository's comments, which the owner | ||
// reads. | ||
wait_until(|| ok(&owner, &["pull", "show", &url, &pull]).contains("Name the file.")); | ||
| ||
// With --owner alone it finds the repository too: sharing it left the | ||
// agent an invite at the appview, which no listing of the owner's PDS | ||
// would show. | ||
owner.commit_file(&work, "g", "hidden again\n"); | ||
owner.git(&work, &["push", "-q", &url, "HEAD:refs/heads/y"]); | ||
let args = ["pull", "create", &url, "main", &url, "y", "Found by owner"]; | ||
let found = ok(&owner, &[&args[..], &["--reviewer", &me]].concat()); | ||
wait_until(|| { | ||
bot.cli(&["pull", "list", &url], b"") | ||
.1 | ||
.contains(found.rsplit('/').next().unwrap()) | ||
}); | ||
let owners = Options { | ||
repos: Vec::new(), | ||
owners: vec![owned.clone()], | ||
..opts | ||
}; | ||
rt.block_on(knotless_agent::run(owners, common::ctx(&bot))) | ||
.unwrap(); | ||
assert_eq!( | ||
given(&bot, "Found by owner")["instructions"], | ||
"Keep it secret.\n" | ||
); | ||
} | ||
| ||
#[test] | ||
docs/design/access.md+6−1
| @@ -166,7 +166,12 @@ A private repository is made with `knotless repo create --private`, the | ||
repository. Its owner adds and removes members with `knotless repo share` and | ||
`--undo`, the `repo_share` tool, or the Readers list on the pages' Access tab, | ||
and lists them with `knotless repo members`, the `repo_members` tool, or that list, | ||
which only the owner sees. | ||
which only the owner sees. Sharing one also leaves the account an invite | ||
at the appview (`dev.knotless.inviteRunner`), as naming a runner or a | ||
triager does: no PDS lists another's private repositories, so an agent | ||
watching an owner's repositories (`knotless-agent --owner`) finds the | ||
private ones shared with it that way, and reads each as a member, as an | ||
invite is only a hint. | ||
| ||
Every member is a space writer. The alpha lists a member's repo in the | ||
space only if that member's writes are allowed, and members write their | ||
docs/design/collaboration.md+5−1
| @@ -122,7 +122,11 @@ the pull again once its author changes the title or body, at that head | ||
or the next, rather than keeping its verdict, since what it asked may be | ||
answered in what the pull says, as a line a check reads. An approval | ||
is not read again for an edit. What it reviewed it reads back | ||
from its own records, so it keeps nothing else. | ||
from its own records, so it keeps nothing else. Named an `--owner`, it | ||
watches every repository of that account it may read: the public ones as | ||
the owner's PDS lists them, and the private ones the owner shared with | ||
it, by the invite sharing leaves at the appview | ||
([Members and writers](access.md#members-and-writers)). | ||
| ||
Each review spends its operator's model usage, and anyone may ask any | ||
account for a review, so it reviews only pulls the owner, a collaborator | ||
docs/guide/reviews.md+1−1
| @@ -45,7 +45,7 @@ Use a new account, not your own. The owner's approvals count toward required rev | ||
knotless-agent review --command knotless-claude-review --once alice.example.com/my-project | ||
``` | ||
| ||
Name as many repositories as you like, each as `<handle>/<name>`, or `--owner alice.example.com` for every public repository that account holds, including those it makes later. With no pull asking for it yet, it reads the repository and exits without writing anything. A review takes Claude a minute or two. | ||
Name as many repositories as you like, each as `<handle>/<name>`, or `--owner alice.example.com` for every repository that account holds that the reviewer may read, including those it makes later: the public ones, and the private ones it shared with the reviewer's account (`knotless repo share`), with no need to name them. With no pull asking for it yet, it reads the repository and exits without writing anything. A review takes Claude a minute or two. | ||
| ||
6. Run it without `--once` to keep it watching. See [Keep it running](#keep-it-running). | ||
| ||
docs/reference/cli.md+2−2
| @@ -88,7 +88,7 @@ has neither. | ||
| `repo delete <url> --yes` | delete a repository and everything in it: its records, and what you wrote about it (your pulls, issues, comments, labels, star and reactions on it); run again, a delete cut short carries on | | ||
| `repo transfer <url> <handle or url>` / `repo transfer <url> --undo` | offer a public repository to another account, or with `--undo` withdraw the offer | | ||
| `repo accept <url>` | take a repository offered to you, its pulls and issues included | | ||
| `repo share <url> <handle> [--write] [--undo]` | let an account read a private repository, or push too, printing `shared`; with `--write` before you have delegated your writes to a gateway, delegates them to the appview's first, as `account delegate` does, approved in the browser, printing `delegated to <did>` before it. With `--undo`, take access away | | ||
| `repo share <url> <handle> [--write] [--undo]` | let an account read a private repository, or push too, printing `shared`, and leave it an invite at the appview (`KNOTLESS_APPVIEW`), by which an agent watching your repositories (`knotless-agent --owner`) finds it; with `--write` before you have delegated your writes to a gateway, delegates them to the appview's first, as `account delegate` does, approved in the browser, printing `delegated to <did>` before it. With `--undo`, take access away | | ||
| `repo members <url>` | who may read and push, a line each: the DID, then `owner`, `write` (a collaborator), `member` (of the organization the owner is) or `read`, tab-separated | | ||
| `repo star <url> [--undo]` | star a public repository, printing the star's `at://` URI; one already starred keeps its star; a private one fails `<url>: a private repository can't be starred, as a star is public`. With `--undo`, take your star back, printing `unstarred`, starred or not | | ||
| `repo stars [<handle>]` | the repositories you, or `<handle>`, starred, newest first: `knotless://<handle>/<name>` and when, tab-separated | | ||
| @@ -771,7 +771,7 @@ does. | ||
| `--sweep <secs>` | 300 | how often every repository is read, whatever events say | | ||
| `--settle <secs>` | 5 | how long after an event the repositories are read, for the appview to index the pull | | ||
| `--once` | off | read each repository once, and stop | | ||
| `--owner <handle\|did>` | | also watch every public repository this account holds, listed from its PDS at each read, so one it makes later is watched too. Repeat it for more accounts. An owner whose listing fails is logged as `listing an owner's repositories failed`, and its last listing stands | | ||
| `--owner <handle\|did>` | | also watch every repository this account holds that the agent reads: the public ones, listed from its PDS at each read, so one it makes later is watched too, and the private ones it shared with the agent's account, from the invites `repo share` leaves it at the appview (an invite it can no longer read by, as once unshared, is passed by; without a gateway's DID at the appview to ask with, it logs `listing the invites left it failed` and watches the public ones). Repeat it for more accounts. An owner whose listing fails is logged as `listing an owner's repositories failed`, and its last listing stands | | ||
| ||
It reviews a pull that is open and not a draft, that its owner, a | ||
collaborator or a member of the owner's organization opened, and whose | ||