chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
Pulls and issues are assigned, and maintainers ask for reviews
.changeset/assignees.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: minor | ||
--- | ||
| ||
# Pulls and issues are assigned, and maintainers ask for reviews | ||
| ||
The owner and collaborators assign a pull or an issue to accounts with `knotless assign` and take them off with `unassign`, and ask anyone to review a pull, not only its author, with `knotless ask-review` and `unask-review`. Each stops asking only those it asked. `knotless show` lists the assignees, `knotless list -q` and the MCP's `list_items` take `assignee:<handle, did or @me>` and `no:assignee`, and the MCP's `assign_item` does either. Those assigned hear of it as an `assigned` notification, and those asked as a `review-requested` one. The pages do the same under an item's title. | ||
crates/knotless/src/cli.rs+36−1
| @@ -273,6 +273,14 @@ const USAGE: &str = "usage: knotless <command> | ||
label <url> <at-uri> <label>... as owner or collaborator, put labels on | ||
a pull or issue | ||
unlabel <url> <at-uri> <label>... take them off | ||
assign <url> <at-uri> <handle>... as owner or collaborator, assign a pull | ||
or issue to them | ||
unassign <url> <at-uri> <handle>... take them off it | ||
ask-review <url> <at-uri> <handle>... | ||
ask them to review a pull: as its author, | ||
or as owner or collaborator | ||
unask-review <url> <at-uri> <handle>... | ||
stop asking them, as whoever asked | ||
labels <url> the labels pulls and issues may carry | ||
labels <url> add <name> [--color <#hex>] [--description <text>] [--exclusive] | ||
define one, or redefine it; of a scope's | ||
| @@ -1761,7 +1769,7 @@ async fn command( | ||
other => bail!("unexpected {other}"), | ||
} | ||
} | ||
if !filter.reviewers.is_empty() { | ||
if filter.names() { | ||
let me = session::me(ctx).await.ok(); | ||
filter = filter | ||
.resolve(ctx, me.as_ref().map(|m| m.did.as_str())) | ||
| @@ -1791,6 +1799,14 @@ async fn command( | ||
} | ||
writeln!(out, "review requested: {}", named.join(", "))?; | ||
} | ||
if !i.assignees.is_empty() { | ||
let mut named = Vec::new(); | ||
for did in &i.assignees { | ||
let handle = crate::identity::handle(ctx, did).await; | ||
named.push(handle.as_deref().unwrap_or(did).to_string()); | ||
} | ||
writeln!(out, "assignees: {}", named.join(", "))?; | ||
} | ||
if !i.labels.is_empty() { | ||
writeln!(out, "labels: {}", i.labels.join(", "))?; | ||
} | ||
| @@ -1919,6 +1935,25 @@ async fn command( | ||
forge::delete_comment(ctx, &url, args.next("<at-uri>")?).await?; | ||
writeln!(out, "deleted")?; | ||
} | ||
"assign" | "unassign" | "ask-review" | "unask-review" => { | ||
let url = args.url()?; | ||
let subject = args.next("<at-uri>")?; | ||
let names: Vec<&str> = args.rest.by_ref().map(String::as_str).collect(); | ||
let role = match cmd.ends_with("review") { | ||
true => records::Role::Reviewer, | ||
false => records::Role::Assignee, | ||
}; | ||
let removed = cmd.starts_with("un"); | ||
let item = forge::assign(ctx, &url, subject, role, &names, removed).await?; | ||
let now = match role { | ||
records::Role::Assignee => &item.assignees, | ||
records::Role::Reviewer => &item.reviewers, | ||
}; | ||
for did in now { | ||
let handle = crate::identity::handle(ctx, did).await; | ||
writeln!(out, "{}", handle.as_deref().unwrap_or(did))?; | ||
} | ||
} | ||
"label" | "unlabel" => { | ||
let url = args.url()?; | ||
let subject = args.next("<at-uri>")?; | ||
crates/knotless/src/forge.rs+246−4
| @@ -30,9 +30,9 @@ use crate::picture; | ||
use crate::push::{self, Update}; | ||
use crate::read; | ||
use crate::records::{ | ||
self, Attachment, BlobRef, BlockRecord, CommentRecord, Facet, FailedCheck, HideRecord, | ||
ImageAttachment, IssueRecord, LabelRecord, LineRef, PullHead, PullRecord, QueueRecord, | ||
RepoRecord, RepoRef, Reviewer, StateRecord, nsid, | ||
self, AssignmentRecord, Attachment, BlobRef, BlockRecord, CommentRecord, Facet, FailedCheck, | ||
HideRecord, ImageAttachment, IssueRecord, LabelRecord, LineRef, PullHead, PullRecord, | ||
QueueRecord, RepoRecord, RepoRef, Reviewer, Role, StateRecord, nsid, | ||
}; | ||
use crate::remote::{self, Remote, Snapshot, Stored}; | ||
use crate::reviews::{self, Review}; | ||
| @@ -163,9 +163,15 @@ pub struct Item { | ||
/// A pull not ready to merge or queue yet, as its author marks it. | ||
#[serde(default, skip_serializing_if = "std::ops::Not::not")] | ||
pub draft: bool, | ||
/// Those a pull's author asks to review it, by DID. | ||
/// Those asked to review a pull, by DID: first those its author asks, | ||
/// then those its repository's owner or collaborators ask | ||
/// ([`assigned`]). | ||
#[serde(default, skip_serializing_if = "Vec::is_empty")] | ||
pub reviewers: Vec<String>, | ||
/// Those its repository's owner or collaborators assign it to, by | ||
/// DID, in the order assigned ([`assigned`]). | ||
#[serde(default, skip_serializing_if = "Vec::is_empty")] | ||
pub assignees: Vec<String>, | ||
/// The accounts its body mentions, by where. | ||
#[serde(default, skip_serializing_if = "Vec::is_empty")] | ||
pub facets: Vec<Facet>, | ||
| @@ -444,6 +450,7 @@ pub fn assemble(repo: &RepoRecord, owner: &str, entries: &[Entry]) -> Vec<Item> | ||
images, | ||
draft, | ||
reviewers, | ||
assignees: Vec::new(), | ||
facets, | ||
}); | ||
} | ||
| @@ -451,6 +458,7 @@ pub fn assemble(repo: &RepoRecord, owner: &str, entries: &[Entry]) -> Vec<Item> | ||
let states: Vec<(&str, &Entry, StateRecord)> = in_order(entries, nsid::STATE); | ||
let queues: Vec<(&str, &Entry, QueueRecord)> = in_order(entries, nsid::QUEUE); | ||
let labels: Vec<(&str, &Entry, LabelRecord)> = in_order(entries, nsid::LABEL); | ||
let assignments: Vec<(&str, &Entry, AssignmentRecord)> = in_order(entries, nsid::ASSIGNMENT); | ||
// Comments by what they are on, in the order they were made. | ||
let comments: Vec<(&str, &Entry, CommentRecord)> = in_order(entries, nsid::COMMENT); | ||
let mut said: HashMap<&str, Vec<(&Entry, &CommentRecord)>> = HashMap::new(); | ||
| @@ -460,6 +468,15 @@ pub fn assemble(repo: &RepoRecord, owner: &str, entries: &[Entry]) -> Vec<Item> | ||
let now = keys::now_micros(); | ||
for item in &mut items { | ||
item.labels = labelled(repo, owner, &labels, &item.uri, now); | ||
item.assignees = assigned(repo, owner, &assignments, &item.uri, Role::Assignee, now); | ||
if item.kind == "pull" { | ||
let asked = assigned(repo, owner, &assignments, &item.uri, Role::Reviewer, now); | ||
for did in asked { | ||
if did != item.author && !item.reviewers.contains(&did) { | ||
item.reviewers.push(did); | ||
} | ||
} | ||
} | ||
for (at, e, s) in states.iter().filter(|(_, _, s)| s.subject == item.uri) { | ||
let state = s.state.as_deref().unwrap_or_default(); | ||
if allowed(repo, owner, &item.author, &e.author, state, at) { | ||
| @@ -583,6 +600,50 @@ fn labelled( | ||
out | ||
} | ||
| ||
/// Those `records` give `role` on `subject`, in the order they were given | ||
/// it: of each account's entries, from the repository's owner and | ||
/// collaborators alone, the latest, by `setAt` and then the DID of who | ||
/// said it, says whether it stands. An entry set more than | ||
/// [`LABEL_SKEW`] past `now` waits, as a label's does. | ||
fn assigned( | ||
repo: &RepoRecord, | ||
owner: &str, | ||
records: &[(&str, &Entry, AssignmentRecord)], | ||
subject: &str, | ||
role: Role, | ||
now: u64, | ||
) -> Vec<String> { | ||
let mut latest: BTreeMap<&str, ((u64, &str), bool)> = BTreeMap::new(); | ||
for (_, e, record) in records.iter().filter(|(_, _, r)| r.subject == subject) { | ||
if e.author != owner && !repo.is_collaborator(&e.author) { | ||
continue; | ||
} | ||
for entry in record.entries(role) { | ||
let at = micros(&entry.set_at).filter(|at| *at <= now + LABEL_SKEW); | ||
let Some(at) = at else { | ||
continue; | ||
}; | ||
let said = ((at, e.author.as_str()), !entry.removed); | ||
if latest | ||
.get(entry.did.as_str()) | ||
.is_none_or(|(when, _)| said.0 > *when) | ||
{ | ||
latest.insert(entry.did.as_str(), said); | ||
} | ||
} | ||
} | ||
let mut standing: Vec<(&(u64, &str), &str)> = latest | ||
.iter() | ||
.filter(|(_, (_, on))| *on) | ||
.map(|(did, (when, _))| (when, *did)) | ||
.collect(); | ||
standing.sort(); | ||
standing | ||
.into_iter() | ||
.map(|(_, did)| did.to_string()) | ||
.collect() | ||
} | ||
| ||
/// The records of `collection` among `entries` that read as `T`, with | ||
/// their keys, in the order they were made: their keys are TIDs. | ||
fn in_order<'a, T: serde::de::DeserializeOwned>( | ||
| @@ -1082,6 +1143,48 @@ impl Target { | ||
}) | ||
} | ||
| ||
/// Rewrites `me`'s assignment record about `subject` as `change` | ||
/// leaves it, making it if there is none. Returns it as written. | ||
pub(crate) async fn reassign( | ||
&self, | ||
mine: &Remote, | ||
me: &Session, | ||
subject: &str, | ||
change: impl FnOnce(&mut AssignmentRecord), | ||
) -> Result<Entry> { | ||
self.writable()?; | ||
let rkey = keys::assignment_rkey(&self.reference().key, subject); | ||
let stored = mine | ||
.get_record::<AssignmentRecord>(nsid::ASSIGNMENT, &rkey) | ||
.await?; | ||
let write: fn(&str, &str, Value) -> Value = match stored { | ||
Some(_) => remote::update, | ||
None => remote::create, | ||
}; | ||
let mut record = stored.map_or_else( | ||
|| AssignmentRecord { | ||
repo: self.reference(), | ||
subject: subject.to_string(), | ||
assignees: Vec::new(), | ||
reviewers: Vec::new(), | ||
created_at: records::now(), | ||
}, | ||
|s| s.value, | ||
); | ||
change(&mut record); | ||
let value = records::typed(nsid::ASSIGNMENT, &record); | ||
mine.apply( | ||
vec![write(nsid::ASSIGNMENT, &rkey, value.clone())], | ||
&me.auth(), | ||
) | ||
.await?; | ||
Ok(Entry { | ||
uri: mine.uri_of(nsid::ASSIGNMENT, &rkey), | ||
author: me.did.clone(), | ||
value, | ||
}) | ||
} | ||
| ||
/// The entries, with `subject` as its author's repo has it now, not as | ||
/// the index last saw it: the index may not have it yet. | ||
pub(crate) async fn around(&self, ctx: &Ctx, subject: &str) -> Result<(Vec<Entry>, Value)> { | ||
| @@ -1930,6 +2033,90 @@ pub async fn label( | ||
.labels) | ||
} | ||
| ||
/// Assigns a pull or issue to the accounts `names`, by handle or DID, or | ||
/// with [`Role::Reviewer`] asks them to review a pull; with `removed`, | ||
/// no longer. The repository's owner and collaborators do it in their | ||
/// own assignment record for it, rewritten. A pull's author asks for | ||
/// reviews in the pull itself, as [`edit`] does, and stops asking only | ||
/// those it asked: one a maintainer asked stays asked until a maintainer | ||
/// stops. Answers the item as it is now. | ||
pub async fn assign( | ||
ctx: &Ctx, | ||
target: &RepoUrl, | ||
subject: &str, | ||
role: Role, | ||
names: &[&str], | ||
removed: bool, | ||
) -> Result<Item> { | ||
if names.is_empty() { | ||
bail!("name an account, by handle or DID"); | ||
} | ||
let t = Target::open(ctx, target).await?; | ||
let subject = t.named(subject); | ||
let subject = subject.as_str(); | ||
let (author, collection, _) = parse_uri(subject)?; | ||
match (role, collection) { | ||
(Role::Reviewer, nsid::PULL) | (Role::Assignee, nsid::PULL | nsid::ISSUE) => {} | ||
(Role::Reviewer, _) => bail!("{subject} is not a pull: only a pull asks for reviews"), | ||
_ => bail!("{subject} is not a pull or an issue"), | ||
} | ||
let mut dids = Vec::new(); | ||
for name in names { | ||
let did = did_of(ctx, name).await?; | ||
if role == Role::Reviewer && did == author && !removed { | ||
bail!("{name} opened the pull, and reviews no pull of their own"); | ||
} | ||
dids.push(did); | ||
} | ||
let (mine, me) = t.mine(ctx).await?; | ||
let owner = t.remote.did(); | ||
let maintainer = me.did == owner || t.record.is_collaborator(&me.did); | ||
let by_author = role == Role::Reviewer && me.did == author; | ||
if !maintainer && !by_author { | ||
bail!( | ||
"{}: only the owner and collaborators assign pulls and issues, and ask for reviews of a pull besides its author", | ||
t.url | ||
); | ||
} | ||
crate::org::needs_public(ctx, t.facts(), &me, "assigning").await?; | ||
if by_author { | ||
let (add, remove) = match removed { | ||
false => (dids.clone(), Vec::new()), | ||
true => (Vec::new(), dids.clone()), | ||
}; | ||
let asks = PullAsks { | ||
draft: None, | ||
add, | ||
remove, | ||
}; | ||
edit(ctx, target, subject, None, None, &[], &asks).await?; | ||
} | ||
let (entries, _) = t.around(ctx, subject).await?; | ||
let mut fresh = Vec::new(); | ||
// A maintainer who opened the pull asks in it; stopping, they stop | ||
// their fellow maintainers' asks too. | ||
if maintainer && (!by_author || removed) { | ||
let at = records::now(); | ||
let written = t.reassign(&mine, &me, subject, |record| { | ||
for did in &dids { | ||
record.say(role, did, &at, removed); | ||
} | ||
}); | ||
fresh.push(written.await?); | ||
} | ||
let entries = freshened(entries, fresh); | ||
let item = thread(&t.record, owner, &entries, subject)?.item; | ||
let still = dids.iter().find(|d| item.reviewers.contains(d)); | ||
if let (true, Role::Reviewer, Some(did)) = (removed, role, still) { | ||
let asker = match by_author { | ||
true => "the owner or a collaborator", | ||
false => "the pull's author", | ||
}; | ||
bail!("{did} was asked by {asker}, who can stop asking"); | ||
} | ||
Ok(item) | ||
} | ||
| ||
/// Closes or reopens a pull or issue; or, for a comment that starts a | ||
/// thread on a pull's lines, resolves it or opens it again ([`resolve`]). | ||
pub async fn set_state(ctx: &Ctx, target: &RepoUrl, subject: &str, state: &str) -> Result<String> { | ||
| @@ -3363,6 +3550,61 @@ mod tests { | ||
assert_eq!(repo_url(repo).unwrap().name, "r"); | ||
} | ||
| ||
#[test] | ||
fn assigned_by_the_owner_and_collaborators() { | ||
let repo = RepoRecord { | ||
collaborators: vec![records::Collaborator { | ||
did: "did:c".into(), | ||
}], | ||
..Default::default() | ||
}; | ||
let p3 = "at://did:a/dev.knotless.pull/3"; | ||
let pull = json!({"repo": r(), "title": "fix", "base": "main", | ||
"head": {"repo": r(), "branch": "b"}, "createdAt": "2026-01-03", | ||
"reviewers": [{"did": "did:r"}]}); | ||
let said = |author: &str, rkey: &str, assignees: Value, reviewers: Value| { | ||
let value = json!({"repo": r(), "subject": p3, "assignees": assignees, | ||
"reviewers": reviewers, "createdAt": "t"}); | ||
entry(author, nsid::ASSIGNMENT, rkey, value) | ||
}; | ||
let at = |day: u8| format!("2026-01-0{day}T00:00:00Z"); | ||
let entries = vec![ | ||
entry("did:a", nsid::PULL, "3", pull), | ||
said( | ||
"did:o", | ||
"a1", | ||
json!([{"did": "did:c", "setAt": at(1)}, {"did": "did:o", "setAt": at(2)}]), | ||
json!([{"did": "did:c", "setAt": at(1)}, {"did": "did:r", "setAt": at(1)}, | ||
{"did": "did:a", "setAt": at(1)}]), | ||
), | ||
// A collaborator's later word stands over the owner's; a | ||
// stranger's counts for nothing, and one far ahead waits. | ||
said( | ||
"did:c", | ||
"a2", | ||
json!([{"did": "did:o", "setAt": at(3), "removed": true}]), | ||
json!([]), | ||
), | ||
said( | ||
"did:x", | ||
"a3", | ||
json!([{"did": "did:x", "setAt": at(4)}]), | ||
json!([]), | ||
), | ||
said( | ||
"did:o", | ||
"a4", | ||
json!([{"did": "did:z", "setAt": "2999-01-01T00:00:00Z"}]), | ||
json!([]), | ||
), | ||
]; | ||
let items = assemble(&repo, "did:o", &entries); | ||
assert_eq!(items[0].assignees, ["did:c"]); | ||
// The author's asks first, then the maintainers', each once, and | ||
// never the author. | ||
assert_eq!(items[0].reviewers, ["did:r", "did:c"]); | ||
} | ||
| ||
#[test] | ||
fn labels_from_those_allowed_to_put_them_on() { | ||
let exclusive = |name: &str| records::Label { | ||
crates/knotless/src/keys.rs+6−0
| @@ -66,6 +66,12 @@ pub fn label_rkey(key: &str, subject: &str) -> String { | ||
format!("{key}.{}", ref_hash(&crate::aturi::short(subject))) | ||
} | ||
| ||
/// The assignment record an account keeps for `subject`, on the | ||
/// repository `key`: keyed as its label record is. | ||
pub fn assignment_rkey(key: &str, subject: &str) -> String { | ||
label_rkey(key, subject) | ||
} | ||
| ||
/// The generation a new ref starts at: the time in milliseconds, past any | ||
/// generation an earlier ref of the same name reached, as one moves far | ||
/// less often than once a millisecond. Its claims, which compaction keeps a | ||
crates/knotless/src/query.rs+30−6
| @@ -27,6 +27,11 @@ pub struct Query { | ||
/// DID or `@me`. It asks each, once [`Query::resolve`] makes them | ||
/// DIDs. | ||
pub reviewers: Vec<String>, | ||
/// Those it is assigned to: `assignee:`, by handle, DID or `@me`, made | ||
/// DIDs as reviewers are. | ||
pub assignees: Vec<String>, | ||
/// `no:assignee`: it is assigned to no one. | ||
pub unassigned: bool, | ||
/// Words its title or body must hold, ignoring case: anything else. | ||
pub words: Vec<String>, | ||
} | ||
| @@ -56,17 +61,26 @@ impl Query { | ||
(false, "review-requested", _) => { | ||
q.reviewers.push(value.trim_start_matches('@').into()) | ||
} | ||
(false, "assignee", ME) => q.assignees.push(value), | ||
(false, "assignee", _) => q.assignees.push(value.trim_start_matches('@').into()), | ||
(false, "no", "assignee") => q.unassigned = true, | ||
_ => q.words.push(raw.to_lowercase()), | ||
} | ||
} | ||
q | ||
} | ||
| ||
/// The query with each reviewer it names as a DID: `@me` as `me`, the | ||
/// account asking, and a handle as the DID it resolves to. One that | ||
/// names no one, or `@me` with no one asking, matches nothing. | ||
/// Whether it names accounts, which [`Query::resolve`] makes DIDs. | ||
pub fn names(&self) -> bool { | ||
!self.reviewers.is_empty() || !self.assignees.is_empty() | ||
} | ||
| ||
/// The query with each reviewer and assignee it names as a DID: `@me` | ||
/// as `me`, the account asking, and a handle as the DID it resolves | ||
/// to. One that names no one, or `@me` with no one asking, matches | ||
/// nothing. | ||
pub async fn resolve(mut self, ctx: &Ctx, me: Option<&str>) -> Query { | ||
for name in &mut self.reviewers { | ||
for name in self.reviewers.iter_mut().chain(&mut self.assignees) { | ||
let did = match (name.as_str(), me) { | ||
(ME, Some(me)) => Some(me.to_string()), | ||
(ME, None) => None, | ||
| @@ -110,11 +124,13 @@ impl Query { | ||
&& (self.authors.is_empty() || self.authors.iter().any(by)) | ||
&& self.draft.is_none_or(|d| d == item.draft) | ||
&& self.reviewers.iter().all(|r| item.reviewers.contains(r)) | ||
&& self.assignees.iter().all(|a| item.assignees.contains(a)) | ||
&& (!self.unassigned || item.assignees.is_empty()) | ||
&& self.words.iter().all(|w| text.contains(w.as_str())) | ||
} | ||
} | ||
| ||
/// What `review-requested:` names for the account asking. | ||
/// What `review-requested:` and `assignee:` name for the account asking. | ||
const ME: &str = "@me"; | ||
| ||
/// `text` split on spaces, except within double quotes, which are dropped. | ||
| @@ -152,7 +168,7 @@ mod tests { | ||
#[test] | ||
fn parses_what_github_does() { | ||
let q = Query::parse( | ||
r#"state:closed is:issue label:kind/bug label:"good first issue" -label:area/web no:label author:@alice.test sort:new "two words" -crash -author:bob -is:draft review-requested:@me review-requested:@bob.test"#, | ||
r#"state:closed is:issue label:kind/bug label:"good first issue" -label:area/web no:label author:@alice.test sort:new "two words" -crash -author:bob -is:draft review-requested:@me review-requested:@bob.test assignee:@me no:assignee"#, | ||
); | ||
let want = Query { | ||
state: Some("closed".into()), | ||
| @@ -163,6 +179,8 @@ mod tests { | ||
authors: vec!["alice.test".into()], | ||
draft: Some(false), | ||
reviewers: vec!["@me".into(), "bob.test".into()], | ||
assignees: vec!["@me".into()], | ||
unassigned: true, | ||
words: vec![ | ||
"sort:new".into(), | ||
"two words".into(), | ||
| @@ -203,6 +221,12 @@ mod tests { | ||
assert!(m("-is:draft", &bug) && !m("-is:draft", &draft)); | ||
assert!(m("review-requested:did:plc:bob", &draft)); | ||
assert!(!m("review-requested:did:plc:bob", &bug)); | ||
let taken = Item { | ||
assignees: vec!["did:plc:carol".into()], | ||
..bug.clone() | ||
}; | ||
assert!(m("assignee:did:plc:carol", &taken) && !m("assignee:did:plc:carol", &bug)); | ||
assert!(m("no:assignee", &bug) && !m("no:assignee", &taken)); | ||
let state = |text: &str, i: &Item| Query::parse(text).in_state(i); | ||
assert!(state("", &bug) && state("state:open", &bug)); | ||
assert!(state("state:closed", &merged) && !state("state:closed", &bug)); | ||
crates/knotless/src/records.rs+69−1
| @@ -27,10 +27,15 @@ pub mod nsid { | ||
pub const QUEUE: &str = "dev.knotless.queue"; | ||
/// The labels one account puts on a pull or issue. | ||
pub const LABEL: &str = "dev.knotless.label"; | ||
/// Whom one of a repository's maintainers assigns a pull or issue to, | ||
/// and asks to review a pull. | ||
pub const ASSIGNMENT: &str = "dev.knotless.assignment"; | ||
/// What people write about a repository, in their own accounts, and | ||
/// what its owner hides of it and whom they keep out. A block names no | ||
/// repository when it keeps someone out of all of them. | ||
pub const FORGE: [&str; 8] = [PULL, ISSUE, COMMENT, STATE, HIDE, BLOCK, QUEUE, LABEL]; | ||
pub const FORGE: [&str; 9] = [ | ||
PULL, ISSUE, COMMENT, STATE, HIDE, BLOCK, QUEUE, LABEL, ASSIGNMENT, | ||
]; | ||
/// The email addresses an account's commits are authored as. | ||
pub const EMAILS: &str = "dev.knotless.emails"; | ||
/// How an account shows on knotless, over its Bluesky profile. | ||
| @@ -1245,6 +1250,69 @@ impl LabelRecord { | ||
} | ||
} | ||
| ||
/// Whom one of a repository's maintainers assigns a pull or issue to, and | ||
/// asks to review a pull: one record per account and subject, rewritten | ||
/// as they change, as [`LabelRecord`] is. | ||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] | ||
#[serde(rename_all = "camelCase")] | ||
pub struct AssignmentRecord { | ||
pub repo: RepoRef, | ||
/// The pull or issue, by `at://` URI. | ||
pub subject: String, | ||
/// The latest this account said of each account it assigned. | ||
#[serde(default, skip_serializing_if = "Vec::is_empty")] | ||
pub assignees: Vec<AssignEntry>, | ||
/// The latest this account said of each account it asked to review | ||
/// a pull. | ||
#[serde(default, skip_serializing_if = "Vec::is_empty")] | ||
pub reviewers: Vec<AssignEntry>, | ||
pub created_at: String, | ||
} | ||
| ||
/// What an [`AssignmentRecord`] says of someone: assigned, or asked to | ||
/// review. | ||
#[derive(Clone, Copy, Debug, PartialEq, Eq)] | ||
pub enum Role { | ||
Assignee, | ||
Reviewer, | ||
} | ||
| ||
impl AssignmentRecord { | ||
/// Its entries for `role`. | ||
pub fn entries(&self, role: Role) -> &[AssignEntry] { | ||
match role { | ||
Role::Assignee => &self.assignees, | ||
Role::Reviewer => &self.reviewers, | ||
} | ||
} | ||
| ||
/// Says `did` given `role`, or with `removed` no longer, at `at`, in | ||
/// place of what it said of them before. | ||
pub fn say(&mut self, role: Role, did: &str, at: &str, removed: bool) { | ||
let entries = match role { | ||
Role::Assignee => &mut self.assignees, | ||
Role::Reviewer => &mut self.reviewers, | ||
}; | ||
entries.retain(|e| e.did != did); | ||
entries.push(AssignEntry { | ||
did: did.to_string(), | ||
set_at: at.to_string(), | ||
removed, | ||
}); | ||
} | ||
} | ||
| ||
/// An account assigned, or asked to review, or no longer, at a time. | ||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] | ||
#[serde(rename_all = "camelCase")] | ||
pub struct AssignEntry { | ||
pub did: String, | ||
pub set_at: String, | ||
/// No longer, rather than so. | ||
#[serde(default, skip_serializing_if = "std::ops::Not::not")] | ||
pub removed: bool, | ||
} | ||
| ||
/// What a triager judged in choosing its labels. | ||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Default)] | ||
pub struct Judgment { | ||
crates/knotless-mcp/src/lib.rs+51−6
| @@ -1020,8 +1020,9 @@ pub struct Items { | ||
/// A filter, as GitHub's search reads one: `label:<name>`, | ||
/// `-label:<name>`, `no:label`, `author:<handle or did>`, | ||
/// `state:open|closed|merged`, `is:pull|issue`, `is:draft`, | ||
/// `-is:draft`, `review-requested:<handle, did or @me>`, and words the | ||
/// title or body holds. Double quotes keep a value together. | ||
/// `-is:draft`, `review-requested:<handle, did or @me>`, | ||
/// `assignee:<handle, did or @me>`, `no:assignee`, and words the title | ||
/// or body holds. Double quotes keep a value together. | ||
pub query: Option<String>, | ||
} | ||
| ||
| @@ -1202,6 +1203,21 @@ pub struct LabelItem { | ||
pub remove: bool, | ||
} | ||
| ||
#[derive(Deserialize, schemars::JsonSchema)] | ||
pub struct AssignItem { | ||
pub repo: String, | ||
/// The pull's or issue's at:// URI. | ||
pub uri: String, | ||
/// The accounts, by handle or DID. | ||
pub accounts: Vec<String>, | ||
/// Ask them to review the pull, rather than assign it to them. | ||
#[serde(default)] | ||
pub review: bool, | ||
/// Take them off instead, or stop asking. | ||
#[serde(default)] | ||
pub remove: bool, | ||
} | ||
| ||
#[derive(Deserialize, schemars::JsonSchema)] | ||
pub struct SetState { | ||
pub repo: String, | ||
| @@ -1891,7 +1907,7 @@ impl Server { | ||
} | ||
| ||
#[tool( | ||
description = "Pull requests and issues on a repository, newest first, with their state, labels and comment counts, and for a pull, draft: true or false. `query` filters them as GitHub's search does: `label:kind/bug -label:area/web author:alice.test crash`." | ||
description = "Pull requests and issues on a repository, newest first, with their state, labels and comment counts, and for a pull, draft: true or false. `query` filters them as GitHub's search does: `label:kind/bug -label:area/web author:alice.test assignee:@me crash`." | ||
)] | ||
async fn list_items( | ||
&self, | ||
| @@ -1926,7 +1942,7 @@ impl Server { | ||
} | ||
| ||
#[tool( | ||
description = "A pull request or issue with its comments and reviews, oldest first. Each comment has its author's DID and handle, and `facets`, the accounts its body mentions, by where. A comment on a pull's lines has `line` ({path, side, line, startLine}), the `commit` they are in, and the `review` it was sent with: a review's own comment, the one with its verdict or body, names itself there. A comment in a thread on a pull's lines, the first or an answer, has `resolved`, true once the thread is (other comments have none); an answer has `reply`, the comment starting it. A review that its reviewer's later one stands in place of has `superseded`. A pull's `item.reviewers` are those its author asks to review it ({did, handle, pending}: pending while the pull is open and they have not reviewed it, false once it is merged or closed), and `item.reviews` where each reviewer stands: by (DID), handle, verdict, the commit it looked at, uri, `outdated` once the open pull has moved past that commit, `maintainer` for the owner or a collaborator, and `uncounted`, why an approval does not count toward what its target branch's rule asks (only the owner's and collaborators' count; with dismiss_stale, only those of its head as it is now). For a pull, `compare` says what it brings in: the base and head commits, the commits the base lacks, and whether it merges (merged, fast-forward, clean or conflict, with the conflicting paths); for a merged one, what its merge brought in, from where the base was before it to the merge, though its branch is gone. A merged pull that a merged revert undid names it as `item.revertedBy`, and the revert names the pull as `item.reverts`. An open pull in its branch's merge queue, or ejected from it, has `standing`: its place, the batch it is in while that is checked or landing, and `said`, where it stands in words." | ||
description = "A pull request or issue with its comments and reviews, oldest first. Each comment has its author's DID and handle, and `facets`, the accounts its body mentions, by where. A comment on a pull's lines has `line` ({path, side, line, startLine}), the `commit` they are in, and the `review` it was sent with: a review's own comment, the one with its verdict or body, names itself there. A comment in a thread on a pull's lines, the first or an answer, has `resolved`, true once the thread is (other comments have none); an answer has `reply`, the comment starting it. A review that its reviewer's later one stands in place of has `superseded`. `item.assignees` are those the owner or a collaborator assigns it to ({did, handle}). A pull's `item.reviewers` are those asked to review it, by its author or by the owner or a collaborator ({did, handle, pending}: pending while the pull is open and they have not reviewed it, false once it is merged or closed), and `item.reviews` where each reviewer stands: by (DID), handle, verdict, the commit it looked at, uri, `outdated` once the open pull has moved past that commit, `maintainer` for the owner or a collaborator, and `uncounted`, why an approval does not count toward what its target branch's rule asks (only the owner's and collaborators' count; with dismiss_stale, only those of its head as it is now). For a pull, `compare` says what it brings in: the base and head commits, the commits the base lacks, and whether it merges (merged, fast-forward, clean or conflict, with the conflicting paths); for a merged one, what its merge brought in, from where the base was before it to the merge, though its branch is gone. A merged pull that a merged revert undid names it as `item.revertedBy`, and the revert names the pull as `item.reverts`. An open pull in its branch's merge queue, or ejected from it, has `standing`: its place, the batch it is in while that is checked or landing, and `said`, where it stands in words." | ||
)] | ||
async fn show_item( | ||
&self, | ||
| @@ -1939,7 +1955,9 @@ impl Server { | ||
// Those asked to review it and those who did, by handle too. | ||
let reviewers = thread.item.reviews.iter().map(|r| r.by.as_str()); | ||
let asked = thread.item.reviewers.iter().map(String::as_str); | ||
let named = knotless::identity::handles(&self.ctx, asked.chain(reviewers)).await; | ||
let assigned = thread.item.assignees.iter().map(String::as_str); | ||
let all = asked.chain(reviewers).chain(assigned); | ||
let named = knotless::identity::handles(&self.ctx, all).await; | ||
thread.handles.extend(named); | ||
let handle = |did: &str| thread.handles.get(did).cloned(); | ||
// A thread's answers stand as it does; a review's own comment | ||
| @@ -2035,6 +2053,11 @@ impl Server { | ||
); | ||
item["reviewers"] = asked.collect(); | ||
} | ||
if !thread.item.assignees.is_empty() { | ||
let them = thread.item.assignees.iter(); | ||
let them = them.map(|did| json!({"did": did, "handle": handle(did)})); | ||
item["assignees"] = them.collect(); | ||
} | ||
let mut out = json!({"item": item, "comments": comments, "reactions": reactions}); | ||
if !thread.mentioned_by.is_empty() { | ||
out["mentionedBy"] = serde_json::to_value(&thread.mentioned_by)?; | ||
| @@ -2264,6 +2287,28 @@ impl Server { | ||
) | ||
} | ||
| ||
#[tool( | ||
description = "Assign a pull request or issue to accounts, or with review, ask them to review a pull; with remove, take them off it, or stop asking. The owner and collaborators assign, and ask for reviews besides a pull's author, who asks in the pull itself. A pull's author stops asking only those it asked, and the owner and collaborators those they asked. Answers whom it is assigned to now and, for a pull, who is asked to review it, by DID." | ||
)] | ||
async fn assign_item( | ||
&self, | ||
Parameters(p): Parameters<AssignItem>, | ||
) -> Result<CallToolResult, McpError> { | ||
answer( | ||
async { | ||
let names: Vec<&str> = p.accounts.iter().map(String::as_str).collect(); | ||
let url = repo_url(&p.repo)?; | ||
let role = match p.review { | ||
true => knotless::records::Role::Reviewer, | ||
false => knotless::records::Role::Assignee, | ||
}; | ||
let item = forge::assign(&self.ctx, &url, &p.uri, role, &names, p.remove).await?; | ||
Ok(json!({"assignees": item.assignees, "reviewers": item.reviewers})) | ||
} | ||
.await, | ||
) | ||
} | ||
| ||
#[tool( | ||
description = "Close or reopen a pull request or issue: its author, the owner and collaborators may. Given a comment that starts a thread on a pull's lines, resolve it, or open it again: the thread's author, the pull's, the owner and collaborators may." | ||
)] | ||
| @@ -3322,7 +3367,7 @@ impl Server { | ||
#[tool_handler( | ||
router = self.router(), | ||
name = "knotless", | ||
instructions = "Git repositories stored in atproto accounts. Name a repository as handle/name. Before changing a repository, read its AGENTS.md (or CLAUDE.md) with read_file if it has one: how that repository wants changes made and checked. Read with list_tree, read_file, search, blame, log and diff: no clone needed. Change files with commit: no checkout needed. Collaborate with fork, template, open_pull, open_issue, list_items, show_item, comment, set_state, label_item (see list_labels and define_label) and merge (with queue, into the merge queue: see merge_queue and leave_queue), and undo a merged pull with revert; change what you wrote with edit_item, and take a comment back with delete_comment. Workflows are YAML files under .knotless/workflows/, run by the runners a repository names (see runners) on the commit a push or a pull brings: `on: {push: {refs: [refs/heads/*]}, pull: {base: [refs/heads/main], paths: [web/**]}}` (a file with no `on` runs on every push to a branch; `paths`, globs, runs it only on a change touching one, and passes it as skipped otherwise), `image` (the one container all the steps run in, in turn, so a step sees what earlier ones installed), `env`, `secrets` (names; never given to a pull), and `steps: [{name, run, timeout}]`, each `run` a shell script, stopping at the first that fails. After a push, checks says whether they passed, workflows which a ref has and what runs each, and rerun asks for them again, or, as owner, for one of a pull's workflows by name, or one at a ref, with variables: one with `on: {asked: {refs: [refs/heads/main]}}` runs only so. Moderate your repositories with hide_item and block_account (see list_blocks), and report with report. React to pulls, issues and comments with react. Star repositories with star, and see an account's with list_stars; follow accounts with follow, and see whom an account follows, or who follows it, with list_follows. Make, choose and delete themes for knotless's pages with theme, and find others' with list_themes. See what others did that concerns you with notifications, choose which you hear of with notification_settings, and watch a repository, or subscribe to or mute a pull or an issue, with subscribe. Set how you show on knotless with set_profile, and which email addresses show commits as yours with set_email. Publish a version of a package from a repository with publish, see its versions with releases, and read a file of one with read_release. Make your account an organization, or run one you are an admin of, with org, once you have delegated to a gateway with delegate. An error naming a `knotless` command names a tool here: `knotless delegate` is delegate, `knotless org join` is org with join, and so on." | ||
instructions = "Git repositories stored in atproto accounts. Name a repository as handle/name. Before changing a repository, read its AGENTS.md (or CLAUDE.md) with read_file if it has one: how that repository wants changes made and checked. Read with list_tree, read_file, search, blame, log and diff: no clone needed. Change files with commit: no checkout needed. Collaborate with fork, template, open_pull, open_issue, list_items, show_item, comment, set_state, label_item (see list_labels and define_label), assign_item and merge (with queue, into the merge queue: see merge_queue and leave_queue), and undo a merged pull with revert; change what you wrote with edit_item, and take a comment back with delete_comment. Workflows are YAML files under .knotless/workflows/, run by the runners a repository names (see runners) on the commit a push or a pull brings: `on: {push: {refs: [refs/heads/*]}, pull: {base: [refs/heads/main], paths: [web/**]}}` (a file with no `on` runs on every push to a branch; `paths`, globs, runs it only on a change touching one, and passes it as skipped otherwise), `image` (the one container all the steps run in, in turn, so a step sees what earlier ones installed), `env`, `secrets` (names; never given to a pull), and `steps: [{name, run, timeout}]`, each `run` a shell script, stopping at the first that fails. After a push, checks says whether they passed, workflows which a ref has and what runs each, and rerun asks for them again, or, as owner, for one of a pull's workflows by name, or one at a ref, with variables: one with `on: {asked: {refs: [refs/heads/main]}}` runs only so. Moderate your repositories with hide_item and block_account (see list_blocks), and report with report. React to pulls, issues and comments with react. Star repositories with star, and see an account's with list_stars; follow accounts with follow, and see whom an account follows, or who follows it, with list_follows. Make, choose and delete themes for knotless's pages with theme, and find others' with list_themes. See what others did that concerns you with notifications, choose which you hear of with notification_settings, and watch a repository, or subscribe to or mute a pull or an issue, with subscribe. Set how you show on knotless with set_profile, and which email addresses show commits as yours with set_email. Publish a version of a package from a repository with publish, see its versions with releases, and read a file of one with read_release. Make your account an organization, or run one you are an admin of, with org, once you have delegated to a gateway with delegate. An error naming a `knotless` command names a tool here: `knotless delegate` is delegate, `knotless org join` is org with join, and so on." | ||
)] | ||
impl ServerHandler for Server { | ||
async fn call_tool( | ||
crates/knotless-mcp/tests/mcp.rs+18−0
| @@ -1412,6 +1412,24 @@ fn agents_fork_review_and_merge() { | ||
); | ||
let off = json!({"repo": repo, "uri": issue, "labels": ["kind/bug"], "remove": true}); | ||
assert_eq!(o.json("label_item", off)["labels"], json!(["kind/docs"])); | ||
// The owner assigns it, and asks for a review of a pull besides its | ||
// author; the shown item names them. | ||
let them = contrib.account.handle.clone(); | ||
let assigning = json!({"repo": repo, "uri": issue, "accounts": [them]}); | ||
let (text, err) = c.call("assign_item", assigning.clone()); | ||
assert!(err, "{text}"); | ||
let assigned = o.json("assign_item", assigning); | ||
assert_eq!(assigned["assignees"], json!([contrib.account.did])); | ||
let shown = o.json("show_item", json!({"repo": repo, "uri": issue})); | ||
assert_eq!(shown["item"]["assignees"][0]["handle"], them.as_str()); | ||
let me = owner.account.handle.clone(); | ||
let asking = json!({"repo": repo, "uri": clash, "accounts": [me], "review": true}); | ||
assert_eq!( | ||
o.json("assign_item", asking)["reviewers"], | ||
json!([owner.account.did]) | ||
); | ||
let off = json!({"repo": repo, "uri": issue, "accounts": [them], "remove": true}); | ||
assert_eq!(o.json("assign_item", off)["assignees"], json!([])); | ||
let gone = o.json( | ||
"define_label", | ||
json!({"repo": repo, "name": "kind/bug", "remove": true}), | ||
crates/knotless-serve/src/web/threads.rs+66−6
| @@ -1,20 +1,21 @@ | ||
//! Saying things on a pull or an issue from the pages, as the account | ||
//! logged in to them: `dev.knotless.createComment` (with a verdict, a | ||
//! review), `editItem`, `deleteComment`, `setItemState`, `labelItem`, | ||
//! `hideItem` and `createReport`. Each goes through the same code as the | ||
//! command line's `comment`, `edit`, `delete-comment`, `close` and `reopen`, | ||
//! `label` and `unlabel`, `hide` and `unhide`, and `report`, and indexes | ||
//! `assignItem`, `hideItem` and `createReport`. Each goes through the same | ||
//! code as the command line's `comment`, `edit`, `delete-comment`, `close` | ||
//! and `reopen`, `label` and `unlabel`, `assign`, `unassign`, `ask-review` | ||
//! and `unask-review`, `hide` and `unhide`, and `report`, and indexes | ||
//! what it wrote at once, so the page after shows it. | ||
| ||
use super::opening::Asked; | ||
use super::repo::found; | ||
use super::repo::{found, invalid}; | ||
use super::stars::logged_in; | ||
use super::*; | ||
use crate::viewer::Viewer; | ||
use axum::Extension; | ||
use knotless::env::Ctx; | ||
use knotless::moderation; | ||
use knotless::records::{ImageAttachment, LineRef}; | ||
use knotless::records::{ImageAttachment, LineRef, Role}; | ||
use knotless::remote::Stored; | ||
use serde_json::Value; | ||
| ||
| @@ -26,6 +27,7 @@ pub fn routes() -> Router<Arc<App>> { | ||
.route("/xrpc/dev.knotless.deleteComment", post(delete_comment)) | ||
.route("/xrpc/dev.knotless.setItemState", post(set_item_state)) | ||
.route("/xrpc/dev.knotless.labelItem", post(label_item)) | ||
.route("/xrpc/dev.knotless.assignItem", post(assign_item)) | ||
.route("/xrpc/dev.knotless.hideItem", post(hide_item)) | ||
.route("/xrpc/dev.knotless.createReport", post(create_report)) | ||
.route( | ||
| @@ -48,6 +50,10 @@ pub struct ThreadViewer { | ||
revert: bool, | ||
#[serde(skip_serializing_if = "Vec::is_empty")] | ||
labels: Vec<LabelView>, | ||
/// Whether it assigns the item, and asks for reviews of a pull, as | ||
/// the owner or a collaborator. | ||
#[serde(skip_serializing_if = "std::ops::Not::not")] | ||
assign: bool, | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
subscription: Option<String>, | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
| @@ -83,7 +89,8 @@ pub(super) fn heard( | ||
| ||
/// What `did` may do with `item`, a pull or an issue on the repository | ||
/// `record` of `owner`, as the knotless crate decides when it writes: who | ||
/// may close it, hide it, revert it once merged, and put which labels on. | ||
/// may close it, hide it, revert it once merged, put which labels on, and | ||
/// assign it. | ||
pub(super) fn viewer( | ||
record: &RepoRecord, | ||
owner: &str, | ||
| @@ -99,6 +106,7 @@ pub(super) fn viewer( | ||
hide: did == owner, | ||
revert: maintainer && merged, | ||
labels: labels.map(label_view).collect(), | ||
assign: maintainer, | ||
subscription: None, | ||
notified_for: None, | ||
} | ||
| @@ -405,6 +413,58 @@ pub(super) async fn labelled( | ||
Ok(now) | ||
} | ||
| ||
#[derive(Deserialize)] | ||
pub struct AssignInput { | ||
repo: String, | ||
subject: String, | ||
did: String, | ||
/// `assignee`, or `reviewer` to ask for a review of a pull. | ||
#[serde(default)] | ||
role: Option<String>, | ||
#[serde(default)] | ||
removed: bool, | ||
} | ||
| ||
/// `dev.knotless.assignItem`'s answer. | ||
#[derive(Serialize)] | ||
pub struct Assigned { | ||
assignees: Vec<ActorView>, | ||
reviewers: Vec<ActorView>, | ||
} | ||
| ||
/// `dev.knotless.assignItem`. | ||
pub async fn assign_item( | ||
State(app): State<Arc<App>>, | ||
Extension(viewer): Extension<Viewer>, | ||
headers: HeaderMap, | ||
axum::Json(input): axum::Json<AssignInput>, | ||
) -> Result<Response> { | ||
let role = match input.role.as_deref() { | ||
None | Some("assignee") => Role::Assignee, | ||
Some("reviewer") => Role::Reviewer, | ||
Some(other) => return Err(invalid(format!("{other} is no role to give"))), | ||
}; | ||
let (ctx, repo, url) = acting(&app, viewer, &headers, "assign", &input.repo).await?; | ||
let (subject, names) = (&input.subject, [input.did.as_str()]); | ||
let assigning = forge::assign(&ctx, &url, subject, role, &names, input.removed); | ||
let item = assigning.await.map_err(refused)?; | ||
// What it wrote: its own assignment record, or as a pull's author, | ||
// the pull. | ||
let did = ctx.env.identifier.clone().unwrap_or_default(); | ||
let rkey = knotless::keys::assignment_rkey(&repo.key, subject); | ||
let record = format!("at://{did}/{}/{rkey}", nsid::ASSIGNMENT); | ||
reindex(&app, &repo, &record).await?; | ||
if forge::parse_uri(subject).is_ok_and(|(author, _, _)| author == did) { | ||
reindex(&app, &repo, subject).await?; | ||
} | ||
let assignees = item.assignees.iter().map(|d| actor(&app, d)); | ||
let reviewers = item.reviewers.iter().map(|d| actor(&app, d)); | ||
Ok(answer(Assigned { | ||
assignees: futures::future::join_all(assignees).await, | ||
reviewers: futures::future::join_all(reviewers).await, | ||
})) | ||
} | ||
| ||
#[derive(Deserialize)] | ||
pub struct HideInput { | ||
repo: String, | ||
crates/knotless-serve/src/web/views.rs+6−1
| @@ -258,9 +258,13 @@ pub struct ItemView { | ||
/// A pull its author has not marked ready. | ||
#[serde(skip_serializing_if = "std::ops::Not::not")] | ||
pub draft: bool, | ||
/// Those a pull's author asks to review it. | ||
/// Those asked to review a pull: by its author, then by the owner or | ||
/// a collaborator. | ||
#[serde(skip_serializing_if = "Vec::is_empty")] | ||
pub reviewers: Vec<ActorView>, | ||
/// Those the owner or a collaborator assigns it to. | ||
#[serde(skip_serializing_if = "Vec::is_empty")] | ||
pub assignees: Vec<ActorView>, | ||
#[serde(skip_serializing_if = "Vec::is_empty")] | ||
pub labels: Vec<LabelView>, | ||
} | ||
| @@ -280,6 +284,7 @@ pub async fn item_view(app: &App, record: &RepoRecord, item: &Item) -> ItemView | ||
comments: item.comments, | ||
draft: item.draft, | ||
reviewers: futures::future::join_all(item.reviewers.iter().map(|r| actor(app, r))).await, | ||
assignees: futures::future::join_all(item.assignees.iter().map(|a| actor(app, a))).await, | ||
labels: labels.collect(), | ||
} | ||
} | ||