chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
Maintainers dismiss reviews
.changeset/dismiss-reviews.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: minor | ||
--- | ||
| ||
# Dismiss a review | ||
| ||
`knotless dismiss <url> <pull> <reviewer> <reason>` dismisses a review as the owner or a collaborator, and so does MCP's `dismiss_review`. The review counts for nothing until its reviewer reviews again, so a gate's request for changes no longer needs `--force`, even on your own pull. `show` marks a dismissed review. | ||
crates/knotless/src/approvals.rs+26−4
| @@ -12,7 +12,11 @@ | ||
//! named, as a review agent's ([`gated`]), though it may not write: one | ||
//! that asks for changes blocks it until it reviews again, and its | ||
//! approval counts toward nothing else. A collaborator's approval of the | ||
//! head, given after the gate's newest review, stands in for it. | ||
//! head, given after the gate's newest review, stands in for it, as does | ||
//! a writer's dismissing that review. | ||
//! | ||
//! A dismissed review ([`crate::reviews`]) counts for nothing: no | ||
//! approval, and no request for changes. | ||
//! | ||
//! `knotless merge`, `merge --check`, the pages' merge box, the merge | ||
//! queue as it takes a pull in and as its runner puts one in a batch all | ||
| @@ -163,6 +167,7 @@ pub fn gated( | ||
continue; | ||
} | ||
match at.map(|at| &reviews[at]) { | ||
Some(r) if r.dismissed.is_some() => {} | ||
Some(r) if r.verdict == reviews::CHANGES => changes.push(gate.did.clone()), | ||
Some(r) if r.at_head(head) => {} | ||
_ => awaiting.push(gate.did.clone()), | ||
| @@ -186,10 +191,11 @@ pub fn stand( | ||
changed: &[String], | ||
) -> Standing { | ||
let why = |r: &Review| uncounted(rule, r, writer(&r.by), head); | ||
let approving: Vec<&Review> = (reviews.iter()) | ||
let standing = || reviews.iter().filter(|r| r.dismissed.is_none()); | ||
let approving: Vec<&Review> = standing() | ||
.filter(|r| r.verdict == reviews::APPROVE && why(r).is_none()) | ||
.collect(); | ||
let not_counted = reviews.iter().filter_map(|r| Some((r.by.clone(), why(r)?))); | ||
let not_counted = standing().filter_map(|r| Some((r.by.clone(), why(r)?))); | ||
let mut unowned: Vec<(String, Vec<String>)> = Vec::new(); | ||
let mut authored = Vec::new(); | ||
let file = owners.filter(|_| rule.code_owners); | ||
| @@ -205,7 +211,7 @@ pub fn stand( | ||
} | ||
} | ||
} | ||
let mut changes: Vec<String> = (reviews.iter()) | ||
let mut changes: Vec<String> = standing() | ||
.filter(|r| writer(&r.by) && r.verdict == reviews::CHANGES) | ||
.map(|r| r.by.clone()) | ||
.collect(); | ||
| @@ -339,6 +345,7 @@ mod tests { | ||
verdict: verdict.into(), | ||
commit: Some(commit.into()), | ||
uri: format!("at://{by}/dev.knotless.comment/1"), | ||
dismissed: None, | ||
} | ||
} | ||
| ||
| @@ -438,6 +445,21 @@ mod tests { | ||
assert!(s(&after, &rule).passed()); | ||
let before = [after[1].clone(), after[0].clone()]; | ||
assert!(!s(&before, &rule).passed()); | ||
// A writer's dismissing its review lets it go, at any head. | ||
let dismissed = Review { | ||
dismissed: Some("at://did:alice/dev.knotless.comment/2".into()), | ||
..asked[0].clone() | ||
}; | ||
assert!(s(std::slice::from_ref(&dismissed), &rule).passed()); | ||
// A dismissed review counts for nothing, for or against. | ||
rule.approvals = 1; | ||
let alice = Review { | ||
verdict: reviews::APPROVE.into(), | ||
by: "did:alice".into(), | ||
..dismissed.clone() | ||
}; | ||
assert_eq!(s(&[dismissed, alice], &rule).missing(), ["reviews 0 of 1"]); | ||
rule.approvals = 0; | ||
let stale = [ | ||
asked[0].clone(), | ||
review("did:alice", reviews::APPROVE, "old"), | ||
crates/knotless/src/cli.rs+18−1
| @@ -281,6 +281,10 @@ const USAGE: &str = "usage: knotless <command> | ||
or as owner or collaborator | ||
unask-review <url> <at-uri> <handle>... | ||
stop asking them, as whoever asked | ||
dismiss <url> <at-uri> <handle> <reason> | ||
as owner or collaborator, dismiss their | ||
review of a pull: it counts for nothing, | ||
for or against, until they review again | ||
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 | ||
| @@ -1827,7 +1831,12 @@ async fn command( | ||
let by = t.handles.get(&r.by).unwrap_or(&r.by); | ||
let at = r.commit.as_deref().unwrap_or_default(); | ||
let at = at.get(..12).unwrap_or(at); | ||
writeln!(out, "review: {by} {} at {at}", r.verdict)?; | ||
let dismissed = if r.dismissed.is_some() { | ||
", dismissed" | ||
} else { | ||
"" | ||
}; | ||
writeln!(out, "review: {by} {} at {at}{dismissed}", r.verdict)?; | ||
} | ||
if let Some(q) = i | ||
.queue | ||
| @@ -1954,6 +1963,14 @@ async fn command( | ||
writeln!(out, "{}", handle.as_deref().unwrap_or(did))?; | ||
} | ||
} | ||
"dismiss" => { | ||
let url = args.url()?; | ||
let pull = args.next("<at-uri>")?; | ||
let reviewer = args.next("<handle>")?; | ||
let reason = text(args.next("<reason>")?, stdin)?; | ||
let uri = forge::dismiss(ctx, &url, pull, reviewer, &reason).await?; | ||
writeln!(out, "{uri}")?; | ||
} | ||
"label" | "unlabel" => { | ||
let url = args.url()?; | ||
let subject = args.next("<at-uri>")?; | ||
crates/knotless/src/forge.rs+62−1
| @@ -514,7 +514,11 @@ pub fn assemble(repo: &RepoRecord, owner: &str, entries: &[Entry]) -> Vec<Item> | ||
.unwrap_or_default(); | ||
item.comments = on.len(); | ||
if item.kind == "pull" { | ||
item.reviews = reviews::standing(&item.author, on.iter().copied()); | ||
let writer = |e: &Entry| { | ||
let at = e.uri.rsplit('/').next().unwrap_or_default(); | ||
maintains(repo, owner, &e.author, at) | ||
}; | ||
item.reviews = reviews::standing(&item.author, on.iter().copied(), &writer); | ||
} | ||
} | ||
items.sort_by(|a, b| b.created_at.cmp(&a.created_at)); | ||
| @@ -1693,6 +1697,7 @@ pub async fn comment( | ||
line: None, | ||
reply: None, | ||
review: None, | ||
dismisses: None, | ||
created_at: records::now(), | ||
updated_at: None, | ||
facets: Vec::new(), | ||
| @@ -2117,6 +2122,62 @@ pub async fn assign( | ||
Ok(item) | ||
} | ||
| ||
/// Dismisses `reviewer`'s standing review of `pull`, by handle or DID, | ||
/// saying why in `reason`: a comment naming it, so it counts for nothing | ||
/// until they review again. Only the repository's owner and collaborators | ||
/// dismiss, their own pulls' reviews too. Answers the comment's URI. | ||
pub async fn dismiss( | ||
ctx: &Ctx, | ||
target: &RepoUrl, | ||
pull: &str, | ||
reviewer: &str, | ||
reason: &str, | ||
) -> Result<String> { | ||
if reason.trim().is_empty() { | ||
bail!("say why the review is dismissed"); | ||
} | ||
let t = Target::open(ctx, target).await?; | ||
let pull = t.named(pull); | ||
let pull = pull.as_str(); | ||
if parse_uri(pull)?.1 != nsid::PULL { | ||
bail!("{pull} is not a pull: only a pull's reviews are dismissed"); | ||
} | ||
let did = did_of(ctx, reviewer).await?; | ||
let (mine, me) = t.mine(ctx).await?; | ||
let owner = t.remote.did(); | ||
if me.did != owner && !t.record.is_collaborator(&me.did) { | ||
bail!( | ||
"{}: only the owner and collaborators dismiss reviews", | ||
t.url | ||
); | ||
} | ||
let (entries, _) = t.around(ctx, pull).await?; | ||
let item = thread(&t.record, owner, &entries, pull)?.item; | ||
let review = item.reviews.iter().find(|r| r.by == did); | ||
let review = review.filter(|r| r.dismissed.is_none()); | ||
let review = review.with_context(|| format!("{reviewer} has no standing review of {pull}"))?; | ||
let record = CommentRecord { | ||
repo: t.reference(), | ||
subject: pull.to_string(), | ||
body: reason.to_string(), | ||
attachments: Vec::new(), | ||
verdict: None, | ||
commit: None, | ||
line: None, | ||
reply: None, | ||
review: None, | ||
dismisses: Some(review.uri.clone()), | ||
created_at: records::now(), | ||
updated_at: None, | ||
facets: mentions::facets(ctx, reason).await, | ||
}; | ||
let rkey = keys::tid(); | ||
let write = remote::create(nsid::COMMENT, &rkey, records::typed(nsid::COMMENT, &record)); | ||
mine.apply(vec![write], &me.auth()).await?; | ||
t.shown(ctx).await; | ||
Ok(mine.uri_of(nsid::COMMENT, &rkey)) | ||
} | ||
| ||
/// 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> { | ||
crates/knotless/src/queue.rs+1−0
| @@ -1238,6 +1238,7 @@ mod tests { | ||
verdict: crate::reviews::APPROVE.into(), | ||
commit: Some("h1".into()), | ||
uri: format!("at://{by}/dev.knotless.comment/c"), | ||
dismissed: None, | ||
}; | ||
// a, unreviewed, waits; the gate approved b's head; the owner's | ||
// approval of c's head stands in for the gate's. | ||
crates/knotless/src/records.rs+4−0
| @@ -1137,6 +1137,10 @@ pub struct CommentRecord { | ||
/// The review it was sent with, as one of its line comments. | ||
#[serde(default, skip_serializing_if = "Option::is_none")] | ||
pub review: Option<String>, | ||
/// The review, another comment, it dismisses: written by the owner or | ||
/// a collaborator, its body says why, and that review stops counting. | ||
#[serde(default, skip_serializing_if = "Option::is_none")] | ||
pub dismisses: Option<String>, | ||
/// Pictures its body shows, so its author's PDS keeps them. | ||
#[serde(default, skip_serializing_if = "Vec::is_empty")] | ||
pub attachments: Vec<Attachment>, | ||
crates/knotless/src/reviews.rs+59−2
| @@ -6,6 +6,11 @@ | ||
//! An approval is of the head it looked at ([`Review::at_head`]): once the | ||
//! pull moves, it stands for what the pull was, not what it is. A request | ||
//! for changes stands until its reviewer reviews again. | ||
//! | ||
//! One who may write to the repository dismisses a review with a comment | ||
//! that names it ([`CommentRecord::dismisses`]), as GitHub's do: it stays | ||
//! where its reviewer stands, marked dismissed, and counts for nothing, | ||
//! for or against the pull, until they review again. | ||
| ||
use crate::forge::Entry; | ||
use crate::records::CommentRecord; | ||
| @@ -29,6 +34,9 @@ pub struct Review { | ||
pub commit: Option<String>, | ||
/// The comment that reviewed it. | ||
pub uri: String, | ||
/// The comment that dismissed it, once one did. | ||
#[serde(default, skip_serializing_if = "Option::is_none")] | ||
pub dismissed: Option<String>, | ||
} | ||
| ||
impl Review { | ||
| @@ -40,13 +48,20 @@ impl Review { | ||
| ||
/// Each reviewer's standing review of a pull that `author` opened, from | ||
/// what was said on it in the order it was said: their latest comment | ||
/// with a verdict this version knows, in the order they last reviewed. | ||
/// with a verdict this version knows, in the order they last reviewed, | ||
/// each marked dismissed once a comment `writer` says may write to the | ||
/// repository dismissed it. | ||
pub fn standing<'a>( | ||
author: &str, | ||
said: impl IntoIterator<Item = (&'a Entry, &'a CommentRecord)>, | ||
writer: &dyn Fn(&Entry) -> bool, | ||
) -> Vec<Review> { | ||
let mut out: Vec<Review> = Vec::new(); | ||
for (e, c) in said { | ||
let named = c.dismisses.as_deref().filter(|_| writer(e)); | ||
if let Some(r) = named.and_then(|d| out.iter_mut().find(|r| r.uri == d)) { | ||
r.dismissed = Some(e.uri.clone()); | ||
} | ||
let verdict = c.verdict.as_deref(); | ||
let Some(verdict) = verdict.filter(|v| [APPROVE, CHANGES].contains(v)) else { | ||
continue; | ||
| @@ -60,6 +75,7 @@ pub fn standing<'a>( | ||
verdict: verdict.to_string(), | ||
commit: c.commit.clone(), | ||
uri: e.uri.clone(), | ||
dismissed: None, | ||
}); | ||
} | ||
out | ||
| @@ -75,11 +91,22 @@ mod tests { | ||
rkey: &str, | ||
verdict: Option<&str>, | ||
commit: &str, | ||
) -> (Entry, CommentRecord) { | ||
dismissing(author, rkey, verdict, commit, None) | ||
} | ||
| ||
fn dismissing( | ||
author: &str, | ||
rkey: &str, | ||
verdict: Option<&str>, | ||
commit: &str, | ||
dismisses: Option<&str>, | ||
) -> (Entry, CommentRecord) { | ||
let value = json!({ | ||
"repo": {"uri": "at://did:o/dev.knotless.repo/r", "key": "k"}, | ||
"subject": "at://did:a/dev.knotless.pull/p", "body": "", | ||
"verdict": verdict, "commit": commit, "createdAt": "t", | ||
"dismisses": dismisses, | ||
}); | ||
let entry = Entry { | ||
uri: format!("at://{author}/dev.knotless.comment/{rkey}"), | ||
| @@ -103,7 +130,9 @@ mod tests { | ||
said("did:a", "5", Some(APPROVE), "h2"), | ||
said("did:d", "6", Some("dismiss"), "h2"), | ||
]; | ||
let reviews = standing("did:a", all.iter().map(|(e, c)| (e, c))); | ||
// As forge reads them: borrowed pairs, copied. | ||
let pairs: Vec<(&Entry, &CommentRecord)> = all.iter().map(|(e, c)| (e, c)).collect(); | ||
let reviews = standing("did:a", pairs.iter().copied(), &|_| true); | ||
let by: Vec<(&str, &str)> = reviews | ||
.iter() | ||
.map(|r| (r.by.as_str(), r.verdict.as_str())) | ||
| @@ -112,4 +141,32 @@ mod tests { | ||
assert!(!reviews[0].at_head("h2") && reviews[1].at_head("h2")); | ||
assert_eq!(reviews[1].uri, "at://did:c/dev.knotless.comment/4"); | ||
} | ||
| ||
#[test] | ||
fn one_who_may_write_dismisses_a_review_until_its_reviewer_reviews_again() { | ||
let bot = "at://did:bot/dev.knotless.comment/1"; | ||
let all = [ | ||
said("did:bot", "1", Some(CHANGES), "h1"), | ||
// Someone who may not write names it: nothing changes. | ||
dismissing("did:x", "2", None, "h1", Some(bot)), | ||
]; | ||
let writer = |e: &Entry| e.author == "did:o"; | ||
// As forge reads them, so it is the one standing that runs. | ||
let read = |all: &[(Entry, CommentRecord)], author: &str| { | ||
let pairs: Vec<(&Entry, &CommentRecord)> = all.iter().map(|(e, c)| (e, c)).collect(); | ||
standing(author, pairs.iter().copied(), &writer) | ||
}; | ||
let reviews = read(&all, "did:a"); | ||
assert_eq!(reviews[0].dismissed, None); | ||
let mut all = all.to_vec(); | ||
// The owner, the pull's author here, may. | ||
all.push(dismissing("did:o", "3", None, "h1", Some(bot))); | ||
let reviews = read(&all, "did:o"); | ||
let by = "at://did:o/dev.knotless.comment/3"; | ||
assert_eq!(reviews[0].dismissed.as_deref(), Some(by)); | ||
// The bot reviews again: its new verdict stands. | ||
all.push(said("did:bot", "4", Some(CHANGES), "h2")); | ||
let reviews = read(&all, "did:o"); | ||
assert_eq!((reviews.len(), &reviews[0].dismissed), (1, &None)); | ||
} | ||
} | ||
crates/knotless-mcp/src/lib.rs+32−2
| @@ -1313,6 +1313,17 @@ pub struct AssignItem { | ||
pub remove: bool, | ||
} | ||
| ||
#[derive(Deserialize, schemars::JsonSchema)] | ||
pub struct DismissReview { | ||
pub repo: String, | ||
/// The pull's at:// URI. | ||
pub uri: String, | ||
/// The reviewer whose review it dismisses, by handle or DID. | ||
pub reviewer: String, | ||
/// Why, as the comment's Markdown body. | ||
pub reason: String, | ||
} | ||
| ||
#[derive(Deserialize, schemars::JsonSchema)] | ||
pub struct SetState { | ||
pub repo: String, | ||
| @@ -2037,7 +2048,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`. `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." | ||
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`, and a comment that dismissed a review names it as `dismisses`. `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, `dismissed`, the comment that dismissed it (dismiss_review), when one did, 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, | ||
| @@ -2087,6 +2098,7 @@ impl Server { | ||
"line": lines(&c.value["line"]), | ||
"reply": c.value["reply"], | ||
"review": reviewed(c), | ||
"dismisses": c.value["dismisses"], | ||
"createdAt": c.value["createdAt"], | ||
"updatedAt": c.value["updatedAt"], | ||
"reactions": thread.reactions.get(&c.uri), | ||
| @@ -2127,6 +2139,7 @@ impl Server { | ||
"outdated": now.is_some_and(|now| !r.at_head(now)), | ||
"maintainer": writer, | ||
"uncounted": not, | ||
"dismissed": r.dismissed, | ||
}) | ||
}) | ||
.collect(); | ||
| @@ -2404,6 +2417,23 @@ impl Server { | ||
) | ||
} | ||
| ||
#[tool( | ||
description = "Dismiss a reviewer's standing review of a pull, saying why: a comment on the pull naming it, so it counts for nothing, for or against, until they review again. A dismissed gate's review lets the pull go without it. The owner and collaborators may, on their own pulls too, where their approval never counts. Answers the comment's uri." | ||
)] | ||
async fn dismiss_review( | ||
&self, | ||
Parameters(p): Parameters<DismissReview>, | ||
) -> Result<CallToolResult, McpError> { | ||
answer( | ||
async { | ||
let url = repo_url(&p.repo)?; | ||
let uri = forge::dismiss(&self.ctx, &url, &p.uri, &p.reviewer, &p.reason).await?; | ||
Ok(json!({ "uri": uri })) | ||
} | ||
.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." | ||
)] | ||
| @@ -3462,7 +3492,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), 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." | ||
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, dismiss_review 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+19−0
| @@ -1436,6 +1436,25 @@ fn agents_fork_review_and_merge() { | ||
); | ||
let off = json!({"repo": repo, "uri": issue, "accounts": [them], "remove": true}); | ||
assert_eq!(o.json("assign_item", off)["assignees"], json!([])); | ||
// The owner asks for changes to it, then dismisses that review: the | ||
// shown pull says so, and its author may not. | ||
let review = json!({"repo": repo, "uri": clash, "body": "no", "verdict": "changes"}); | ||
o.json("comment", review); | ||
let dismissing = json!({"repo": repo, "uri": clash, "reviewer": me, "reason": "moot"}); | ||
let (text, err) = c.call("dismiss_review", dismissing.clone()); | ||
assert!(err, "{text}"); | ||
let dismissal = o.json("dismiss_review", dismissing)["uri"].clone(); | ||
let shown = o.json("show_item", json!({"repo": repo, "uri": clash})); | ||
let mine = &shown["item"]["reviews"][0]; | ||
assert_eq!( | ||
(&mine["by"], &mine["dismissed"]), | ||
(&json!(owner.account.did), &dismissal) | ||
); | ||
let said = shown["comments"].as_array().unwrap(); | ||
assert!( | ||
said.iter().any(|c| c["dismisses"] == mine["uri"]), | ||
"{shown}" | ||
); | ||
let gone = o.json( | ||
"define_label", | ||
json!({"repo": repo, "name": "kind/bug", "remove": true}), | ||
crates/knotless-serve/src/web/items.rs+7−0
| @@ -369,6 +369,9 @@ pub async fn get_thread( | ||
line: lined.get(&c.uri).cloned(), | ||
reply: c.value["reply"].as_str().map(str::to_string), | ||
review: c.value["review"].as_str().map(str::to_string), | ||
dismisses: (c.value["dismisses"].as_str()) | ||
.filter(|_| c.author == repo.did || record.is_collaborator(&c.author)) | ||
.map(str::to_string), | ||
resolved: resolved.contains(&c.uri), | ||
reactions: reactions.get(&c.uri).cloned().unwrap_or_default(), | ||
} | ||
| @@ -377,6 +380,8 @@ pub async fn get_thread( | ||
// Against the head as it is now: a merged pull's reviews stand as | ||
// they were. | ||
let at = changes.as_ref().and_then(|c| c.head.as_deref()); | ||
let rule = item.base.as_deref().and_then(|b| record.rule(b)); | ||
let gates = (rule.and_then(|r| r.reviews.as_ref())).map_or(&[][..], |r| &r.gates[..]); | ||
let reviews = futures::future::join_all(item.reviews.iter().map(|r| async { | ||
ReviewView { | ||
reviewer: actor(&app, &r.by).await, | ||
| @@ -385,6 +390,8 @@ pub async fn get_thread( | ||
commit: r.commit.clone(), | ||
outdated: at.is_some_and(|at| !r.at_head(at)), | ||
maintainer: r.by == repo.did || record.is_collaborator(&r.by), | ||
dismissed: r.dismissed.is_some(), | ||
gate: gates.iter().any(|g| g.did == r.by), | ||
} | ||
})) | ||
.await; | ||