chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A private repository's records are named in full, and public-form references are moved
.changeset/space-record-uris-full.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: minor | ||
--- | ||
| ||
# A private repository's pulls, issues, comments and checks are named by their URI in its space | ||
| ||
knotless names a record in a private repository's space as atproto's permissioned data proposal does, `at://<owner>/space/dev.knotless.space/<name>/<author>/<collection>/<rkey>`, wherever it gives one: what `knotless pull`, `issue`, `comment` and `checks` print, what `show` and `list` name, and what new records written into the space refer to. A URI in the public form, `at://<author>/<collection>/<rkey>`, is still taken for one in the repository being asked about, and records written before are read as they were. A knotless older than the one that reads both forms does not find a record named in full: upgrade with `knotless upgrade`. | ||
.changeset/space-short-form-moved.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: patch | ||
--- | ||
| ||
# Records in a private repository that name another in the public form are written again in full | ||
| ||
The first time knotless writes into a private repository's space for you, it rewrites the records you wrote there whose `subject`, `pull`, `reply`, `check` or pull head names another in the public form, `at://<author>/<collection>/<rkey>`, to the full form, through your own login, once. What you typed in a body is left as you wrote it. | ||
crates/knotless/src/checks.rs+6−2
| @@ -4,6 +4,7 @@ | ||
//! private repository. A runner's checks of one repository share a key | ||
//! prefix, so each takes one scan, and a pull's checks a narrower one. | ||
| ||
use crate::aturi::{self, AtUri}; | ||
use crate::env::Ctx; | ||
use crate::identity; | ||
use crate::keys::{check_prefix, checks_of, queue_name}; | ||
| @@ -177,6 +178,7 @@ pub async fn by( | ||
let mut repos = HashMap::new(); | ||
for read in futures::future::join_all(reads).await { | ||
let (runner, theirs, found) = read?; | ||
let space = theirs.space_ref(); | ||
repos.insert(runner.clone(), theirs); | ||
for stored in found { | ||
let Ok(record) = serde_json::from_value::<CheckRecord>(stored.value) else { | ||
| @@ -186,13 +188,15 @@ pub async fn by( | ||
|| filter.commit.is_some_and(|c| !record.commit.starts_with(c)) | ||
|| filter | ||
.pull | ||
.is_some_and(|p| Some(p) != record.pull.as_deref()) | ||
.is_some_and(|p| !record.pull.as_deref().is_some_and(|r| aturi::same(p, r))) | ||
|| filter.workflow.is_some_and(|w| w != record.workflow) | ||
{ | ||
continue; | ||
} | ||
checks.push(Check { | ||
uri: format!("at://{runner}/{}/{}", nsid::CHECK, stored.rkey), | ||
uri: AtUri::public(&runner, nsid::CHECK, &stored.rkey) | ||
.resolved(space.as_ref()) | ||
.to_string(), | ||
runner: runner.clone(), | ||
record, | ||
log_text: None, | ||
crates/knotless/src/forge.rs+43−8
| @@ -746,11 +746,23 @@ impl Target { | ||
} | ||
} | ||
| ||
/// The repository as forge records name it, under the name it has now. | ||
/// The repository as forge records name it, under the name it has now: | ||
/// a private one's record by its URI in the space. | ||
pub fn reference(&self) -> RepoRef { | ||
self.snap | ||
let mut reference = self | ||
.snap | ||
.reference(self.remote.did()) | ||
.expect("an opened repository has a record") | ||
.expect("an opened repository has a record"); | ||
reference.uri = self.named(&reference.uri); | ||
reference | ||
} | ||
| ||
/// `uri`, a record of this repository's, typed or given in either | ||
/// form, as its records are named here: in full, in the space, for a | ||
/// private repository's. One in some other space is as it was, and | ||
/// names nothing here. | ||
pub fn named(&self, uri: &str) -> String { | ||
crate::aturi::resolve(uri, self.remote.space_ref().as_ref()) | ||
} | ||
| ||
fn is_about(&self, value: &Value) -> bool { | ||
| @@ -939,7 +951,7 @@ impl Target { | ||
for r in member.list_all::<Value>(collection).await? { | ||
if self.is_about(&r.value) { | ||
entries.push(Entry { | ||
uri: format!("at://{did}/{collection}/{}", r.rkey), | ||
uri: member.uri_of(collection, &r.rkey), | ||
author: did.clone(), | ||
value: r.value, | ||
}); | ||
| @@ -949,7 +961,7 @@ impl Target { | ||
// A reaction names what it is about, not the repository. | ||
for r in member.list_all::<Value>(nsid::REACTION).await? { | ||
reactions.push(Entry { | ||
uri: format!("at://{did}/{}/{}", nsid::REACTION, r.rkey), | ||
uri: member.uri_of(nsid::REACTION, &r.rkey), | ||
author: did.clone(), | ||
value: r.value, | ||
}); | ||
| @@ -976,6 +988,7 @@ impl Target { | ||
.as_ref() | ||
.map(|s| Space::owner(ctx, &s.uri, &me)), | ||
}; | ||
crate::short_form::move_once(ctx, &mine, &me.auth()).await; | ||
Ok((mine, me)) | ||
} | ||
| ||
| @@ -987,7 +1000,7 @@ impl Target { | ||
mine.apply(vec![remote::create(collection, &rkey, value)], &me.auth()) | ||
.await?; | ||
self.shown(ctx).await; | ||
Ok(format!("at://{}/{collection}/{rkey}", me.did)) | ||
Ok(mine.uri_of(collection, &rkey)) | ||
} | ||
| ||
/// Fails when the repository is archived, and so takes nothing more | ||
| @@ -1066,7 +1079,7 @@ impl Target { | ||
mine.apply(vec![write(nsid::LABEL, &rkey, value.clone())], &me.auth()) | ||
.await?; | ||
Ok(Entry { | ||
uri: format!("at://{}/{}/{rkey}", me.did, nsid::LABEL), | ||
uri: mine.uri_of(nsid::LABEL, &rkey), | ||
author: me.did.clone(), | ||
value, | ||
}) | ||
| @@ -1117,6 +1130,8 @@ pub async fn hide(ctx: &Ctx, target: &RepoUrl, subject: &str, hidden: bool) -> R | ||
bail!("{subject} is not a pull, an issue or a comment"); | ||
} | ||
let t = Target::open(ctx, target).await?; | ||
let subject = t.named(subject); | ||
let subject = subject.as_str(); | ||
let (mine, me) = t.mine(ctx).await?; | ||
if me.did != t.remote.did() { | ||
bail!("only {}'s owner can hide what is said about it", t.url); | ||
| @@ -1566,6 +1581,8 @@ pub async fn comment( | ||
} | ||
let t = Target::open(ctx, target).await?; | ||
t.writable()?; | ||
let subject = t.named(subject); | ||
let subject = subject.as_str(); | ||
let said = |subject: &str, body: &str, images: &[ImageAttachment]| CommentRecord { | ||
repo: t.reference(), | ||
subject: subject.to_string(), | ||
| @@ -1617,7 +1634,7 @@ pub async fn comment( | ||
} | ||
}; | ||
let (mine, me) = t.mine(ctx).await?; | ||
let uri = |rkey: &str| format!("at://{}/{}/{rkey}", me.did, nsid::COMMENT); | ||
let uri = |rkey: &str| mine.uri_of(nsid::COMMENT, rkey); | ||
let (mut writes, mut wrote) = (Vec::new(), Vec::new()); | ||
let mut review = None; | ||
if lines.is_empty() || verdict.is_some() || !body.trim().is_empty() { | ||
| @@ -1662,6 +1679,8 @@ pub async fn comment( | ||
/// thread's author, the pull's, the owner or a collaborator. | ||
pub async fn resolve(ctx: &Ctx, target: &RepoUrl, thread: &str, resolved: bool) -> Result<String> { | ||
let t = Target::open(ctx, target).await?; | ||
let thread = t.named(thread); | ||
let thread = thread.as_str(); | ||
let value = match parse_uri(thread)?.1 { | ||
nsid::COMMENT => t.record(ctx, thread).await?, | ||
_ => None, | ||
| @@ -1722,6 +1741,8 @@ pub fn resolved( | ||
async fn own(ctx: &Ctx, target: &RepoUrl, subject: &str) -> Result<(Remote, Session, Value)> { | ||
let (author, _, _) = parse_uri(subject)?; | ||
let t = Target::open(ctx, target).await?; | ||
let subject = t.named(subject); | ||
let subject = subject.as_str(); | ||
t.writable()?; | ||
let (mine, me) = t.mine(ctx).await?; | ||
if author != me.did { | ||
| @@ -1866,6 +1887,8 @@ pub async fn label( | ||
bail!("name a label to put on or take off"); | ||
} | ||
let t = Target::open(ctx, target).await?; | ||
let subject = t.named(subject); | ||
let subject = subject.as_str(); | ||
let mut defined = Vec::new(); | ||
for name in names { | ||
let Some(label) = t.record.label(name) else { | ||
| @@ -1921,6 +1944,8 @@ pub async fn set_state(ctx: &Ctx, target: &RepoUrl, subject: &str, state: &str) | ||
bail!("a pull or issue is open or closed; merging takes `merge`"); | ||
} | ||
let t = Target::open(ctx, target).await?; | ||
let subject = t.named(subject); | ||
let subject = subject.as_str(); | ||
let record = StateRecord { | ||
repo: t.reference(), | ||
subject: subject.to_string(), | ||
| @@ -2007,6 +2032,8 @@ pub async fn merge( | ||
gate: Gate, | ||
) -> Result<Merged> { | ||
let t = Target::open(ctx, target).await?; | ||
let pull = t.named(pull); | ||
let pull = pull.as_str(); | ||
// The pull as its author's repo has it now, not as the index last saw | ||
// it, and the newest reviews: the index may not have them yet. | ||
let me = session::me(ctx).await?; | ||
| @@ -2372,6 +2399,8 @@ pub struct Comparison { | ||
/// to the merge, as its state records them: its branch may be gone. | ||
pub async fn compare(ctx: &Ctx, target: &RepoUrl, pull: &str) -> Result<Comparison> { | ||
let t = Target::open(ctx, target).await?; | ||
let pull = t.named(pull); | ||
let pull = pull.as_str(); | ||
let (item, _) = t.item(ctx, pull).await?; | ||
if let Some((git, from, merged)) = merged_sides(ctx, &t, &item).await? { | ||
let range = format!("{from}..{merged}"); | ||
| @@ -2422,6 +2451,8 @@ async fn merged_sides(ctx: &Ctx, t: &Target, item: &Item) -> Result<Option<(Git, | ||
/// merged pull's is what its merge brought in, as `compare` has it. | ||
pub async fn pull_diff(ctx: &Ctx, target: &RepoUrl, pull: &str) -> Result<String> { | ||
let t = Target::open(ctx, target).await?; | ||
let pull = t.named(pull); | ||
let pull = pull.as_str(); | ||
let (item, _) = t.item(ctx, pull).await?; | ||
if let Some((git, from, merged)) = merged_sides(ctx, &t, &item).await? { | ||
return crate::read::diff(&git, &from, &merged).await; | ||
| @@ -2574,6 +2605,8 @@ pub async fn retarget( | ||
head: Option<&str>, | ||
) -> Result<()> { | ||
let t = Target::open(ctx, target).await?; | ||
let pull = t.named(pull); | ||
let pull = pull.as_str(); | ||
t.writable()?; | ||
let (item, value) = t.item(ctx, pull).await?; | ||
if item.kind != "pull" { | ||
| @@ -2889,6 +2922,8 @@ async fn taken_down(ctx: &Ctx, subject: &str, author: &str) -> bool { | ||
/// 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 subject = t.named(subject); | ||
let subject = subject.as_str(); | ||
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 | ||
crates/knotless/src/landing.rs+2−0
| @@ -128,6 +128,8 @@ pub fn may(t: Facts, item: &Item, who: &str) -> May { | ||
/// nothing. | ||
pub async fn mergeable(ctx: &Ctx, target: &RepoUrl, pull: &str, who: &str) -> Result<Mergeable> { | ||
let t = Target::open(ctx, target).await?; | ||
let pull = t.named(pull); | ||
let pull = pull.as_str(); | ||
let entries = t.reviewed(ctx, pull, who).await?; | ||
let items = forge::assemble(&t.record, t.remote.did(), &entries); | ||
let item = forge::thread_in(&t.record, t.remote.did(), &entries, &items, pull)?.item; | ||
crates/knotless/src/lib.rs+1−0
| @@ -57,6 +57,7 @@ pub mod reviews; | ||
pub mod sealed; | ||
pub mod secrets; | ||
pub mod session; | ||
pub mod short_form; | ||
pub mod social; | ||
pub mod space; | ||
pub mod space_sync; | ||
crates/knotless/src/manage.rs+3−3
| @@ -754,7 +754,7 @@ pub async fn rerun( | ||
let record = records::RerunRecord { | ||
repo: RepoRef::new(remote.did(), &repo.rkey, &repo.value.key), | ||
ref_name: refname, | ||
pull: pull.map(str::to_string), | ||
pull: pull.map(|p| crate::aturi::resolve(p, remote.space_ref().as_ref())), | ||
workflow: workflow.map(str::to_string), | ||
env, | ||
created_at: records::now(), | ||
| @@ -762,7 +762,7 @@ pub async fn rerun( | ||
let rkey = format!("{}.{}", repo.value.key, keys::tid()); | ||
let write = remote::create(nsid::RERUN, &rkey, records::typed(nsid::RERUN, &record)); | ||
remote.apply(vec![write], &auth.auth()).await?; | ||
Ok(format!("at://{}/{}/{rkey}", remote.did(), nsid::RERUN)) | ||
Ok(remote.uri_of(nsid::RERUN, &rkey)) | ||
} | ||
| ||
/// The ref `name` names in `snap`: itself, else the branch or the tag of | ||
| @@ -1238,7 +1238,7 @@ async fn owned(remote: &Remote, key: &str) -> Result<Vec<serde_json::Value>> { | ||
for (collection, field) in fields.chain([(nsid::STAR, "subject")]) { | ||
for r in remote.list_all::<serde_json::Value>(collection).await? { | ||
if about(&r.value, field) { | ||
gone.insert(format!("at://{did}/{collection}/{}", r.rkey)); | ||
gone.insert(remote.uri_of(collection, &r.rkey)); | ||
writes.push(remote::delete(collection, &r.rkey)); | ||
} | ||
} | ||
crates/knotless/src/queue.rs+7−1
| @@ -123,6 +123,8 @@ async fn open_pull(ctx: &Ctx, t: &Target, pull: &str) -> Result<(Vec<Entry>, Ite | ||
/// One already queued keeps its place. The owner and collaborators may. | ||
pub async fn enqueue(ctx: &Ctx, url: &RepoUrl, pull: &str) -> Result<Place> { | ||
let t = Target::open(ctx, url).await?; | ||
let pull = t.named(pull); | ||
let pull = pull.as_str(); | ||
let me = maintainer(ctx, &t).await?; | ||
if t.record.runners.is_empty() { | ||
bail!( | ||
| @@ -230,6 +232,8 @@ fn short(sha: &str) -> &str { | ||
/// Takes a pull out of the merge queue. The owner and collaborators may. | ||
pub async fn leave(ctx: &Ctx, url: &RepoUrl, pull: &str) -> Result<()> { | ||
let t = Target::open(ctx, url).await?; | ||
let pull = t.named(pull); | ||
let pull = pull.as_str(); | ||
maintainer(ctx, &t).await?; | ||
let (_, item) = open_pull(ctx, &t, pull).await?; | ||
if item.queue.as_ref().is_none_or(|q| q.state != QUEUED) { | ||
| @@ -595,7 +599,9 @@ pub async fn list(ctx: &Ctx, url: &RepoUrl) -> Result<Vec<Waiting>> { | ||
/// it is queued or was ejected and is open. | ||
pub async fn standing(ctx: &Ctx, url: &RepoUrl, pull: &str) -> Result<Option<Waiting>> { | ||
let all = list(ctx, url).await?; | ||
Ok(all.into_iter().find(|w| w.item.uri == pull)) | ||
Ok(all | ||
.into_iter() | ||
.find(|w| crate::aturi::same(&w.item.uri, pull))) | ||
} | ||
| ||
/// A pull squashed onto the one before it in a batch: the head it brought, | ||
crates/knotless/src/remote.rs+45−4
| @@ -1,6 +1,7 @@ | ||
//! One repository's records on its owner's PDS: in their public repo, or, | ||
//! for a private repository, in a space. | ||
| ||
use crate::aturi::{AtUri, SpaceRef}; | ||
use crate::env::Ctx; | ||
use crate::identity::{self, Identity}; | ||
use crate::keys; | ||
| @@ -244,6 +245,28 @@ impl Remote { | ||
&self.identity.did | ||
} | ||
| ||
/// The space a private repository's records are in. | ||
pub fn space_ref(&self) -> Option<SpaceRef> { | ||
let space = self.space.as_ref()?; | ||
SpaceRef::parse(&space.uri).ok() | ||
} | ||
| ||
/// The URI of this repo's record `rkey` in `collection`: in full, in | ||
/// the space, for a private repository's. | ||
pub fn uri_of(&self, collection: &str, rkey: &str) -> String { | ||
let at = AtUri::public(self.did(), collection, rkey); | ||
at.resolved(self.space_ref().as_ref()).to_string() | ||
} | ||
| ||
/// `raw`, read from here: in a space, with the references it holds in | ||
/// the public form given the space. | ||
fn parse<T: DeserializeOwned>(&self, mut raw: RawRecord) -> Result<Stored<T>> { | ||
if let Some(space) = self.space_ref() { | ||
crate::short_form::resolve_refs(&mut raw.value, &space); | ||
} | ||
parse(raw) | ||
} | ||
| ||
/// The space this one points to, for a space named for a later name of | ||
/// a private repository. | ||
pub async fn home(&self) -> Result<Option<String>> { | ||
| @@ -315,7 +338,7 @@ impl Remote { | ||
.read::<RawRecord>("getRecord", &[("collection", collection), ("rkey", rkey)]) | ||
.await; | ||
match res { | ||
Ok(raw) => parse(raw).map(Some), | ||
Ok(raw) => self.parse(raw).map(Some), | ||
Err(err) if XrpcError::of(&err).is_some_and(|e| e.error == "RecordNotFound") => { | ||
Ok(None) | ||
} | ||
| @@ -441,7 +464,7 @@ impl Remote { | ||
return Ok(page); | ||
} | ||
page.next = Some(rkey.to_string()); | ||
page.items.push(parse(raw)?); | ||
page.items.push(self.parse(raw)?); | ||
} | ||
if !full { | ||
page.next = None; | ||
| @@ -672,11 +695,25 @@ impl Remote { | ||
let limit = limit.to_string(); | ||
let params = [("collection", collection), ("limit", limit.as_str())]; | ||
let out: ListOut = self.read("listRecords", ¶ms).await?; | ||
out.records.into_iter().map(parse).collect() | ||
out.records.into_iter().map(|r| self.parse(r)).collect() | ||
} | ||
| ||
/// Every record in `collection`, from the public repo or the space. | ||
pub async fn list_all<T: DeserializeOwned>(&self, collection: &str) -> Result<Vec<Stored<T>>> { | ||
self.list_with(collection, true).await | ||
} | ||
| ||
/// [`Remote::list_all`], each record as it was written, its references | ||
/// in the public form left so. | ||
pub async fn list_as_written(&self, collection: &str) -> Result<Vec<Stored<Value>>> { | ||
self.list_with(collection, false).await | ||
} | ||
| ||
async fn list_with<T: DeserializeOwned>( | ||
&self, | ||
collection: &str, | ||
resolve: bool, | ||
) -> Result<Vec<Stored<T>>> { | ||
let mut all = Vec::new(); | ||
let mut cursor: Option<String> = None; | ||
let limit = PAGE.to_string(); | ||
| @@ -688,7 +725,11 @@ impl Remote { | ||
let out: ListOut = self.read("listRecords", ¶ms).await?; | ||
let full = out.records.len() == PAGE; | ||
for raw in out.records { | ||
all.push(parse(raw)?); | ||
all.push(if resolve { | ||
self.parse(raw)? | ||
} else { | ||
parse(raw)? | ||
}); | ||
} | ||
match out.cursor { | ||
Some(next) if full => cursor = Some(next), | ||
crates/knotless/src/revert.rs+2−0
| @@ -41,6 +41,8 @@ pub async fn revert( | ||
reason: Option<&str>, | ||
) -> Result<Reverted> { | ||
let t = Target::open(ctx, target).await?; | ||
let pull = t.named(pull); | ||
let pull = pull.as_str(); | ||
let me = session::me(ctx).await?; | ||
if me.did != t.remote.did() && !t.record.is_collaborator(&me.did) { | ||
bail!("only the owner of {target} and its collaborators revert a pull"); | ||