~ 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

Pulls and issues are assigned, and maintainers ask for reviews

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:forge/assignees
.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(),
}
}
crates/knotless-serve/src/index.rs+63−10
crates/knotless-serve/src/plans.rs+2−1
crates/knotless-serve/src/spaces.rs+2−1
crates/knotless-serve/tests/gateway/opening.rs+29−0
crates/knotless-serve/tests/forge.rs+111−0
docs/design/collaboration.md+46−12
docs/design/serve.md+14−5
docs/reference/actions.md+1−0
docs/reference/api.md+4−3
docs/reference/cli.md+2−0
docs/reference/mcp.md+2−1
harness/e2e/flows.spec.mjs+7−0
harness/e2e/private.spec.mjs+6−0
lexicons/dev/knotless/assignItem.jsonnew+92−0
lexicons/dev/knotless/assignment.jsonnew+78−0
lexicons/dev/knotless/defs.json+6−1
lexicons/dev/knotless/getPreferences.json+1−0
lexicons/dev/knotless/getThread.json+1−0
lexicons/dev/knotless/listNotifications.json+3−3
lexicons/dev/knotless/space.json+2−1
web/src/lib/molecules/AddMenu.svelte+30−8
web/src/lib/molecules/AssigneesFact.sveltenew+50−0
web/src/lib/molecules/NotificationItem.svelte+3−3
web/src/lib/organisms/ItemFacts.svelte+25−10
web/src/lib/organisms/NotificationSettings.svelte+2−1
web/src/lib/notifications.svelte.ts+8−1
web/src/routes/[authority]/[repo]/[kind=item]/[did]/[rkey]/+page.svelte+11−5
web/src/app.css+5−4