chadtmiller.com/knotless
git that lives in your Atmosphere account
1
10
An issue's page shows the pulls that mention it
.changeset/show-mentions.mdnew+7−0
| @@ -0,0 +1,7 @@ | ||
--- | ||
default: minor | ||
--- | ||
| ||
# `show` lists the pulls that mention an issue | ||
| ||
`knotless show` on an issue prints `mentioned in <pull>` for each pull on the repository whose title or body names it, oldest first, ending `, to close it` where the pull names it after a closing keyword. MCP's `show_item` gives the same as `mentionedBy`. | ||
.knotless/issue.md+3−1
| @@ -13,7 +13,9 @@ | ||
| ||
<!-- Steps someone else can follow, from a fresh login if they can, and | ||
the repository, pull or run's URI. This matters most and is the | ||
hardest part to give: say which steps you are unsure of. --> | ||
hardest part to give: say which steps you are unsure of. A pull or | ||
issue of this repository named by its bare at:// URI, not in | ||
`code`, shows as a link with its title. --> | ||
| ||
## What you expected | ||
| ||
.knotless/pull.md+4−1
| @@ -5,7 +5,10 @@ | ||
The squash commit takes this title and body as its message, so | ||
say everything here: a link is no substitute for the reason. | ||
Delete a section that has nothing to say, but keep at least one: | ||
a body with none of these headings is refused. --> | ||
a body with none of these headings is refused. | ||
Name a pull or issue of this repository by its at:// URI, bare, | ||
not in `code`: its page shows it as a link with its title, and an | ||
issue named so lists this pull on its own page. --> | ||
| ||
## What changes | ||
| ||
crates/knotless/src/cli.rs+4−0
| @@ -1674,6 +1674,10 @@ async fn command( | ||
if let Some(pull) = &i.closed_by { | ||
writeln!(out, "closed by {pull}")?; | ||
} | ||
for m in &t.mentioned_by { | ||
let closes = if m.closes { ", to close it" } else { "" }; | ||
writeln!(out, "mentioned in {}{closes}", m.pull)?; | ||
} | ||
if let Some(pull) = &i.reverts { | ||
writeln!(out, "reverts {pull}")?; | ||
} | ||
crates/knotless/src/closing.rs+95−0
| @@ -10,6 +10,9 @@ | ||
//! own repository count: others are passed over. The merge closes each | ||
//! that is open, and that whoever merges may close, with a | ||
//! `dev.knotless.state` naming the pull. | ||
//! | ||
//! A pull that names an issue the same way with no keyword before it | ||
//! mentions it: the issue's page lists it, as it lists those that close it. | ||
| ||
use crate::forge::{self, Item, Target}; | ||
use crate::keys; | ||
| @@ -41,6 +44,18 @@ pub fn named_issues(text: &str) -> Vec<String> { | ||
named | ||
} | ||
| ||
/// Every issue `text` names, by their `at://` URIs, in the order it names | ||
/// them: after a closing keyword or not. | ||
pub fn mentioned_issues(text: &str) -> Vec<String> { | ||
let mut named: Vec<String> = Vec::new(); | ||
for uri in unlinked(text).split_whitespace().filter_map(issue) { | ||
if !named.contains(&uri) { | ||
named.push(uri); | ||
} | ||
} | ||
named | ||
} | ||
| ||
/// `text` with each Markdown link, `[text](target)`, as its target. | ||
fn unlinked(text: &str) -> String { | ||
let mut out = String::new(); | ||
| @@ -115,6 +130,26 @@ pub fn closes<'a>(pull: &Item, items: &'a [Item]) -> Vec<&'a Item> { | ||
found.collect() | ||
} | ||
| ||
/// The pulls among `items` whose title or body names `issue`, in the order | ||
/// `items` holds them, each with whether it names it to close: none for a | ||
/// pull. | ||
pub fn mentioning<'a>(issue: &Item, items: &'a [Item]) -> Vec<(&'a Item, bool)> { | ||
if issue.kind != "issue" { | ||
return Vec::new(); | ||
} | ||
let pulls = items.iter().filter(|i| i.kind == "pull"); | ||
let said = |pull: &'a Item| { | ||
let text = format!( | ||
"{}\n{}", | ||
pull.title, | ||
pull.body.as_deref().unwrap_or_default() | ||
); | ||
let names = mentioned_issues(&text).contains(&issue.uri); | ||
names.then(|| (pull, named_issues(&text).contains(&issue.uri))) | ||
}; | ||
pulls.filter_map(said).collect() | ||
} | ||
| ||
/// The issues among `items` that merging `pull` into `branch` closes, by | ||
/// their URIs: those it names, on the repository whose record is `repo` | ||
/// and whose owner is `owner`, when `branch` is its default branch, that | ||
| @@ -202,6 +237,66 @@ mod tests { | ||
assert_eq!(named_issues(&two), [I.to_string(), format!("{I}2")]); | ||
} | ||
| ||
#[test] | ||
fn what_a_pull_mentions() { | ||
let page = "https://knotless.dev/alice.test/r/issue/did:plc:a/3abc"; | ||
let text = format!("See {I}, and [this]({page}). Fixes {I}2; not `{I}3`x"); | ||
let i2 = format!("{I}2"); | ||
assert_eq!(mentioned_issues(&text), [I.to_string(), i2]); | ||
assert!(mentioned_issues("See at://did:plc:a/dev.knotless.pull/3abc").is_empty()); | ||
} | ||
| ||
#[test] | ||
fn which_pulls_mention_an_issue() { | ||
let issue = item(I, "issue", "open", "did:plc:a", None); | ||
let mentions = format!("For {I}"); | ||
let fixes = format!("Fixes {I}"); | ||
let items = vec![ | ||
issue.clone(), | ||
item( | ||
"at://did:plc:b/dev.knotless.pull/1", | ||
"pull", | ||
"open", | ||
"did:plc:b", | ||
Some(&mentions), | ||
), | ||
item( | ||
"at://did:plc:b/dev.knotless.pull/2", | ||
"pull", | ||
"merged", | ||
"did:plc:b", | ||
Some(&fixes), | ||
), | ||
item( | ||
"at://did:plc:b/dev.knotless.pull/3", | ||
"pull", | ||
"open", | ||
"did:plc:b", | ||
None, | ||
), | ||
item( | ||
"at://did:plc:b/dev.knotless.issue/4", | ||
"issue", | ||
"open", | ||
"did:plc:b", | ||
Some(&fixes), | ||
), | ||
]; | ||
let found: Vec<(&str, bool)> = mentioning(&issue, &items) | ||
.iter() | ||
.map(|(p, closes)| (p.uri.as_str(), *closes)) | ||
.collect(); | ||
assert_eq!( | ||
found, | ||
[ | ||
("at://did:plc:b/dev.knotless.pull/1", false), | ||
("at://did:plc:b/dev.knotless.pull/2", true) | ||
] | ||
); | ||
// A pull is mentioned by none. | ||
assert!(mentioning(&items[1], &items).is_empty()); | ||
} | ||
| ||
fn item(uri: &str, kind: &str, state: &str, author: &str, body: Option<&str>) -> Item { | ||
serde_json::from_value(json!({ | ||
"uri": uri, "author": author, "kind": kind, "title": "t", "body": body, | ||
crates/knotless/src/forge.rs+22−0
| @@ -243,6 +243,21 @@ pub struct Thread { | ||
/// What a pull's target branch asks of its reviews, when it asks. | ||
#[serde(skip)] | ||
pub asks: Option<records::Reviews>, | ||
/// For an issue, the pulls on the repository whose title or body names | ||
/// it, oldest first ([`closing::mentioning`]). | ||
#[serde(default, skip_serializing_if = "Vec::is_empty")] | ||
pub mentioned_by: Vec<Mention>, | ||
} | ||
| ||
/// A pull that names an issue. | ||
#[derive(Serialize, Deserialize, Clone, Debug, PartialEq)] | ||
#[serde(rename_all = "camelCase")] | ||
pub struct Mention { | ||
/// The pull's URI. | ||
pub pull: String, | ||
/// Whether it names the issue after a closing keyword. | ||
#[serde(default, skip_serializing_if = "std::ops::Not::not")] | ||
pub closes: bool, | ||
} | ||
| ||
/// A reaction read with a private repository's records. | ||
| @@ -632,7 +647,14 @@ pub fn thread_in( | ||
let writers = reviewers.filter(writes).map(str::to_string).collect(); | ||
let rule = item.base.as_deref().and_then(|b| repo.rule(b)); | ||
let asks = rule.and_then(|r| r.reviews.clone()); | ||
let mut mentions = crate::closing::mentioning(&item, items); | ||
mentions.sort_by(|(a, _), (b, _)| a.created_at.cmp(&b.created_at)); | ||
let mentioned_by = mentions.into_iter().map(|(pull, closes)| Mention { | ||
pull: pull.uri.clone(), | ||
closes, | ||
}); | ||
Ok(Thread { | ||
mentioned_by: mentioned_by.collect(), | ||
item, | ||
comments, | ||
handles: BTreeMap::new(), | ||
crates/knotless-mcp/src/lib.rs+3−0
| @@ -1995,6 +1995,9 @@ impl Server { | ||
item["reviewers"] = asked.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)?; | ||
} | ||
if let Some(compared) = compared { | ||
out["compare"] = match compared { | ||
Ok(compared) => serde_json::to_value(compared)?, | ||
crates/knotless-mcp/tests/mcp.rs+5−2
| @@ -1148,7 +1148,7 @@ fn agents_fork_review_and_merge() { | ||
// comment back. | ||
let edited = c.json( | ||
"edit_item", | ||
json!({"repo": repo, "uri": pull, "title": "Add b, please", "body": "smaller"}), | ||
json!({"repo": repo, "uri": pull, "title": "Add b, please", "body": format!("smaller, for {issue}")}), | ||
); | ||
assert_eq!(edited["edited"], pull); | ||
let said = c.json( | ||
| @@ -1201,11 +1201,14 @@ fn agents_fork_review_and_merge() { | ||
let shown = o.json("show_item", json!({"repo": repo, "uri": pull})); | ||
let comments = shown["comments"].as_array().unwrap().clone(); | ||
shown["item"]["title"] == "Add b, please" | ||
&& shown["item"]["body"] == "smaller" | ||
&& shown["item"]["body"] == format!("smaller, for {issue}") | ||
&& comments | ||
.iter() | ||
.any(|x| x["body"] == "one more thing" && x["updatedAt"].is_string()) | ||
}); | ||
// The issue it names says so. | ||
let shown = o.json("show_item", json!({"repo": repo, "uri": issue})); | ||
assert_eq!(shown["mentionedBy"], json!([{"pull": pull}]), "{shown}"); | ||
assert_eq!( | ||
c.json("delete_comment", json!({"repo": repo, "uri": said}))["deleted"], | ||
said | ||
crates/knotless-serve/src/web/items.rs+41−2
| @@ -117,6 +117,14 @@ pub struct ThreadParams { | ||
first_rows: bool, | ||
} | ||
| ||
/// `dev.knotless.getThread#mention`: a pull that names an issue. | ||
#[derive(Serialize)] | ||
struct MentionView { | ||
item: ItemView, | ||
#[serde(skip_serializing_if = "std::ops::Not::not")] | ||
closes: bool, | ||
} | ||
| ||
/// `dev.knotless.getThread`'s answer: a pull or an issue, and everything | ||
/// said on it. | ||
#[derive(Serialize)] | ||
| @@ -147,6 +155,9 @@ pub struct ThreadView { | ||
/// The pull whose merge closed an issue. | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
closed_by: Option<ItemViewBasic>, | ||
/// For an issue, the pulls on the repository that name it. | ||
#[serde(skip_serializing_if = "Vec::is_empty")] | ||
mentioned_by: Vec<MentionView>, | ||
/// The revert whose merge undid a merged pull. | ||
#[serde(skip_serializing_if = "Option::is_none")] | ||
reverted_by: Option<ItemViewBasic>, | ||
| @@ -240,6 +251,7 @@ pub async fn get_thread( | ||
item, | ||
comments, | ||
resolved, | ||
mentioned_by, | ||
.. | ||
} = thread; | ||
let lined = anchored(&app, &repo, &item, &comments).await; | ||
| @@ -313,6 +325,7 @@ pub async fn get_thread( | ||
}; | ||
let base = repo.href(""); | ||
let shown_repo = repo.uri(); | ||
let named = named_items(&repo, all); | ||
let said = futures::future::join_all(comments.iter().map(|c| async { | ||
CommentView { | ||
uri: c.uri.clone(), | ||
| @@ -328,11 +341,12 @@ pub async fn get_thread( | ||
uri: &c.uri, | ||
images: &comment_pictures(&c.value), | ||
}; | ||
written( | ||
written_naming( | ||
body, | ||
&base, | ||
Some(attached), | ||
&mentions::mentioned(body, &facets), | ||
&named, | ||
) | ||
}, | ||
verdict: c.value["verdict"].as_str().map(str::to_string), | ||
| @@ -361,6 +375,18 @@ pub async fn get_thread( | ||
.await; | ||
let queue = queue::in_queue(&app, &repo, &item).await?; | ||
let closes = item_views(&app, &record, closing::closes(&item, all)).await; | ||
let mentions = mentioned_by.iter().filter_map(|m| { | ||
let pull = all.iter().find(|i| i.uri == m.pull)?; | ||
Some((pull, m.closes)) | ||
}); | ||
let mentions: Vec<(&Item, bool)> = mentions.collect(); | ||
let pulls = item_views(&app, &record, mentions.iter().map(|(p, _)| *p)).await; | ||
let mentioned_by = pulls.into_iter().zip(&mentions); | ||
let mentioned_by = mentioned_by.map(|(item, (_, closes))| MentionView { | ||
item, | ||
closes: *closes, | ||
}); | ||
let mentioned_by = mentioned_by.collect(); | ||
// The pull that closed an issue, or the revert that undid a pull. | ||
let basic = |uri: &Option<String>| { | ||
let found = all.iter().find(|i| uri.as_ref() == Some(&i.uri)); | ||
| @@ -378,11 +404,12 @@ pub async fn get_thread( | ||
uri, | ||
images, | ||
}; | ||
written( | ||
written_naming( | ||
b, | ||
&base, | ||
Some(attached), | ||
&mentions::mentioned(b, &item.facets), | ||
&named, | ||
) | ||
}), | ||
item: item_view(&app, &record, &item).await, | ||
| @@ -394,6 +421,7 @@ pub async fn get_thread( | ||
reviews, | ||
closes, | ||
closed_by, | ||
mentioned_by, | ||
reverted_by, | ||
queue, | ||
checks, | ||
| @@ -405,6 +433,17 @@ pub async fn get_thread( | ||
})) | ||
} | ||
| ||
/// `repo`'s pulls and issues among `items`, for what is said on one to | ||
/// link to by their `at://` URIs. | ||
fn named_items(repo: &Repo, items: &[Item]) -> Named { | ||
let page = |i: &Item| { | ||
let (did, _, rkey) = forge::parse_uri(&i.uri).ok()?; | ||
let path = repo.href(&format!("/{}/{did}/{rkey}", i.kind)); | ||
Some((i.uri.clone(), (path, i.title.clone()))) | ||
}; | ||
items.iter().filter_map(page).collect() | ||
} | ||
| ||
/// The most lines a comment on a pull's lines shows of them. | ||
const LINES_SHOWN: u32 = 20; | ||
| ||
crates/knotless-serve/src/web.rs+151−6
| @@ -1999,11 +1999,37 @@ pub(super) fn written( | ||
attached: Option<Attached>, | ||
mentioned: &HashMap<String, String>, | ||
) -> String { | ||
drawn(text, base, base, "", None, attached, Some(mentioned)) | ||
written_naming(text, base, attached, mentioned, &Named::new()) | ||
} | ||
| ||
/// The pulls and issues of a repository, by their `at://` URIs: each its | ||
/// page's path and its title. | ||
pub(super) type Named = HashMap<String, (String, String)>; | ||
| ||
/// [`written`], with each pull or issue `named` holds linked to its page | ||
/// where the text names it by its `at://` URI, bare or as a link's target. | ||
/// A bare one shows as its title. | ||
pub(super) fn written_naming( | ||
text: &str, | ||
base: &str, | ||
attached: Option<Attached>, | ||
mentioned: &HashMap<String, String>, | ||
named: &Named, | ||
) -> String { | ||
let said = Said { mentioned, named }; | ||
drawn(text, base, base, "", None, attached, Some(said)) | ||
} | ||
| ||
/// Whom and what a pull, issue or comment names, to link to. | ||
#[derive(Clone, Copy)] | ||
struct Said<'a> { | ||
mentioned: &'a HashMap<String, String>, | ||
named: &'a Named, | ||
} | ||
| ||
/// [`markdown`], with the pictures `attached` shown from here, and with | ||
/// `mentioned`, its mentions linked as [`written`] links them. | ||
/// `said`, its mentions and references linked as [`written_naming`] links | ||
/// them. | ||
fn drawn( | ||
text: &str, | ||
blobs: &str, | ||
| @@ -2011,13 +2037,17 @@ fn drawn( | ||
dir: &str, | ||
trees: Trees, | ||
attached: Option<Attached>, | ||
mentioned: Option<&HashMap<String, String>>, | ||
said: Option<Said>, | ||
) -> String { | ||
let dir = url_path(dir); | ||
use pulldown_cmark::{Event, Options, Parser, Tag}; | ||
let options = | ||
Options::ENABLE_TABLES | Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TASKLISTS; | ||
let parsed = pulldown_cmark::TextMergeStream::new(Parser::new_ext(text, options)); | ||
let page = |dest: &str| { | ||
said.and_then(|s| s.named.get(dest)) | ||
.map(|(path, _)| path.clone()) | ||
}; | ||
let events = autolinks(parsed).map(|event| match event { | ||
Event::Html(html) | Event::InlineHtml(html) => Event::Text(html), | ||
Event::Start(Tag::Link { | ||
| @@ -2027,7 +2057,9 @@ fn drawn( | ||
id, | ||
}) => Event::Start(Tag::Link { | ||
link_type, | ||
dest_url: link(&dest_url, blobs, &dir, trees).into(), | ||
dest_url: page(&dest_url) | ||
.unwrap_or_else(|| link(&dest_url, blobs, &dir, trees)) | ||
.into(), | ||
title, | ||
id, | ||
}), | ||
| @@ -2047,8 +2079,8 @@ fn drawn( | ||
}), | ||
other => other, | ||
}); | ||
let events: Vec<Event> = match mentioned { | ||
Some(mentioned) => mentions(events, mentioned).collect(), | ||
let events: Vec<Event> = match said { | ||
Some(said) => mentions(references(events, said.named), said.mentioned).collect(), | ||
None => events.collect(), | ||
}; | ||
let mut out = String::new(); | ||
| @@ -2124,6 +2156,76 @@ fn mentions<'a>( | ||
}) | ||
} | ||
| ||
/// Text's `at://` URIs of the pulls and issues `named` holds, as links to | ||
/// their pages, each shown as its title. Not in code, nor in what is | ||
/// already a link. | ||
fn references<'a>( | ||
events: impl Iterator<Item = pulldown_cmark::Event<'a>>, | ||
named: &Named, | ||
) -> impl Iterator<Item = pulldown_cmark::Event<'a>> { | ||
use pulldown_cmark::{Event, Tag, TagEnd}; | ||
let mut inside = 0usize; | ||
events.flat_map(move |event| match event { | ||
Event::Text(text) if inside == 0 => referenced(text, named), | ||
event => { | ||
match &event { | ||
Event::Start(Tag::Link { .. } | Tag::Image { .. } | Tag::CodeBlock(_)) => { | ||
inside += 1 | ||
} | ||
Event::End(TagEnd::Link | TagEnd::Image | TagEnd::CodeBlock) => inside -= 1, | ||
_ => {} | ||
} | ||
vec![event] | ||
} | ||
}) | ||
} | ||
| ||
/// `text` as text, with each `at://` URI in it that `named` holds made a | ||
/// link to its page, showing its title. | ||
fn referenced<'a>( | ||
text: pulldown_cmark::CowStr<'a>, | ||
named: &Named, | ||
) -> Vec<pulldown_cmark::Event<'a>> { | ||
use pulldown_cmark::{Event, LinkType, Tag, TagEnd}; | ||
let mut events = Vec::new(); | ||
let (mut done, mut from) = (0, 0); | ||
while let Some(found) = text[from..].find("at://") { | ||
let at = from + found; | ||
from = at + "at://".len(); | ||
let before = text[..at].chars().next_back(); | ||
if before.is_some_and(char::is_alphanumeric) { | ||
continue; | ||
} | ||
let rest = &text[at..]; | ||
let end = rest.find(|c: char| c.is_whitespace() || "<>()[]\"'`".contains(c)); | ||
let uri = | ||
rest[..end.unwrap_or(rest.len())].trim_end_matches(['.', ',', ';', ':', '!', '?']); | ||
let Some((path, title)) = named.get(uri) else { | ||
continue; | ||
}; | ||
if done < at { | ||
events.push(Event::Text(text[done..at].to_string().into())); | ||
} | ||
events.push(Event::Start(Tag::Link { | ||
link_type: LinkType::Inline, | ||
dest_url: path.clone().into(), | ||
title: "".into(), | ||
id: "".into(), | ||
})); | ||
events.push(Event::Text(title.clone().into())); | ||
events.push(Event::End(TagEnd::Link)); | ||
done = at + uri.len(); | ||
from = done; | ||
} | ||
if done == 0 { | ||
return vec![Event::Text(text)]; | ||
} | ||
if done < text.len() { | ||
events.push(Event::Text(text[done..].to_string().into())); | ||
} | ||
events | ||
} | ||
| ||
/// Text's bare web addresses as links, as GitHub makes them, but not in | ||
/// code or in what is already a link. | ||
fn autolinks<'a>( | ||
| @@ -3205,6 +3307,49 @@ mod tests { | ||
assert_eq!(html.matches("width=").count(), 2, "{html}"); | ||
} | ||
| ||
#[test] | ||
fn references_link_to_the_pulls_and_issues_they_name() { | ||
let issue = "at://did:plc:a/dev.knotless.issue/1"; | ||
let pull = "at://did:plc:b/dev.knotless.pull/2"; | ||
let named = Named::from([ | ||
( | ||
issue.to_string(), | ||
( | ||
"/o/r/issue/did:plc:a/1".to_string(), | ||
"A <crash>".to_string(), | ||
), | ||
), | ||
( | ||
pull.to_string(), | ||
("/o/r/pull/did:plc:b/2".to_string(), "Fix it".to_string()), | ||
), | ||
]); | ||
let text = format!( | ||
"See ({issue}), {pull}. Not `{issue}`, x{issue}, [here]({pull}) or at://did:plc:c/dev.knotless.issue/3; ask @bob.test." | ||
); | ||
let none = HashMap::new(); | ||
let html = written_naming(&text, "/o/r", None, &none, &named); | ||
assert!( | ||
html.contains(r#"See (<a href="/o/r/issue/did:plc:a/1">A <crash></a>), <a href="/o/r/pull/did:plc:b/2">Fix it</a>. Not <code>"#), | ||
"{html}" | ||
); | ||
assert!(html.contains(&format!("x{issue}")), "{html}"); | ||
assert!( | ||
html.contains(r#"<a href="/o/r/pull/did:plc:b/2">here</a>"#), | ||
"{html}" | ||
); | ||
assert!( | ||
html.contains("or at://did:plc:c/dev.knotless.issue/3;"), | ||
"{html}" | ||
); | ||
assert!( | ||
html.contains(r#"<a href="/bob.test">@bob.test</a>"#), | ||
"{html}" | ||
); | ||
// Without them, as it was. | ||
assert!(written(&text, "/o/r", None, &none).contains(&format!("({issue})"))); | ||
} | ||
| ||
#[test] | ||
fn mentions_link_to_whom_they_name() { | ||
let mentioned = HashMap::from([("alice.test".to_string(), "did:plc:a".to_string())]); | ||