~ 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

An issue's page shows the pulls that mention it

Merged chadtmiller.com asked to merge into main from chadtmiller.com/knotless:serve/item-references
.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 &lt;crash&gt;</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())]);
crates/knotless-serve/tests/forge.rs+34−0
docs/design/collaboration.md+8−0
docs/reference/api.md+1−1
docs/reference/cli.md+1−1
docs/reference/mcp.md+1−1
lexicons/dev/knotless/getThread.json+14−0
web/src/routes/[authority]/[repo]/[kind=item]/[did]/[rkey]/+page.svelte+14−6