chadtmiller.com/knotless

Git hosted in your atproto account, on any stock PDS, fast enough for agents.

git clone knotless://chadtmiller.com/knotless

needs the knotless git helper. set it up

Forge: edit pulls, issues and comments; delete comments

chadtmiller.com committed on Oct 22, 2026 parent e5c5603 commit 675c1ce
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" {