This commit is contained in:
2026-08-25 17:23:32 +07:00
parent e34008775c
commit e1eef3d107
9 changed files with 1090 additions and 51 deletions
+306
View File
@@ -0,0 +1,306 @@
use nostr::prelude::*;
/// ngit / GitWorkshop cover-note extension (kind 1624): a markdown note
/// attached to an issue, patch or PR by its author or a repository
/// maintainer. Not part of the NIP-34 draft; read support for interop.
pub const COVER_NOTE_KIND: Kind = Kind::Custom(1624);
/// Whether a kind-1985 label event is a valid annotation of `root`: it
/// references the root via a lowercase `e` tag and was authored by the root
/// author or a maintainer.
fn label_targets_root(event: &Event, root: &Event, maintainers: &[PublicKey]) -> bool {
if event.kind != Kind::Label {
return false;
}
if event.pubkey != root.pubkey && !maintainers.contains(&event.pubkey) {
return false;
}
let root_id = root.id.to_hex();
event
.tags
.iter()
.any(|tag| tag.kind() == "e" && tag.content().is_some_and(|content| content == root_id))
}
/// Whether a kind-1985 label event declares the `#t` namespace and carries at
/// least one `["l", "<value>", "#t"]` label.
fn has_hashtag_labels(event: &Event) -> bool {
event.tags.iter().any(|tag| tag.as_slice() == ["L", "#t"])
&& event.tags.iter().any(|tag| {
let slice = tag.as_slice();
slice.len() >= 3 && slice[0] == "l" && slice[2] == "#t" && !slice[1].is_empty()
})
}
/// The effective hashtag labels of `root`: the `t` tags on the event itself
/// (self-reported by its author) plus all labels attached via authorized
/// NIP-32 kind-1985 events in the `#t` namespace. Labels are additive — all
/// valid label events contribute (no latest-wins semantics).
pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) -> Vec<String> {
let mut labels: Vec<String> = root
.tags
.hashtags()
.map(|hashtag| hashtag.to_string())
.collect();
for event in label_events {
if !label_targets_root(event, root, maintainers) || !has_hashtag_labels(event) {
continue;
}
for tag in event.tags.iter() {
let slice = tag.as_slice();
if slice.len() >= 3 && slice[0] == "l" && slice[2] == "#t" && !slice[1].is_empty() {
let label = &slice[1];
if !labels.contains(label) {
labels.push(label.clone());
}
}
}
}
labels
}
/// The effective subject/title override of `root`, from authorized kind-1985
/// label events in the `#subject` namespace. Only the latest event wins
/// (tiebreak: lexicographically larger event id, per NIP-01 replaceable
/// semantics). Returns `None` when no valid override exists.
pub fn subject_override(
root: &Event,
label_events: &[Event],
maintainers: &[PublicKey],
) -> Option<String> {
label_events
.iter()
.filter(|event| label_targets_root(event, root, maintainers))
.filter(|event| {
event
.tags
.iter()
.any(|tag| tag.as_slice() == ["L", "#subject"])
&& event.tags.iter().any(|tag| {
let slice = tag.as_slice();
slice.len() >= 3
&& slice[0] == "l"
&& slice[2] == "#subject"
&& !slice[1].is_empty()
})
})
.max_by(|a, b| {
a.created_at
.cmp(&b.created_at)
.then_with(|| a.id.to_string().cmp(&b.id.to_string()))
})
.and_then(|event| {
event.tags.iter().find_map(|tag| {
let slice = tag.as_slice();
(slice.len() >= 3
&& slice[0] == "l"
&& slice[2] == "#subject"
&& !slice[1].is_empty())
.then(|| slice[1].clone())
})
})
}
/// The effective hashtag labels and subject override of `root` in one pass
/// (mirrors ngit's `get_labels_and_subject`).
pub fn labels_and_subject(
root: &Event,
label_events: &[Event],
maintainers: &[PublicKey],
) -> (Vec<String>, Option<String>) {
(
labels(root, label_events, maintainers),
subject_override(root, label_events, maintainers),
)
}
/// The effective cover note of `root`: the latest authorized kind-1624 event
/// (tiebreak: lexicographically larger event id, per NIP-01 replaceable
/// semantics). Returns `None` when no valid cover note exists.
pub fn cover_note<'a>(
root: &Event,
cover_notes: &'a [Event],
maintainers: &[PublicKey],
) -> Option<&'a Event> {
let root_id = root.id.to_hex();
cover_notes
.iter()
.filter(|event| {
event.kind == COVER_NOTE_KIND
&& (event.pubkey == root.pubkey || maintainers.contains(&event.pubkey))
&& event.tags.iter().any(|tag| {
tag.kind() == "e" && tag.content().is_some_and(|content| content == root_id)
})
})
.max_by(|a, b| {
a.created_at
.cmp(&b.created_at)
.then_with(|| a.id.to_string().cmp(&b.id.to_string()))
})
}
#[cfg(test)]
mod tests {
use super::*;
fn keys_from_hex(hex: &str) -> Keys {
Keys::new(SecretKey::from_hex(hex).expect("valid secret key"))
}
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.finalize(author)
.expect("signed event")
}
fn root_event() -> Event {
signed(
&keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001"),
Kind::GitIssue,
vec![Tag::hashtag("bug")],
100,
)
}
fn e_tag(event: &Event) -> Tag {
Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag")
}
#[test]
fn labels_take_inline_hashtags_and_external_label_events() {
let root = root_event();
let maintainer =
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
let labels_event = signed(
&maintainer,
Kind::Label,
vec![
e_tag(&root),
Tag::parse(["L", "#t"]).expect("valid L tag"),
Tag::parse(["l", "help-wanted", "#t"]).expect("valid l tag"),
],
200,
);
let labels = labels(&root, &[labels_event], &[maintainer.public_key()]);
assert_eq!(labels, vec!["bug", "help-wanted"]);
}
#[test]
fn labels_ignore_unauthorized_and_misnamed_events() {
let root = root_event();
let maintainer =
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
let stranger =
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000003");
// A stranger's label event is not authorized.
let stranger_labels = signed(
&stranger,
Kind::Label,
vec![
e_tag(&root),
Tag::parse(["L", "#t"]).expect("valid L tag"),
Tag::parse(["l", "nope", "#t"]).expect("valid l tag"),
],
200,
);
// A valid author referencing a different event.
let other_labels = signed(
&maintainer,
Kind::Label,
vec![
Tag::parse([
"e",
"2222222222222222222222222222222222222222222222222222222222222222",
])
.expect("valid e tag"),
Tag::parse(["L", "#t"]).expect("valid L tag"),
Tag::parse(["l", "nope", "#t"]).expect("valid l tag"),
],
200,
);
// A valid author without the namespace declaration.
let missing_namespace = signed(
&maintainer,
Kind::Label,
vec![
e_tag(&root),
Tag::parse(["l", "nope", "#t"]).expect("valid l tag"),
],
200,
);
assert_eq!(
labels(
&root,
&[stranger_labels, other_labels, missing_namespace],
&[maintainer.public_key()]
),
vec!["bug"]
);
}
#[test]
fn subject_override_latest_authorized_event_wins() {
let root = root_event();
let maintainer =
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
let older = signed(
&maintainer,
Kind::Label,
vec![
e_tag(&root),
Tag::parse(["L", "#subject"]).expect("valid L tag"),
Tag::parse(["l", "Old title", "#subject"]).expect("valid l tag"),
],
200,
);
let newer = signed(
&maintainer,
Kind::Label,
vec![
e_tag(&root),
Tag::parse(["L", "#subject"]).expect("valid L tag"),
Tag::parse(["l", "New title", "#subject"]).expect("valid l tag"),
],
300,
);
assert_eq!(
subject_override(&root, &[newer, older], &[maintainer.public_key()]),
Some("New title".to_owned())
);
}
#[test]
fn cover_note_latest_authorized_event_wins() {
let root = root_event();
let maintainer =
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
let stranger =
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000003");
let older = signed(&maintainer, COVER_NOTE_KIND, vec![e_tag(&root)], 200);
let newer = signed(&maintainer, COVER_NOTE_KIND, vec![e_tag(&root)], 300);
let unauthorized = signed(&stranger, COVER_NOTE_KIND, vec![e_tag(&root)], 400);
let newer_id = newer.id;
let events = [older, unauthorized, newer];
let maintainers = [maintainer.public_key()];
let note = cover_note(&root, &events, &maintainers);
assert_eq!(note.map(|event| event.id), Some(newer_id));
}
#[test]
fn cover_note_none_without_valid_events() {
let root = root_event();
assert_eq!(cover_note(&root, &[], &[]), None);
}
}
+161
View File
@@ -0,0 +1,161 @@
use std::collections::{HashMap, HashSet};
use nostr::prelude::*;
/// A NIP-22 comment thread: a top-level comment on the root event and its
/// nested replies (oldest first at every level).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CommentThread {
/// The thread's top-level comment.
pub comment: Event,
/// Replies to [`Self::comment`], nested recursively.
pub replies: Vec<CommentThread>,
}
/// The direct parent of a comment (NIP-22 lowercase `e` tag), or `None` for
/// comments without one.
fn comment_parent(event: &Event) -> Option<EventId> {
event
.tags
.iter()
.find(|tag| tag.kind() == "e")
.and_then(Tag::content)
.and_then(|id| EventId::parse(id).ok())
}
/// Group the comments on a root event (issue / patch / PR) into NIP-22
/// threads. A comment whose parent is the root itself starts a thread; other
/// comments nest under their parent comment. Threads and replies are ordered
/// oldest-first. Replies whose parent comment is missing (e.g. not fetched)
/// are placed as top-level threads so they are not dropped.
pub fn comment_threads(root: &Event, comments: &[Event]) -> Vec<CommentThread> {
// Index comments by their parent id. Comments without a parent tag are
// treated as replying to the root event itself.
let mut children: HashMap<EventId, Vec<&Event>> = HashMap::new();
for comment in comments {
let parent = comment_parent(comment).unwrap_or(root.id);
children.entry(parent).or_default().push(comment);
}
for list in children.values_mut() {
list.sort_by_key(|event| event.created_at);
}
let mut visited: HashSet<EventId> = HashSet::new();
fn build(
id: EventId,
children: &HashMap<EventId, Vec<&Event>>,
visited: &mut HashSet<EventId>,
) -> Vec<CommentThread> {
let Some(list) = children.get(&id) else {
return Vec::new();
};
let mut threads = Vec::new();
for event in list {
// Guards against malformed reply cycles.
if visited.insert(event.id) {
threads.push(CommentThread {
comment: (*event).clone(),
replies: build(event.id, children, visited),
});
}
}
threads
}
let mut threads = build(root.id, &children, &mut visited);
// Orphan replies: their parent comment is unknown, so they never appear
// in the tree rooted at the root event; surface them as top-level threads.
let mut orphans: Vec<&Event> = comments
.iter()
.filter(|event| !visited.contains(&event.id))
.collect();
orphans.sort_by_key(|event| event.created_at);
for comment in orphans {
if visited.insert(comment.id) {
threads.push(CommentThread {
comment: comment.clone(),
replies: build(comment.id, &children, &mut visited),
});
}
}
threads
}
#[cfg(test)]
mod tests {
use super::*;
fn comment(keys: &Keys, parent: Option<&Event>, content: &str, created_at: u64) -> Event {
let tags = parent
.map(|parent| vec![Tag::parse(["e", &parent.id.to_hex()]).expect("valid e tag")])
.unwrap_or_default();
EventBuilder::new(Kind::Comment, content)
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.finalize(keys)
.expect("signed event")
}
fn flatten(threads: &[CommentThread]) -> Vec<String> {
let mut out = Vec::new();
for thread in threads {
out.push(thread.comment.content.clone());
out.extend(flatten(&thread.replies));
}
out
}
#[test]
fn nests_replies_under_their_parents() {
let keys = Keys::generate();
let root = EventBuilder::new(Kind::GitIssue, "issue")
.finalize(&keys)
.expect("signed event");
let a = comment(&keys, Some(&root), "a", 100);
let a1 = comment(&keys, Some(&a), "a1", 200);
let a2 = comment(&keys, Some(&a), "a2", 300);
let b = comment(&keys, Some(&root), "b", 150);
let threads = comment_threads(&root, &[a2, b, a, a1]);
assert_eq!(flatten(&threads), vec!["a", "a1", "a2", "b"]);
}
#[test]
fn comments_without_a_parent_tag_attach_to_the_root() {
let keys = Keys::generate();
let root = EventBuilder::new(Kind::GitIssue, "issue")
.finalize(&keys)
.expect("signed event");
// Old-style comments carried no `e` tag at all.
let orphan = comment(&keys, None, "no parent", 100);
let threads = comment_threads(&root, &[orphan]);
assert_eq!(flatten(&threads), vec!["no parent"]);
}
#[test]
fn orphan_replies_are_surfaced_as_top_level_threads() {
let keys = Keys::generate();
let root = EventBuilder::new(Kind::GitIssue, "issue")
.finalize(&keys)
.expect("signed event");
let a = comment(&keys, Some(&root), "a", 100);
// `missing` is not in the comment set; its reply should still show up.
let missing = EventBuilder::new(Kind::Comment, "missing")
.finalize(&keys)
.expect("signed event");
let reply_to_missing = comment(&keys, Some(&missing), "reply to missing", 200);
let threads = comment_threads(&root, &[a, reply_to_missing]);
assert_eq!(flatten(&threads), vec!["a", "reply to missing"]);
}
}
+9
View File
@@ -52,6 +52,15 @@ pub fn statuses_for(root: EventId) -> Filter {
.event(root)
}
/// Cover notes (kind 1624) and NIP-32 label events (kind 1985) referencing a
/// specific root event (`#e` tag), fetched per root like comments and
/// statuses because they carry no repository `a` tag.
pub fn annotations_for(root: EventId) -> Filter {
Filter::new()
.kinds([crate::COVER_NOTE_KIND, Kind::Label])
.event(root)
}
/// A user's grasp list (kind `10317`).
pub fn grasp_list(public_key: PublicKey) -> Filter {
Filter::new()
+5 -1
View File
@@ -1,5 +1,7 @@
pub mod addr;
pub mod annotations;
pub mod clone_url;
pub mod comments;
pub mod deletions;
pub mod filters;
pub mod model;
@@ -7,8 +9,10 @@ pub mod state;
pub mod status;
pub use addr::{RepoAddr, repo_addr};
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
pub use clone_url::{CloneTarget, parse_clone_url};
pub use comments::{CommentThread, comment_threads};
pub use deletions::Deletions;
pub use model::{Announcement, activity_subject, pull_request_patch};
pub use state::parse_state;
pub use state::{build_state, parse_state};
pub use status::{RepoStatus, references_root, resolve_status};
+72
View File
@@ -22,6 +22,9 @@ pub struct Announcement {
pub euc: Option<String>,
/// Other recognized maintainers.
pub maintainers: Vec<PublicKey>,
/// Value of a `u` tag, if any: this repository is a subordinate fork of
/// the referenced upstream (NIP-34).
pub upstream: Option<String>,
/// Hashtags labelling the repository (`t` tags).
pub hashtags: Vec<String>,
}
@@ -186,6 +189,7 @@ impl Announcement {
let mut relays: Vec<RelayUrl> = Vec::new();
let mut euc: Option<String> = None;
let mut maintainers: Vec<PublicKey> = Vec::new();
let mut upstream: Option<String> = None;
for tag in event.tags.iter() {
match Nip34Tag::parse(tag.as_slice()) {
@@ -198,6 +202,12 @@ impl Announcement {
Ok(Nip34Tag::Maintainers(keys)) => maintainers.extend(keys),
_ => {}
}
// The `u` tag is not modelled by the SDK's `Nip34Tag`; parse it
// manually (first value wins).
if upstream.is_none() && tag.kind() == "u" {
upstream = tag.content().map(str::to_owned);
}
}
Some(Self {
@@ -211,6 +221,7 @@ impl Announcement {
relays,
euc,
maintainers,
upstream,
hashtags,
})
}
@@ -226,6 +237,18 @@ impl Announcement {
.clone()
.unwrap_or(SharedString::from("No description"))
}
/// The effective maintainers of this repository: the announced
/// `maintainers` plus the announcement author, who asserts themselves as
/// a maintainer of the primary project unless a `u` tag marks this
/// repository as a subordinate fork (NIP-34).
pub fn effective_maintainers(&self) -> Vec<PublicKey> {
let mut maintainers = self.maintainers.clone();
if self.upstream.is_none() && !maintainers.contains(&self.owner) {
maintainers.push(self.owner);
}
maintainers
}
}
#[cfg(test)]
@@ -348,6 +371,55 @@ mod tests {
assert!(announcement.web.is_empty());
}
#[test]
fn parses_upstream_tag() {
let event = announcement_event(&[
&["d", "my-fork"],
&["u", "30617:abc:upstream|https://example.com/upstream.git"],
]);
let announcement = Announcement::from_event(&event).expect("parses");
assert_eq!(
announcement.upstream.as_deref(),
Some("30617:abc:upstream|https://example.com/upstream.git")
);
}
#[test]
fn effective_maintainers_include_owner_for_primary_repos() {
let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]);
let announcement = Announcement::from_event(&event).expect("parses");
let maintainers = announcement.effective_maintainers();
// The owner asserts themselves as a maintainer of the primary
// project (NIP-34), alongside the announced co-maintainers.
assert_eq!(maintainers.len(), 2);
assert!(maintainers.contains(&announcement.owner));
assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")));
}
#[test]
fn effective_maintainers_exclude_owner_for_subordinate_forks() {
let event = announcement_event(&[
&["d", "my-fork"],
&["u", "30617:abc:upstream|https://example.com/upstream.git"],
&["maintainers", MAINTAINER_HEX],
]);
let announcement = Announcement::from_event(&event).expect("parses");
let maintainers = announcement.effective_maintainers();
// A `u` tag marks the repository as a subordinate fork: the author
// is not a maintainer of the primary project (NIP-34).
assert!(!maintainers.contains(&announcement.owner));
assert_eq!(
maintainers,
vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")]
);
}
/// Build a signed PR event with the given tags and content.
fn pr_event(content: &str, tags: Vec<Tag>) -> Event {
EventBuilder::new(Kind::GitPullRequest, content)
+51
View File
@@ -1,5 +1,23 @@
use nostr::prelude::*;
/// Build a kind `30618` repository state event from refs and HEAD.
///
/// `refs` are `(refname, commit-id)` pairs (e.g. `refs/heads/main`); `head`
/// is the short branch name HEAD points to, published as
/// `ref: refs/heads/<branch>`. The `d` tag matches the repository id.
pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
for (name, commit) in refs {
tags.push(Tag::parse([name.as_str(), commit.as_str()]).expect("valid ref tag"));
}
if let Some(head) = head {
tags.push(
Tag::parse(["HEAD", &format!("ref: refs/heads/{head}")]).expect("valid HEAD tag"),
);
}
EventBuilder::new(Kind::RepoState, "").tags(tags)
}
/// Parse a kind `30618` repository state event into refs and HEAD.
///
/// `refs` are `(refname, commit-id)` pairs; `head` is the branch pointed to
@@ -92,4 +110,37 @@ mod tests {
assert!(refs.is_empty());
assert!(head.is_none());
}
#[test]
fn build_state_round_trips_through_parse() {
let refs = [
("refs/heads/main".to_owned(), COMMIT_A.to_owned()),
("refs/heads/dev".to_owned(), COMMIT_B.to_owned()),
("refs/tags/v1.0".to_owned(), COMMIT_A.to_owned()),
];
let event = build_state("my-repo", &refs, Some("main"))
.finalize(&keys())
.expect("signed event");
assert_eq!(event.kind, Kind::RepoState);
assert_eq!(event.tags.identifier().as_deref(), Some("my-repo"));
let (parsed_refs, head) = parse_state(&event);
assert_eq!(parsed_refs, refs);
assert_eq!(head.as_deref(), Some("main"));
}
#[test]
fn build_state_omits_head_when_detached() {
let refs = [("refs/heads/main".to_owned(), COMMIT_A.to_owned())];
let event = build_state("my-repo", &refs, None)
.finalize(&keys())
.expect("signed event");
let (parsed_refs, head) = parse_state(&event);
assert_eq!(parsed_refs, refs);
assert!(head.is_none());
}
}