~ 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

Maintainers dismiss reviews

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:forge/dismiss
.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;
crates/knotless-serve/src/web/threads.rs+29−0
crates/knotless-serve/src/web/views.rs+10−0
crates/knotless-serve/tests/gateway/opening.rs+23−0
crates/knotless-serve/tests/forge.rs+70−0
crates/knotless-serve/tests/web.rs+2−0
docs/design/collaboration.md+22−3
docs/reference/actions.md+1−0
docs/reference/api.md+2−1
docs/reference/cli.md+1−0
docs/reference/mcp.md+1−0
harness/e2e/flows.spec.mjs+20−0
lexicons/dev/knotless/comment.json+1−0
lexicons/dev/knotless/defs.json+4−1
lexicons/dev/knotless/dismissReview.jsonnew+71−0
lexicons/dev/knotless/getThread.json+1−0
web/src/lib/molecules/ReviewersFact.svelte+6−5
web/src/lib/molecules/Verdict.svelte+3−3
web/src/lib/organisms/MergeBox.svelte+5−3
web/src/lib/organisms/Reviewers.svelte+54−13
web/src/routes/[authority]/[repo]/[kind=item]/[did]/[rkey]/+page.svelte+19−3
web/src/app.css+4−3