chadtmiller.com/knotless
Git hosted in your atproto account, on any stock PDS, fast enough for agents.
git clone knotless://chadtmiller.com/knotlessneeds the knotless git helper. set it up
Forge: edit pulls, issues and comments; delete comments
Showing 1 changed file with 81 additions and 5 deletions
crates/knotless/src/forge.rs+81−5
| @@ -426,20 +426,27 @@ impl Target { | ||
/// The pull or issue `subject` names, read from its author's repo, so | ||
/// it is there the moment it is written. | ||
async fn subject(&self, ctx: &Ctx, subject: &str) -> Result<(String, Value)> { | ||
let (did, collection, rkey) = parse_uri(subject)?; | ||
let (_, collection, _) = parse_uri(subject)?; | ||
if collection != nsid::PULL && collection != nsid::ISSUE { | ||
bail!("{subject} is not a pull or an issue"); | ||
} | ||
let Some(value) = self.record(ctx, subject).await? else { | ||
bail!("no pull or issue {subject} on {}", self.url); | ||
}; | ||
Ok((collection.to_string(), value)) | ||
} | ||
| ||
/// The forge record `uri` names, if it is about this repository, read | ||
/// from its author's repo: in the space when there is one. | ||
async fn record(&self, ctx: &Ctx, uri: &str) -> Result<Option<Value>> { | ||
let (did, collection, rkey) = parse_uri(uri)?; | ||
let space = match self.remote.space { | ||
Some(_) => Some(self.joined(ctx).await?), | ||
None => None, | ||
}; | ||
let author = self.member(ctx, did, space.as_ref()).await?; | ||
let found = author.get_record::<Value>(collection, rkey).await?; | ||
let Some(found) = found.filter(|f| self.is_about(&f.value)) else { | ||
bail!("no pull or issue {subject} on {}", self.url); | ||
}; | ||
Ok((collection.to_string(), found.value)) | ||
Ok(found.map(|f| f.value).filter(|v| self.is_about(v))) | ||
} | ||
| ||
/// Reads every member's repo in the space. | ||
| @@ -641,6 +648,7 @@ pub async fn open_pull( | ||
title: title.to_string(), | ||
body: body.map(str::to_string), | ||
created_at: records::now(), | ||
updated_at: None, | ||
}; | ||
t.write(ctx, nsid::PULL, records::typed(nsid::PULL, &record)) | ||
.await | ||
| @@ -658,6 +666,7 @@ pub async fn open_issue( | ||
title: title.to_string(), | ||
body: body.map(str::to_string), | ||
created_at: records::now(), | ||
updated_at: None, | ||
}; | ||
t.write(ctx, nsid::ISSUE, records::typed(nsid::ISSUE, &record)) | ||
.await | ||
| @@ -698,11 +707,78 @@ pub async fn comment( | ||
verdict: verdict.map(str::to_string), | ||
commit, | ||
created_at: records::now(), | ||
updated_at: None, | ||
}; | ||
t.write(ctx, nsid::COMMENT, records::typed(nsid::COMMENT, &record)) | ||
.await | ||
} | ||
| ||
/// `subject`, a record I wrote about the repository at `target`, with | ||
/// where I write. | ||
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 (mine, me) = t.mine(ctx).await?; | ||
if author != me.did { | ||
bail!("only the author of {subject} can change or delete it"); | ||
} | ||
let value = t.record(ctx, subject).await?; | ||
let value = value.with_context(|| format!("no {subject} on {}", t.url))?; | ||
Ok((mine, me, value)) | ||
} | ||
| ||
/// Changes what a pull or issue says, its title or body, or what a comment | ||
/// says. Only its author can: the record is in their account. An empty | ||
/// body takes a pull's or issue's away; a comment keeps one. | ||
pub async fn edit( | ||
ctx: &Ctx, | ||
target: &RepoUrl, | ||
subject: &str, | ||
title: Option<&str>, | ||
body: Option<&str>, | ||
) -> Result<()> { | ||
let (_, collection, rkey) = parse_uri(subject)?; | ||
match (collection, title, body) { | ||
(nsid::PULL | nsid::ISSUE, None, None) => { | ||
bail!("nothing to change: give a title, a body or both") | ||
} | ||
(nsid::PULL | nsid::ISSUE, _, _) => {} | ||
(nsid::COMMENT, None, Some(body)) if !body.is_empty() => {} | ||
(nsid::COMMENT, _, _) => bail!("a comment takes a new body, and nothing else"), | ||
_ => bail!("{subject} is not a pull, an issue or a comment"), | ||
} | ||
let (mine, me, mut value) = own(ctx, target, subject).await?; | ||
if let Some(title) = title { | ||
value["title"] = title.into(); | ||
} | ||
match body { | ||
Some("") => { | ||
let fields = value.as_object_mut().expect("a record is an object"); | ||
fields.remove("body"); | ||
} | ||
Some(body) => value["body"] = body.into(), | ||
None => {} | ||
} | ||
value["updatedAt"] = records::now().into(); | ||
mine.apply(vec![remote::update(collection, rkey, value)], &me.auth()) | ||
.await?; | ||
Ok(()) | ||
} | ||
| ||
/// Deletes a comment or review. Only its author can; the repository's | ||
/// owner hides what others say instead. Pulls and issues are closed, not | ||
/// deleted, which would leave what was said about them nowhere. | ||
pub async fn delete_comment(ctx: &Ctx, target: &RepoUrl, subject: &str) -> Result<()> { | ||
let (_, collection, rkey) = parse_uri(subject)?; | ||
if collection != nsid::COMMENT { | ||
bail!("{subject} is not a comment: close a pull or an issue instead"); | ||
} | ||
let (mine, me, _) = own(ctx, target, subject).await?; | ||
mine.apply(vec![remote::delete(collection, rkey)], &me.auth()) | ||
.await?; | ||
Ok(()) | ||
} | ||
| ||
/// Closes or reopens a pull or issue. | ||
pub async fn set_state(ctx: &Ctx, target: &RepoUrl, subject: &str, state: &str) -> Result<String> { | ||
if state != "open" && state != "closed" { | ||