diff --git a/crates/signed_core/src/annotations.rs b/crates/signed_core/src/annotations.rs new file mode 100644 index 0000000..1541b22 --- /dev/null +++ b/crates/signed_core/src/annotations.rs @@ -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", "", "#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 { + let mut labels: Vec = 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 { + 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, Option) { + ( + 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, 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); + } +} diff --git a/crates/signed_core/src/comments.rs b/crates/signed_core/src/comments.rs new file mode 100644 index 0000000..1715cf6 --- /dev/null +++ b/crates/signed_core/src/comments.rs @@ -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, +} + +/// The direct parent of a comment (NIP-22 lowercase `e` tag), or `None` for +/// comments without one. +fn comment_parent(event: &Event) -> Option { + 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 { + // Index comments by their parent id. Comments without a parent tag are + // treated as replying to the root event itself. + let mut children: HashMap> = 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 = HashSet::new(); + + fn build( + id: EventId, + children: &HashMap>, + visited: &mut HashSet, + ) -> Vec { + 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 { + 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"]); + } +} diff --git a/crates/signed_core/src/filters.rs b/crates/signed_core/src/filters.rs index 46e70db..d11d929 100644 --- a/crates/signed_core/src/filters.rs +++ b/crates/signed_core/src/filters.rs @@ -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() diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index 0a0e74c..a19d715 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -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}; diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index 1ce232c..a4a9f83 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -22,6 +22,9 @@ pub struct Announcement { pub euc: Option, /// Other recognized maintainers. pub maintainers: Vec, + /// Value of a `u` tag, if any: this repository is a subordinate fork of + /// the referenced upstream (NIP-34). + pub upstream: Option, /// Hashtags labelling the repository (`t` tags). pub hashtags: Vec, } @@ -186,6 +189,7 @@ impl Announcement { let mut relays: Vec = Vec::new(); let mut euc: Option = None; let mut maintainers: Vec = Vec::new(); + let mut upstream: Option = 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 { + 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) -> Event { EventBuilder::new(Kind::GitPullRequest, content) diff --git a/crates/signed_core/src/state.rs b/crates/signed_core/src/state.rs index dff0fcb..6032748 100644 --- a/crates/signed_core/src/state.rs +++ b/crates/signed_core/src/state.rs @@ -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/`. The `d` tag matches the repository id. +pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder { + let mut tags: Vec = 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()); + } } diff --git a/crates/signed_git/src/lib.rs b/crates/signed_git/src/lib.rs index 3b3b483..713dbf1 100644 --- a/crates/signed_git/src/lib.rs +++ b/crates/signed_git/src/lib.rs @@ -1245,6 +1245,53 @@ pub fn current_branch(repo: &gix::Repository) -> Result> { Ok(Some(String::from_utf8_lossy(name.shorten()).into_owned())) } +/// Branch, tag and HEAD refs of a repository, ready for a NIP-34 kind-30618 +/// repository state announcement. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RepoRefState { + /// `(full refname, commit id)` pairs for heads and tags, sorted. + pub refs: Vec<(String, String)>, + /// Short branch name HEAD points to, or `None` when detached. + pub head: Option, +} + +/// Collect the refs of `repo`: local branches and tags as +/// `(refname, commit-id)` pairs, plus the branch HEAD points to. +pub fn repo_ref_state(repo: &gix::Repository) -> Result { + let mut refs = Vec::new(); + + for reference in repo.references()?.local_branches()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + refs.push(( + String::from_utf8_lossy(reference.name().as_bstr()).into_owned(), + reference.id().to_string(), + )); + } + for reference in repo.references()?.tags()? { + let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?; + refs.push(( + String::from_utf8_lossy(reference.name().as_bstr()).into_owned(), + reference.id().to_string(), + )); + } + refs.sort(); + + let head = match repo.head() { + Ok(head) => head + .referent_name() + .filter(|name| name.as_bstr().starts_with(b"refs/heads/")) + .map(|name| String::from_utf8_lossy(name.shorten()).into_owned()), + Err(_) => None, + }; + + Ok(RepoRefState { refs, head }) +} + +/// [`repo_ref_state`] for the repository at `workdir`. +pub fn worktree_ref_state(workdir: &Path) -> Result { + repo_ref_state(&open_with_cache(workdir)?) +} + /// Everything the browser needs to refresh after a branch or tag switch. pub struct WorktreeSnapshot { /// Relative paths of all worktree entries, directories first. @@ -1375,6 +1422,47 @@ mod tests { ); } + #[test] + fn repo_ref_state_lists_branches_tags_and_head() { + let (_dir, repo) = fixture(&[("a.txt", b"hello")]); + commit_all(&repo, "initial"); + let workdir = repo.workdir().expect("workdir").to_path_buf(); + + let state = repo_ref_state(&repo).expect("refs"); + + let branch = current_branch(&repo).expect("branch").expect("on a branch"); + assert_eq!(state.head.as_deref(), Some(branch.as_str())); + assert_eq!(state.refs.len(), 1); + assert_eq!(state.refs[0].0, format!("refs/heads/{branch}")); + assert_eq!(state.refs[0].1.len(), 40); + + // Additional branches and tags are listed alongside. + git_run(&workdir, &["branch", "feature"]); + git_run(&workdir, &["tag", "v1.0"]); + + let state = repo_ref_state(&repo).expect("refs"); + let mut expected: Vec = vec![ + format!("refs/heads/{branch}"), + "refs/heads/feature".to_owned(), + "refs/tags/v1.0".to_owned(), + ]; + expected.sort(); + assert_eq!( + state + .refs + .iter() + .map(|(name, _)| name.clone()) + .collect::>(), + expected + ); + + // A detached HEAD yields no head branch. + git_run(&workdir, &["checkout", "--detach"]); + let state = repo_ref_state(&repo).expect("refs"); + assert!(state.head.is_none()); + assert_eq!(state.refs.len(), 3); + } + /// Build a throwaway non-bare repository with the given files (rel → bytes). fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, gix::Repository) { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 5ac5869..88c556d 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -3,6 +3,7 @@ use std::time::Duration; use anyhow::{Error, anyhow}; use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task}; +use nostr::event::IntoEventBuilder; use nostr_connect::prelude::*; use nostr_sdk::client::SyncSummary; use nostr_sdk::prelude::*; @@ -838,6 +839,17 @@ impl Backend { }) } + /// Publish a NIP-34 repository announcement (kind 30617) with the + /// current signer. The returned task yields the published event, so + /// callers can show inline progress/errors. + pub fn publish_announcement( + &mut self, + announcement: GitRepositoryAnnouncement, + cx: &mut Context, + ) -> Task> { + self.send(announcement.into_event_builder(), cx) + } + /// Sign, broadcast and store an event without awaiting the result; /// failures surface through [`BackendEvent::Error`]. The spawned task is /// owned by the backend, so it is cancelled when the backend is dropped. diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 0154f55..6456352 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -1,11 +1,14 @@ +use std::borrow::Cow; use std::collections::HashSet; use std::time::Duration; use anyhow::Error; use gpui::{AppContext, Context, Subscription, Task}; +use nostr::event::IntoEventBuilder; use nostr_sdk::prelude::*; use signed_core::{ - Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patch, + Announcement, COVER_NOTE_KIND, Deletions, RepoAddr, RepoStatus, build_state, cover_note, + filters, labels_and_subject, parse_state, pull_request_patch, subject_override, }; use crate::backend::{Backend, BackendEvent}; @@ -31,15 +34,21 @@ pub struct RepoStore { /// Comments on issues / PRs, oldest first. pub comments: Vec, statuses: Vec, + /// Kind-1624 cover notes and kind-1985 label events referencing this + /// repository's roots (ngit / GitWorkshop extensions). + cover_notes: Vec, + labels: Vec, /// Error of the last action initiated from this store, if any. pub last_error: Option, /// Relays announced by this repository (NIP-34 `relays` tag) that we /// have already been asked to connect to and fetch from, to avoid /// re-subscribing on every refresh. repo_relays: HashSet, - /// Root events (issues, patches, PRs) for which a NIP-22 comment fetch - /// has already been requested, to avoid re-fetching on every refresh. - comment_roots: HashSet, + /// Root events (issues, patches, PRs) for which the per-root fetches + /// (NIP-22 comments, statuses without an `a` tag, cover notes and + /// labels) have already been requested, to avoid re-fetching on every + /// refresh. + root_fetches: HashSet, refreshing: bool, refresh_dirty: bool, /// A refresh is waiting out [`REFRESH_DEBOUNCE`]. @@ -65,8 +74,13 @@ impl RepoStore { // matched by coordinate; any comment may reference this // repository's roots. let comment = update.kind == Kind::Comment; + // Status events may omit their `a` tag (NIP-34), so any + // status event may reference a root of this repository. + let status = RepoStatus::from_kind(update.kind).is_some(); + // Cover notes and labels carry no `a` tag either. + let annotation = update.kind == COVER_NOTE_KIND || update.kind == Kind::Label; - deletion || coordinate || (author && kind) || comment + deletion || coordinate || (author && kind) || comment || status || annotation } BackendEvent::Published(event) => { let kind = event.kind == Kind::GitRepoAnnouncement; @@ -93,9 +107,11 @@ impl RepoStore { pull_requests: Vec::new(), comments: Vec::new(), statuses: Vec::new(), + cover_notes: Vec::new(), + labels: Vec::new(), last_error: None, repo_relays: HashSet::new(), - comment_roots: HashSet::new(), + root_fetches: HashSet::new(), refreshing: false, refresh_dirty: false, debouncing: false, @@ -227,6 +243,7 @@ impl RepoStore { let (mut issues, mut patches, mut pull_requests, mut statuses, mut comments) = (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()); + let (mut cover_notes, mut labels): (Vec, Vec) = (Vec::new(), Vec::new()); for event in activity { if deletions.is_deleted(&event) { @@ -260,10 +277,54 @@ impl RepoStore { } } + // Status events may omit their `a` tag (NIP-34 makes it + // optional), so also query them by the root events they + // reference. + let db = client.database(); + let mut seen_statuses: HashSet = statuses.iter().map(|e| e.id).collect(); + let roots = issues + .iter() + .chain(&patches) + .chain(&pull_requests) + .map(|e| e.id); + for root in roots { + for event in db.query(filters::statuses_for(root)).await? { + if seen_statuses.insert(event.id) { + statuses.push(event); + } + } + } + + // Cover notes (1624) and label events (1985) reference their + // target via an `e` tag, so query them per root like comments + // and statuses. + let db = client.database(); + let mut seen_cover_notes: HashSet = cover_notes.iter().map(|e| e.id).collect(); + let mut seen_labels: HashSet = labels.iter().map(|e| e.id).collect(); + let roots = issues + .iter() + .chain(&patches) + .chain(&pull_requests) + .map(|e| e.id); + for root in roots { + for event in db.query(filters::annotations_for(root)).await? { + if deletions.is_deleted(&event) { + continue; + } + if event.kind == COVER_NOTE_KIND && seen_cover_notes.insert(event.id) { + cover_notes.push(event); + } else if event.kind == Kind::Label && seen_labels.insert(event.id) { + labels.push(event); + } + } + } + sort_newest_first(&mut issues); sort_newest_first(&mut patches); sort_newest_first(&mut pull_requests); sort_oldest_first(&mut comments); + sort_newest_first(&mut cover_notes); + sort_newest_first(&mut labels); Ok::<_, Error>(( announcement, @@ -273,23 +334,34 @@ impl RepoStore { pull_requests, statuses, comments, + cover_notes, + labels, )) }); self.tasks.retain(|task| !task.is_ready()); self.tasks.push(cx.spawn(async move |this, cx| { - let (announcement, state, issues, patches, pull_requests, statuses, comments) = - match work.await { - Ok(data) => data, - Err(e) => { - return this.update(cx, |this, cx| { - this.refreshing = false; - this.last_error = Some(e.to_string()); - cx.notify(); - }); - } - }; + let ( + announcement, + state, + issues, + patches, + pull_requests, + statuses, + comments, + cover_notes, + labels, + ) = match work.await { + Ok(data) => data, + Err(e) => { + return this.update(cx, |this, cx| { + this.refreshing = false; + this.last_error = Some(e.to_string()); + cx.notify(); + }); + } + }; let again = this.update(cx, |this, cx| { this.announcement = announcement; @@ -313,10 +385,13 @@ impl RepoStore { this.pull_requests = pull_requests; this.comments = comments; this.statuses = statuses; + this.cover_notes = cover_notes; + this.labels = labels; - // Comments are not addressed to the repository, so fetch - // them by the root events they reference, on the bootstrap - // relays and on the relays this repository announced. + // Comments, statuses without an `a` tag, cover notes and + // labels are not addressed to the repository, so fetch them + // by the root events they reference, on the bootstrap relays + // and on the relays this repository announced. let roots = this .issues .iter() @@ -326,17 +401,30 @@ impl RepoStore { .collect::>(); let new_roots: Vec = roots .iter() - .filter(|id| !this.comment_roots.contains(id)) + .filter(|id| !this.root_fetches.contains(id)) .copied() .collect(); if !new_roots.is_empty() { - this.comment_roots.extend(new_roots.iter().copied()); - let comment_filters = filters::comments_for(new_roots); + this.root_fetches.extend(new_roots.iter().copied()); + let comment_filters = filters::comments_for(new_roots.clone()); + let status_filters: Vec = new_roots + .iter() + .copied() + .map(filters::statuses_for) + .collect(); + let annotation_filters: Vec = new_roots + .into_iter() + .map(filters::annotations_for) + .collect(); let announced: Vec = this.repo_relays.iter().cloned().collect(); let backend = Backend::global(cx); backend.update(cx, |backend, cx| { backend.subscribe_bootstrap(comment_filters.clone(), cx); - backend.connect_repo_relays(announced, comment_filters, cx); + backend.connect_repo_relays(announced.clone(), comment_filters, cx); + backend.subscribe_bootstrap(status_filters.clone(), cx); + backend.connect_repo_relays(announced.clone(), status_filters, cx); + backend.subscribe_bootstrap(annotation_filters.clone(), cx); + backend.connect_repo_relays(announced, annotation_filters, cx); }); } @@ -366,15 +454,52 @@ impl RepoStore { let maintainers = self .announcement .as_ref() - .map(|a| a.maintainers.as_slice()) - .unwrap_or(&[]); + .map(Announcement::effective_maintainers) + .unwrap_or_default(); let events = self .statuses .iter() .filter(|e| signed_core::references_root(e, &root.id)); - signed_core::resolve_status(events, &root.pubkey, maintainers) + signed_core::resolve_status(events, &root.pubkey, &maintainers) + } + + /// The effective cover note of `root` (kind 1624), if any: the latest + /// note authored by the root author or a maintainer. + pub fn cover_note_of(&self, root: &Event) -> Option<&Event> { + let maintainers = self + .announcement + .as_ref() + .map(Announcement::effective_maintainers) + .unwrap_or_default(); + + cover_note(root, &self.cover_notes, &maintainers) + } + + /// The effective hashtag labels of `root`: its own `t` tags plus labels + /// from authorized NIP-32 kind-1985 events (`#t` namespace). + pub fn labels_of(&self, root: &Event) -> Vec { + let maintainers = self + .announcement + .as_ref() + .map(Announcement::effective_maintainers) + .unwrap_or_default(); + + let (labels, _) = labels_and_subject(root, &self.labels, &maintainers); + labels + } + + /// The effective subject/title override of `root` from authorized + /// kind-1985 events (`#subject` namespace), if any. + pub fn subject_of(&self, root: &Event) -> Option { + let maintainers = self + .announcement + .as_ref() + .map(Announcement::effective_maintainers) + .unwrap_or_default(); + + subject_override(root, &self.labels, &maintainers) } /// Number of open issues: issues whose resolved status is @@ -423,19 +548,32 @@ impl RepoStore { .filter(move |e| signed_core::references_root(e, root)) } - /// Comment on a root event (issue / PR) per NIP-34 (kind 1111). + /// Comment on a root event (issue / PR) per NIP-34 (kind 1111), using + /// the SDK's NIP-22 `CommentBuilder` so other NIP-34 clients (ngit, + /// GitWorkshop) can thread the comment. pub fn comment(&mut self, root: &Event, content: String, cx: &mut Context) { - let Ok(root_ref) = Tag::parse(["e", &root.id.to_hex(), "", "root"]) else { - return; - }; + self.reply(root, None, content, cx); + } - let builder = EventBuilder::new(Kind::Comment, content).tags([ - root_ref, - Tag::public_key(root.pubkey), - Tag::coordinate(self.addr.clone(), None), - ]); + /// Reply to `parent` (a comment on `root`) with a NIP-22 threaded + /// comment; `None` publishes a top-level comment on the root itself. + pub fn reply( + &mut self, + root: &Event, + parent: Option<&Event>, + content: String, + cx: &mut Context, + ) { + let relay_hint = self + .announcement + .as_ref() + .and_then(|a| a.relays.first()) + .cloned(); - self.send(builder, cx); + self.send( + comment_builder(root, parent, relay_hint.as_ref(), &self.addr, content), + cx, + ); } /// Open a pull request on this repository: a root PR event (kind 1618) @@ -444,11 +582,14 @@ impl RepoStore { /// references via an `e` tag (NIP-34). /// /// The patch is published first and the PR is sent once the patch - /// event's id is known, so the two always arrive together. The branch - /// metadata (branch name, clone URL, merge base) isn't known to the UI - /// yet and is left empty; the proposed commit is parsed from the patch's - /// `From ` header, falling back to an empty hash for hand-written - /// content. + /// event's id is known, so the two always arrive together. The proposed + /// commit is parsed from the patch's `From ` header; publishing + /// without one is refused, because the PR's `c` tag (and the patch's + /// `commit`/`r` tags) must carry a real commit id for other NIP-34 + /// clients to verify and apply the proposal. The PR's `clone` tag + /// carries the repository's announced mirror URLs (the commit may not be + /// pushed there yet; the linked patch is the source of truth until a + /// push backend exists). pub fn open_pull_request( &mut self, subject: Option, @@ -458,18 +599,39 @@ impl RepoStore { ) { self.last_error = None; - let current_commit = patch_current_commit(&patch) - .and_then(|hex| hex.parse().ok()) - .unwrap_or_else(|| bitcoin_hashes::Sha1::from_byte_array([0u8; 20])); + let Some(current_commit) = + patch_current_commit(&patch).and_then(|hex| hex.parse::().ok()) + else { + self.last_error = Some( + "Patch must be `git format-patch` output with a `From ` header".into(), + ); + cx.notify(); + return; + }; let Ok(root_marker) = Tag::parse(["t", "root"]) else { return; }; - let patch_builder = EventBuilder::new(Kind::GitPatch, patch).tags([ + let commit_hex = current_commit.to_string(); + let mut patch_tags = vec![ Tag::coordinate(self.addr.clone(), None), Tag::public_key(self.addr.public_key), root_marker, - ]); + ]; + // NIP-34: the `r` EUC tag lets clients subscribe to all patches of + // this repository; `commit`/`r` tags reference the proposed commit. + if let Some(euc) = self.announcement.as_ref().and_then(|a| a.euc.clone()) + && let Ok(tag) = Tag::parse(["r", &euc]) + { + patch_tags.push(tag); + } + if let Ok(tag) = Tag::parse(["commit", &commit_hex]) { + patch_tags.push(tag); + } + if let Ok(tag) = Tag::parse(["r", &commit_hex]) { + patch_tags.push(tag); + } + let patch_builder = EventBuilder::new(Kind::GitPatch, patch).tags(patch_tags); let patch_task = Backend::global(cx).update(cx, |backend, cx| backend.send(patch_builder, cx)); @@ -494,7 +656,14 @@ impl RepoStore { subject, labels: Vec::new(), branch_name: None, - clone: Vec::new(), + // NIP-34: PRs carry at least one clone URL where the + // tip commit can be downloaded; use the repository's + // announced mirrors until a push backend exists. + clone: this + .announcement + .as_ref() + .map(|a| a.clone.clone()) + .unwrap_or_default(), current_commit, root_patch_event: Some(patch_event.id), merge_base: None, @@ -515,8 +684,30 @@ impl RepoStore { })); } - /// Set the status of a root event (requires being the root author or a maintainer). + /// Set the status of a root event. Per NIP-34 only the root author or a + /// repository maintainer may set the status; status events from anyone + /// else are ignored by clients, so refuse them up front. pub fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context) { + self.last_error = None; + + let maintainers = self + .announcement + .as_ref() + .map(Announcement::effective_maintainers) + .unwrap_or_default(); + + let Some(user) = Backend::global(cx).read(cx).current_user() else { + self.last_error = Some("Sign in to change the status".into()); + cx.notify(); + return; + }; + + if user != root.pubkey && !maintainers.contains(&user) { + self.last_error = Some("Only the author or a maintainer can change the status".into()); + cx.notify(); + return; + } + let Ok(root_ref) = Tag::parse(["e", &root.id.to_hex(), "", "root"]) else { return; }; @@ -531,6 +722,57 @@ impl RepoStore { self.send(builder, cx); } + /// Publish a repository state announcement (kind 30618) with the refs of + /// the local clone: branches, tags and HEAD. Only the repository owner + /// may publish state, and a local clone must exist to read the refs from. + pub fn publish_state(&mut self, cx: &mut Context) { + self.last_error = None; + + let Some(user) = Backend::global(cx).read(cx).current_user() else { + self.last_error = Some("Sign in to publish repository state".into()); + cx.notify(); + return; + }; + if !self.is_author(&user) { + self.last_error = Some("Only the repository owner can publish state".into()); + cx.notify(); + return; + } + + let cache = GitStore::global(cx).cache().clone(); + let addr = self.addr.clone(); + let clone_urls: Vec = self + .announcement + .as_ref() + .map(|a| a.clone.iter().map(ToString::to_string).collect()) + .unwrap_or_default(); + + let work = cx.background_spawn(async move { + let repo = cache.ensure_clone(&addr, &clone_urls)?; + signed_git::repo_ref_state(&repo) + }); + + self.tasks.push(cx.spawn(async move |this, cx| { + let state = match work.await { + Ok(state) => state, + Err(e) => { + return this.update(cx, |this, cx| { + this.last_error = Some(e.to_string()); + cx.notify(); + }); + } + }; + + this.update(cx, |this, cx| { + let builder = + build_state(&this.addr.identifier, &state.refs, state.head.as_deref()); + this.send(builder, cx); + })?; + + Ok(()) + })); + } + /// Merge a pull request: apply its patch (the content of the linked /// root patch event) to the local clone of this repository, then publish /// the merged status. @@ -629,9 +871,41 @@ fn patch_current_commit(patch: &str) -> Option<&str> { hex.split_whitespace().next().filter(|hex| hex.len() == 40) } +/// Build a NIP-22 kind-1111 comment using the SDK's [`CommentBuilder`]: +/// uppercase `E`/`K`/`P` tags scope the thread root, lowercase `e`/`k`/`p` +/// tags the direct parent (`parent`, or the root itself for a top-level +/// comment). An `a` tag with the repository coordinate is added so Signed's +/// own activity subscriptions also match the comment (it is not part of +/// NIP-22). +fn comment_builder( + root: &Event, + parent: Option<&Event>, + relay_hint: Option<&RelayUrl>, + addr: &RepoAddr, + content: String, +) -> EventBuilder { + let target = |event: &Event| { + CommentTarget::event( + event.id, + event.kind, + Some(event.pubkey), + relay_hint.cloned().map(Cow::Owned), + ) + }; + let root_target = target(root); + let parent_target = parent.map(target).unwrap_or_else(|| root_target.clone()); + + CommentBuilder::new(content, parent_target) + .root(root_target) + .into_event_builder() + .tags([Tag::coordinate(addr.clone(), None)]) +} + #[cfg(test)] mod tests { - use super::patch_current_commit; + use nostr_sdk::prelude::*; + + use super::{comment_builder, patch_current_commit}; #[test] fn parses_format_patch_header() { @@ -648,4 +922,66 @@ mod tests { assert_eq!(patch_current_commit("Subject: [PATCH] x\n\n---\n"), None); assert_eq!(patch_current_commit("From short\n"), None); } + + #[test] + fn comment_builder_follows_nip22() { + let keys = Keys::generate(); + let root = EventBuilder::new(Kind::GitIssue, "issue body") + .finalize(&keys) + .expect("signed event"); + let addr = Coordinate::new(Kind::GitRepoAnnouncement, root.pubkey).identifier("my-repo"); + let relay = RelayUrl::parse("wss://relay.example.com").expect("valid relay URL"); + + let event = comment_builder(&root, None, Some(&relay), &addr, "hi".into()) + .finalize(&keys) + .expect("signed event"); + + assert_eq!(event.kind, Kind::Comment); + + let kinds: Vec<&str> = event.tags.iter().map(Tag::kind).collect(); + for expected in ["E", "K", "P", "e", "k", "p", "a"] { + assert!(kinds.contains(&expected), "missing {expected} tag"); + } + + // The uppercase `E` tag scopes the root: id, relay hint and author. + let e = event.tags.iter().find(|t| t.kind() == "E").expect("E tag"); + let slice = e.as_slice(); + assert_eq!(slice[1], root.id.to_hex()); + assert_eq!(slice[2], relay.as_str()); + assert_eq!(slice[3], root.pubkey.to_hex()); + + // The lowercase `e` tag references the parent, which for a top-level + // comment is the root itself. + let e = event.tags.iter().find(|t| t.kind() == "e").expect("e tag"); + assert_eq!(e.as_slice()[1], root.id.to_hex()); + + // Signed's own `references_root` must keep matching the comment. + assert!(signed_core::references_root(&event, &root.id)); + } + + #[test] + fn comment_builder_replies_nest_under_the_parent() { + let keys = Keys::generate(); + let root = EventBuilder::new(Kind::GitIssue, "issue body") + .finalize(&keys) + .expect("signed event"); + let parent = EventBuilder::new(Kind::Comment, "first comment") + .finalize(&keys) + .expect("signed event"); + let addr = Coordinate::new(Kind::GitRepoAnnouncement, root.pubkey).identifier("my-repo"); + + let event = comment_builder(&root, Some(&parent), None, &addr, "reply".into()) + .finalize(&keys) + .expect("signed event"); + + // The uppercase `E` tag still scopes the root event, while the + // lowercase `e` tag references the parent comment. + let root_ref = event.tags.iter().find(|t| t.kind() == "E").expect("E tag"); + let parent_ref = event.tags.iter().find(|t| t.kind() == "e").expect("e tag"); + assert_eq!(root_ref.as_slice()[1], root.id.to_hex()); + assert_eq!(parent_ref.as_slice()[1], parent.id.to_hex()); + + // The reply still threads under the root for Signed's own display. + assert!(signed_core::references_root(&event, &root.id)); + } }