~ knotless Search/
↑↓ move↵ open⇧↵ all results

chadtmiller.com/knotless

git that lives in your Atmosphere account

knotless.dev
1 collaborator

knotless 0.13.0 Latest 2 packages, 1 image

1
10

A private repository's records are named by their URI in its space

Closed chadtmiller.com asked to merge into main from chadtmiller.com/knotless:space/mint
.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));
}
}
crates/knotless/src/org.rs+1−1
crates/knotless/src/queue.rs+7−1
crates/knotless/src/records.rs+12−1
crates/knotless/src/remote.rs+27−4
crates/knotless/src/revert.rs+2−0
crates/knotless/src/short_form.rsnew+135−0
crates/knotless/src/social.rs+2−0
crates/knotless/src/space.rs+2−2
crates/knotless-run/src/check.rs+7−0
crates/knotless-run/src/lib.rs+1−1
crates/knotless-run/src/queue.rs+5−1
crates/knotless-serve/src/web/actor.rs+18−15
crates/knotless-serve/src/web/landing.rs+5−1
crates/knotless-serve/src/web/preview.rs+3−5
crates/knotless-serve/src/web/reactions.rs+2−1
crates/knotless-serve/src/web/repo.rs+3−5
crates/knotless-serve/src/web/repos.rs+1−1
crates/knotless-serve/src/web/steps.rs+4−4
crates/knotless-serve/src/web/threads.rs+2−1
crates/knotless-serve/src/index.rs+147−17
crates/knotless-serve/src/web.rs+21−6
crates/knotless-serve/tests/gateway.rs+8−4
docs/design/access.md+26−0
harness/answers.mjs+17−1
web/src/lib/format.ts+16−5