chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
A private repository's records are named by their URI in its space
.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-record-uris-read.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: patch | ||
--- | ||
| ||
# A record in a private repository's space can be named by its full URI | ||
| ||
Wherever knotless takes a pull's, an issue's or a comment's `at://` URI, as `knotless show`, `comment`, `merge`, `checks --pull` and a pull's `Fixes`, it reads a private repository's record by its URI in the space too, `at://<owner>/space/dev.knotless.space/<name>/<author>/<collection>/<rkey>`, as atproto's permissioned data proposal names it, as well as by `at://<author>/<collection>/<rkey>`. | ||
crates/knotless/src/aturi.rsnew+341−0
| @@ -0,0 +1,341 @@ | ||
//! `at://` URIs of records, public or in a space, as atproto's | ||
//! permissioned data proposal (0016) addresses them: | ||
//! | ||
//! ```text | ||
//! Public: at://{authorDid}/{collection}/{rkey} | ||
//! Space: at://{spaceDid}/space/{spaceType}/{skey} | ||
//! Record: at://{spaceDid}/space/{spaceType}/{skey}/{authorDid}/{collection}/{rkey} | ||
//! ``` | ||
//! | ||
//! A public collection is an NSID, which has dots, and the `space` marker | ||
//! has none, so the two forms never read as each other. A record in a | ||
//! space is named in full only with every segment through its rkey: the | ||
//! public form of its author, collection and rkey names it only among the | ||
//! records of one space, which [`AtUri::resolved`] turns into its full | ||
//! name once that space is known. | ||
| ||
use anyhow::{Result, bail}; | ||
use std::fmt; | ||
use std::str::FromStr; | ||
| ||
/// The path segment that marks a URI as naming a space or a record in one. | ||
pub const SPACE_MARKER: &str = "space"; | ||
| ||
/// A space, as distinct from a record in one: | ||
/// `at://{spaceDid}/space/{spaceType}/{skey}`. | ||
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] | ||
pub struct SpaceRef { | ||
/// The space's authority. | ||
pub space_did: String, | ||
/// The space type, an NSID. | ||
pub space_type: String, | ||
/// The space key, which tells spaces of one type and authority apart. | ||
pub skey: String, | ||
} | ||
| ||
impl SpaceRef { | ||
pub fn new(space_did: &str, space_type: &str, skey: &str) -> SpaceRef { | ||
SpaceRef { | ||
space_did: space_did.to_string(), | ||
space_type: space_type.to_string(), | ||
skey: skey.to_string(), | ||
} | ||
} | ||
| ||
/// The space `uri` names: only the space's own URI, not a record's in | ||
/// it. | ||
pub fn parse(uri: &str) -> Result<SpaceRef> { | ||
match segments(uri)?[..] { | ||
[did, SPACE_MARKER, space_type, skey] => space(uri, did, space_type, skey), | ||
_ => bail!("{uri} is not at://<did>/{SPACE_MARKER}/<type>/<key>"), | ||
} | ||
} | ||
} | ||
| ||
impl fmt::Display for SpaceRef { | ||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
let SpaceRef { | ||
space_did, | ||
space_type, | ||
skey, | ||
} = self; | ||
write!(f, "at://{space_did}/{SPACE_MARKER}/{space_type}/{skey}") | ||
} | ||
} | ||
| ||
impl FromStr for SpaceRef { | ||
type Err = anyhow::Error; | ||
| ||
fn from_str(uri: &str) -> Result<SpaceRef> { | ||
SpaceRef::parse(uri) | ||
} | ||
} | ||
| ||
/// A record's URI: in public records, or in a space. | ||
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] | ||
pub struct AtUri { | ||
/// The space the record is in; none for a public record. | ||
pub space: Option<SpaceRef>, | ||
/// Whose record it is. A public URI's authority, which may be a | ||
/// handle; in a space, always a DID. | ||
pub author: String, | ||
pub collection: String, | ||
pub rkey: String, | ||
} | ||
| ||
impl AtUri { | ||
/// `author`'s public record. | ||
pub fn public(author: &str, collection: &str, rkey: &str) -> AtUri { | ||
AtUri { | ||
space: None, | ||
author: author.to_string(), | ||
collection: collection.to_string(), | ||
rkey: rkey.to_string(), | ||
} | ||
} | ||
| ||
/// `author`'s record in `space`. | ||
pub fn in_space(space: &SpaceRef, author: &str, collection: &str, rkey: &str) -> AtUri { | ||
AtUri { | ||
space: Some(space.clone()), | ||
..AtUri::public(author, collection, rkey) | ||
} | ||
} | ||
| ||
/// The record `uri` names, in either form. | ||
pub fn parse(uri: &str) -> Result<AtUri> { | ||
match segments(uri)?[..] { | ||
[ | ||
did, | ||
SPACE_MARKER, | ||
space_type, | ||
skey, | ||
author, | ||
collection, | ||
rkey, | ||
] => { | ||
let space = space(uri, did, space_type, skey)?; | ||
if !author.starts_with("did:") { | ||
bail!("{uri}: a record in a space is named by its author's DID"); | ||
} | ||
Ok(AtUri::in_space(&space, author, collection, rkey)) | ||
} | ||
[_, SPACE_MARKER, ..] => { | ||
bail!( | ||
"{uri} is not at://<did>/{SPACE_MARKER}/<type>/<key>/<did>/<collection>/<rkey>" | ||
) | ||
} | ||
[author, collection, rkey] => Ok(AtUri::public(author, collection, rkey)), | ||
_ => bail!("{uri} is not at://<did>/<collection>/<rkey>"), | ||
} | ||
} | ||
| ||
/// The record, named in full: one named in the public form takes | ||
/// `space`, when the URI was found where that is the space its records | ||
/// are in. One already in a space, or with none given, is as it was. | ||
pub fn resolved(mut self, space: Option<&SpaceRef>) -> AtUri { | ||
if self.space.is_none() { | ||
self.space = space.cloned(); | ||
} | ||
self | ||
} | ||
| ||
/// The record's author, collection and rkey, in the public form: what | ||
/// names it among the records of its space, and what a key made from | ||
/// a record's URI is made from, so it is the same in either form. | ||
pub fn short(&self) -> String { | ||
format!("at://{}/{}/{}", self.author, self.collection, self.rkey) | ||
} | ||
} | ||
| ||
impl fmt::Display for AtUri { | ||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { | ||
let AtUri { | ||
author, | ||
collection, | ||
rkey, | ||
.. | ||
} = self; | ||
match &self.space { | ||
Some(space) => write!(f, "{space}/{author}/{collection}/{rkey}"), | ||
None => write!(f, "at://{author}/{collection}/{rkey}"), | ||
} | ||
} | ||
} | ||
| ||
impl FromStr for AtUri { | ||
type Err = anyhow::Error; | ||
| ||
fn from_str(uri: &str) -> Result<AtUri> { | ||
AtUri::parse(uri) | ||
} | ||
} | ||
| ||
/// `uri`'s author, collection and rkey, in either form, borrowed from it. | ||
pub fn parts(uri: &str) -> Result<(&str, &str, &str)> { | ||
match segments(uri)?[..] { | ||
[_, SPACE_MARKER, _, _, author, collection, rkey] if author.starts_with("did:") => { | ||
Ok((author, collection, rkey)) | ||
} | ||
[_, SPACE_MARKER, ..] => bail!("{uri} is not a record in a space"), | ||
[author, collection, rkey] => Ok((author, collection, rkey)), | ||
_ => bail!("{uri} is not at://<did>/<collection>/<rkey>"), | ||
} | ||
} | ||
| ||
/// `uri` as [`AtUri::resolved`] names it in `space`; what does not parse | ||
/// as a record's URI is as it was. | ||
pub fn resolve(uri: &str, space: Option<&SpaceRef>) -> String { | ||
match AtUri::parse(uri) { | ||
Ok(at) => at.resolved(space).to_string(), | ||
Err(_) => uri.to_string(), | ||
} | ||
} | ||
| ||
/// [`AtUri::short`] of `uri`; what does not parse as a record's URI is as | ||
/// it was. | ||
pub fn short(uri: &str) -> String { | ||
AtUri::parse(uri).map_or_else(|_| uri.to_string(), |at| at.short()) | ||
} | ||
| ||
/// Whether `a` and `b` name the same record: the same author, collection | ||
/// and rkey, and the same space, unless one names none, as a URI typed or | ||
/// written in the public form for a record in a space does. | ||
pub fn same(a: &str, b: &str) -> bool { | ||
match (AtUri::parse(a), AtUri::parse(b)) { | ||
(Ok(a), Ok(b)) => { | ||
let spaces = match (&a.space, &b.space) { | ||
(Some(x), Some(y)) => x == y, | ||
_ => true, | ||
}; | ||
spaces && a.short() == b.short() | ||
} | ||
_ => a == b, | ||
} | ||
} | ||
| ||
/// The path segments after `at://`, the authority first, none empty. | ||
fn segments(uri: &str) -> Result<Vec<&str>> { | ||
let Some(rest) = uri.strip_prefix("at://") else { | ||
bail!("{uri} is not an at:// URI"); | ||
}; | ||
let parts: Vec<&str> = rest.split('/').collect(); | ||
if parts.iter().any(|p| p.is_empty()) { | ||
bail!("{uri} has an empty segment"); | ||
} | ||
Ok(parts) | ||
} | ||
| ||
fn space(uri: &str, did: &str, space_type: &str, skey: &str) -> Result<SpaceRef> { | ||
if !did.starts_with("did:") { | ||
bail!("{uri}: a space's authority is a DID"); | ||
} | ||
if !space_type.contains('.') { | ||
bail!("{uri}: {space_type} is no space type"); | ||
} | ||
Ok(SpaceRef::new(did, space_type, skey)) | ||
} | ||
| ||
#[cfg(test)] | ||
mod tests { | ||
use super::*; | ||
| ||
const SPACE: &str = "at://did:plc:o/space/dev.knotless.space/r"; | ||
const RECORD: &str = "at://did:plc:o/space/dev.knotless.space/r/did:plc:a/dev.knotless.pull/3k"; | ||
const PUBLIC: &str = "at://did:plc:a/dev.knotless.pull/3k"; | ||
| ||
#[test] | ||
fn a_record_in_a_space_reads_and_writes_back_whole() { | ||
let at = AtUri::parse(RECORD).unwrap(); | ||
let space = SpaceRef::new("did:plc:o", "dev.knotless.space", "r"); | ||
assert_eq!(at.space, Some(space.clone())); | ||
assert_eq!( | ||
(at.author.as_str(), at.collection.as_str(), at.rkey.as_str()), | ||
("did:plc:a", "dev.knotless.pull", "3k") | ||
); | ||
assert_eq!(at.to_string(), RECORD); | ||
assert_eq!(at.short(), PUBLIC); | ||
let made = AtUri::in_space(&space, "did:plc:a", "dev.knotless.pull", "3k"); | ||
assert_eq!(made, at); | ||
assert_eq!(RECORD.parse::<AtUri>().unwrap(), at); | ||
} | ||
| ||
#[test] | ||
fn a_public_record_has_no_space() { | ||
let at = AtUri::parse(PUBLIC).unwrap(); | ||
assert_eq!(at.space, None); | ||
assert_eq!(at.to_string(), PUBLIC); | ||
assert_eq!(at, AtUri::public("did:plc:a", "dev.knotless.pull", "3k")); | ||
// A public URI's authority may be a handle. | ||
let named = AtUri::parse("at://alice.test/dev.knotless.repo/r").unwrap(); | ||
assert_eq!(named.author, "alice.test"); | ||
} | ||
| ||
#[test] | ||
fn a_space_reads_and_writes_back_whole() { | ||
let space = SpaceRef::parse(SPACE).unwrap(); | ||
assert_eq!(space.to_string(), SPACE); | ||
assert_eq!(SPACE.parse::<SpaceRef>().unwrap(), space); | ||
assert!(SpaceRef::parse(RECORD).is_err()); | ||
assert!(SpaceRef::parse(PUBLIC).is_err()); | ||
} | ||
| ||
#[test] | ||
fn what_is_not_either_form_is_refused() { | ||
for bad in [ | ||
"https://did:plc:a/c/r", | ||
"at://did:plc:a/c", | ||
"at://did:plc:a//r", | ||
"at://did:plc:a/c/r/x", | ||
SPACE, | ||
"at://did:plc:o/space/dev.knotless.space/r/did:plc:a/c", | ||
"at://alice.test/space/dev.knotless.space/r/did:plc:a/c/k", | ||
"at://did:plc:o/space/notype/r/did:plc:a/c/k", | ||
"at://did:plc:o/space/dev.knotless.space/r/alice.test/c/k", | ||
] { | ||
assert!(AtUri::parse(bad).is_err(), "{bad}"); | ||
} | ||
for bad in [ | ||
"did:plc:o/space/dev.knotless.space/r", | ||
"at://alice.test/space/dev.knotless.space/r", | ||
"at://did:plc:o/space/notype/r", | ||
] { | ||
assert!(SpaceRef::parse(bad).is_err(), "{bad}"); | ||
} | ||
assert!(parts(SPACE).is_err()); | ||
assert!(parts("at://did:plc:a/c").is_err()); | ||
assert!(parts("at://did:plc:o/space/t.t/r/alice.test/c/k").is_err()); | ||
} | ||
| ||
#[test] | ||
fn parts_are_the_author_collection_and_rkey_of_either_form() { | ||
let want = ("did:plc:a", "dev.knotless.pull", "3k"); | ||
assert_eq!(parts(RECORD).unwrap(), want); | ||
assert_eq!(parts(PUBLIC).unwrap(), want); | ||
} | ||
| ||
#[test] | ||
fn a_public_form_resolves_into_the_space_it_was_found_in() { | ||
let space = SpaceRef::parse(SPACE).unwrap(); | ||
assert_eq!(resolve(PUBLIC, Some(&space)), RECORD); | ||
assert_eq!(resolve(PUBLIC, None), PUBLIC); | ||
// Already in a space, it stays in its own. | ||
let other = SpaceRef::new("did:plc:x", "dev.knotless.space", "y"); | ||
assert_eq!(resolve(RECORD, Some(&other)), RECORD); | ||
assert_eq!(resolve("not a uri", Some(&space)), "not a uri"); | ||
assert_eq!(short(RECORD), PUBLIC); | ||
assert_eq!(short("not a uri"), "not a uri"); | ||
} | ||
| ||
#[test] | ||
fn the_same_record_is_the_same_in_either_form() { | ||
assert!(same(RECORD, PUBLIC)); | ||
assert!(same(PUBLIC, RECORD)); | ||
assert!(same(RECORD, RECORD)); | ||
let elsewhere = "at://did:plc:x/space/dev.knotless.space/r/did:plc:a/dev.knotless.pull/3k"; | ||
assert!(!same(RECORD, elsewhere)); | ||
assert!(!same(PUBLIC, "at://did:plc:a/dev.knotless.pull/4k")); | ||
assert!(same("x", "x")); | ||
assert!(!same("x", PUBLIC)); | ||
} | ||
} | ||
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/closing.rs+44−8
| @@ -5,7 +5,8 @@ | ||
//! | ||
//! Issues have no short numbers, so a pull names one by its page, | ||
//! `https://<appview>/<owner>/<repo>/issue/<did>/<rkey>`, or by its | ||
//! `at://<did>/dev.knotless.issue/<rkey>` URI, either maybe as a Markdown | ||
//! `at://<did>/dev.knotless.issue/<rkey>` URI (or, for a private | ||
//! repository's, its URI in full, in the repository's space), either maybe as a Markdown | ||
//! link's target: `Fixes [the crash](https://...)`. Only issues on the pull's | ||
//! own repository count: others are passed over. The merge closes each | ||
//! that is open, and that whoever merges may close, with a | ||
| @@ -14,6 +15,7 @@ | ||
//! A pull that names an issue the same way with no keyword before it | ||
//! mentions it: the issue's page lists it, as it lists those that close it. | ||
| ||
use crate::aturi::{self, AtUri}; | ||
use crate::forge::{self, Item, Target}; | ||
use crate::keys; | ||
use crate::records::{self, RepoRecord, StateRecord, nsid}; | ||
| @@ -90,10 +92,13 @@ fn issue(word: &str) -> Option<String> { | ||
let word = word.trim_start_matches(['<', '(']); | ||
let word = word.trim_end_matches(['>', ')', '.', ',', ';', ':', '!', '?']); | ||
let (did, rkey) = match word.strip_prefix("at://") { | ||
Some(_) => match forge::parse_uri(word).ok()? { | ||
(did, nsid::ISSUE, rkey) => (did.to_string(), rkey), | ||
_ => return None, | ||
}, | ||
Some(_) => { | ||
// Named in a space, it is kept so; in the public form, it | ||
// names an issue among those of the pull's own repository. | ||
let at = AtUri::parse(word).ok()?; | ||
let whole = at.collection == nsid::ISSUE && at.author.starts_with("did:"); | ||
return whole.then(|| at.to_string()); | ||
} | ||
None => { | ||
let page = word | ||
.strip_prefix("https://") | ||
| @@ -126,7 +131,7 @@ pub fn closes<'a>(pull: &Item, items: &'a [Item]) -> Vec<&'a Item> { | ||
let named = named_issues(&text); | ||
let found = named | ||
.iter() | ||
.filter_map(|uri| items.iter().find(|i| &i.uri == uri)); | ||
.filter_map(|uri| items.iter().find(|i| aturi::same(&i.uri, uri))); | ||
found.collect() | ||
} | ||
| ||
| @@ -144,8 +149,8 @@ pub fn mentioning<'a>(issue: &Item, items: &'a [Item]) -> Vec<(&'a Item, bool)> | ||
pull.title, | ||
pull.body.as_deref().unwrap_or_default() | ||
); | ||
let names = mentioned_issues(&text).contains(&issue.uri); | ||
names.then(|| (pull, named_issues(&text).contains(&issue.uri))) | ||
let names = |uris: Vec<String>| uris.iter().any(|u| aturi::same(u, &issue.uri)); | ||
names(mentioned_issues(&text)).then(|| (pull, names(named_issues(&text)))) | ||
}; | ||
pulls.filter_map(said).collect() | ||
} | ||
| @@ -233,6 +238,9 @@ mod tests { | ||
] { | ||
assert!(named_issues(&text).is_empty(), "{text}"); | ||
} | ||
let spaced = "at://did:plc:o/space/dev.knotless.space/r/did:plc:a/dev.knotless.issue/3abc"; | ||
assert_eq!(named_issues(&format!("Fixes {spaced}")), [spaced]); | ||
assert!(named_issues("Fixes at://did:plc:o/space/dev.knotless.space/r").is_empty()); | ||
let two = format!("Fixes {I}, fixes {page} and closes {I}2"); | ||
assert_eq!(named_issues(&two), [I.to_string(), format!("{I}2")]); | ||
} | ||
| @@ -346,4 +354,32 @@ mod tests { | ||
let issue = item(open, "issue", "open", "did:plc:a", Some(&body)); | ||
assert!(closes(&issue, &items).is_empty()); | ||
} | ||
| ||
/// A private repository's issue is named in full, in its space; a | ||
/// pull that names it in the public form, as one written before did, | ||
/// closes and mentions it still. | ||
#[test] | ||
fn an_issue_in_a_space_is_named_either_way() { | ||
let space = "at://did:plc:o/space/dev.knotless.space/r"; | ||
let spaced = format!("{space}/did:plc:a/dev.knotless.issue/1"); | ||
let issue = item(&spaced, "issue", "open", "did:plc:a", None); | ||
for named in ["at://did:plc:a/dev.knotless.issue/1", spaced.as_str()] { | ||
let body = format!("Fixes {named}"); | ||
let pull = item( | ||
&format!("{space}/did:plc:b/dev.knotless.pull/p"), | ||
"pull", | ||
"open", | ||
"did:plc:b", | ||
Some(&body), | ||
); | ||
let items = vec![issue.clone(), pull.clone()]; | ||
assert_eq!(closes(&pull, &items).len(), 1, "{named}"); | ||
assert_eq!(mentioning(&issue, &items).len(), 1, "{named}"); | ||
} | ||
// The same author's and rkey's issue in another space is not it. | ||
let other = "at://did:plc:x/space/dev.knotless.space/r/did:plc:a/dev.knotless.issue/1"; | ||
let body = format!("Fixes {other}"); | ||
let pull = item(&spaced, "pull", "open", "did:plc:b", Some(&body)); | ||
assert!(closes(&pull, &[issue, pull.clone()]).is_empty()); | ||
} | ||
} | ||
crates/knotless/src/forge.rs+53−16
| @@ -53,15 +53,11 @@ pub fn repo_uri(did: &str, name: &str) -> String { | ||
format!("at://{did}/{}/{name}", nsid::REPO) | ||
} | ||
| ||
/// An `at://did/collection/rkey` URI's parts. | ||
/// A record's author, collection and rkey, from its URI in either form | ||
/// ([`crate::aturi`]), its author by DID. | ||
pub fn parse_uri(uri: &str) -> Result<(&str, &str, &str)> { | ||
let parts: Vec<&str> = uri | ||
.strip_prefix("at://") | ||
.context("not an at:// URI")? | ||
.split('/') | ||
.collect(); | ||
match parts[..] { | ||
[did, collection, rkey] if did.starts_with("did:") => Ok((did, collection, rkey)), | ||
match crate::aturi::parts(uri)? { | ||
(did, collection, rkey) if did.starts_with("did:") => Ok((did, collection, rkey)), | ||
_ => bail!("{uri} is not at://<did>/<collection>/<rkey>"), | ||
} | ||
} | ||
| @@ -750,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 { | ||
| @@ -943,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, | ||
}); | ||
| @@ -953,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, | ||
}); | ||
| @@ -991,7 +999,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 | ||
| @@ -1070,7 +1078,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, | ||
}) | ||
| @@ -1121,6 +1129,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); | ||
| @@ -1570,6 +1580,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(), | ||
| @@ -1621,7 +1633,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() { | ||
| @@ -1666,6 +1678,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, | ||
| @@ -1726,6 +1740,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 { | ||
| @@ -1870,6 +1886,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 { | ||
| @@ -1925,6 +1943,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(), | ||
| @@ -2011,6 +2031,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?; | ||
| @@ -2376,6 +2398,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}"); | ||
| @@ -2426,6 +2450,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; | ||
| @@ -2578,6 +2604,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" { | ||
| @@ -2893,6 +2921,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 | ||
| @@ -3289,6 +3319,13 @@ mod tests { | ||
assert!(parse_uri("https://x").is_err()); | ||
assert!(parse_uri("at://did:plc:a/c").is_err()); | ||
assert!(parse_uri("at://handle/c/r").is_err()); | ||
let spaced = "at://did:plc:a/space/dev.knotless.space/r/did:plc:b/dev.knotless.pull/p"; | ||
assert_eq!( | ||
parse_uri(spaced).unwrap(), | ||
("did:plc:b", "dev.knotless.pull", "p") | ||
); | ||
let repo = "at://did:plc:a/space/dev.knotless.space/r/did:plc:a/dev.knotless.repo/r"; | ||
assert_eq!(repo_url(repo).unwrap().name, "r"); | ||
} | ||
| ||
#[test] | ||
crates/knotless/src/keys.rs+12−3
| @@ -61,9 +61,9 @@ pub fn release_rkey(key: &str, package: &str, version: &str) -> String { | ||
} | ||
| ||
/// The label record an account keeps for `subject`, a pull's or issue's | ||
/// URI, on the repository `key`. | ||
/// URI, on the repository `key`: the same for either form of the URI. | ||
pub fn label_rkey(key: &str, subject: &str) -> String { | ||
format!("{key}.{}", ref_hash(subject)) | ||
format!("{key}.{}", ref_hash(&crate::aturi::short(subject))) | ||
} | ||
| ||
/// The claim a push creates to move `name` off generation `generation`. | ||
| @@ -81,7 +81,8 @@ pub fn check_prefix(owner: &str, key: &str) -> String { | ||
/// What the keys of every check of `name`, a ref or a pull's AT-URI, in | ||
/// `owner`'s repository `key` start with: a scan of one pull's checks. | ||
pub fn checks_of(owner: &str, key: &str, name: &str) -> String { | ||
format!("{}.{}", check_prefix(owner, key), ref_hash(name)) | ||
let name = crate::aturi::short(name); | ||
format!("{}.{}", check_prefix(owner, key), ref_hash(&name)) | ||
} | ||
| ||
/// The name a runner keys the checks of `branch`'s merge queue by, as a | ||
| @@ -163,6 +164,14 @@ mod tests { | ||
); | ||
let issue = "at://did:plc:a/dev.knotless.issue/3kabc"; | ||
assert_eq!(label_rkey(key, issue), format!("{key}.{}", ref_hash(issue))); | ||
// A record in a space is keyed as its public form is. | ||
let spaced = "at://did:plc:o/space/dev.knotless.space/r/did:plc:a/dev.knotless.issue/3kabc"; | ||
assert_eq!(label_rkey(key, spaced), label_rkey(key, issue)); | ||
assert_eq!(checks_of("o", key, spaced), checks_of("o", key, issue)); | ||
assert_ne!( | ||
checks_of("o", key, "refs/heads/main"), | ||
checks_of("o", key, issue) | ||
); | ||
let pack = pack_rkey(key); | ||
assert!(tid_micros(suffix(key, &pack).unwrap()).is_some()); | ||
assert_eq!(suffix(key, "other.x"), None); | ||
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+2−0
| @@ -12,6 +12,7 @@ | ||
| ||
pub mod account; | ||
pub mod approvals; | ||
pub mod aturi; | ||
pub mod auth; | ||
pub mod base32; | ||
pub mod checks; | ||
| @@ -56,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)); | ||
} | ||
} | ||