From 023267001d784d020c7a7a4f582cd97602a38f99 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sat, 3 Oct 2026 12:03:29 +0700 Subject: [PATCH] reorganize signed_core into method-based APIs --- crates/signed_core/src/addr.rs | 120 ++++- crates/signed_core/src/filters.rs | 265 ++++------ crates/signed_core/src/inbox.rs | 289 ++++++----- crates/signed_core/src/lib.rs | 14 +- crates/signed_core/src/model.rs | 491 ++++++++++-------- crates/signed_core/src/state.rs | 92 ++-- crates/signed_core/src/status.rs | 44 +- crates/signed_git/src/cache.rs | 4 +- crates/signed_state/src/backend.rs | 32 +- crates/signed_state/src/inbox.rs | 10 +- crates/signed_state/src/local_repos.rs | 4 +- crates/signed_state/src/repo.rs | 89 ++-- crates/signed_state/src/repos.rs | 28 +- crates/workspace/src/views/issues/detail.rs | 4 +- crates/workspace/src/views/issues/mod.rs | 4 +- .../src/views/pull_requests/detail.rs | 24 +- .../workspace/src/views/pull_requests/mod.rs | 4 +- .../workspace/src/views/pull_requests/new.rs | 4 +- crates/workspace/src/views/repo/mod.rs | 6 +- crates/workspace/src/views/repo_list.rs | 2 +- 20 files changed, 833 insertions(+), 697 deletions(-) diff --git a/crates/signed_core/src/addr.rs b/crates/signed_core/src/addr.rs index 79d57fc..0945259 100644 --- a/crates/signed_core/src/addr.rs +++ b/crates/signed_core/src/addr.rs @@ -1,23 +1,113 @@ +use std::fmt; +use std::str::FromStr; + use nostr::prelude::*; +use serde::{Deserialize, Serialize}; /// Address of a NIP-34 repository announcement, `30617::`. /// -/// The Rust Nostr SDK's [`Coordinate`] parses, formats and hashes this, -/// the alias reuses the SDK type while keeping repository-specific vocabulary. -pub type RepoAddr = Coordinate; +/// Wraps the Rust Nostr SDK's [`Coordinate`], which parses, formats and hashes this. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(transparent)] +pub struct RepoAddr(Coordinate); -pub fn repo_addr(owner: PublicKey, id: impl Into) -> RepoAddr { - Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(id) +impl RepoAddr { + pub fn new(owner: PublicKey, identifier: impl Into) -> Self { + Self(Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(identifier)) + } + + /// NIP-34 `d` tag identifier for a user-visible repository name. + pub fn identifier_from_name(name: &str) -> String { + name.chars() + .map(|c| { + if c.is_ascii_alphanumeric() || c == '/' { + c + } else { + '-' + } + }) + .collect() + } + + pub fn kind(&self) -> Kind { + self.0.kind + } + + pub fn public_key(&self) -> PublicKey { + self.0.public_key + } + + pub fn identifier(&self) -> &str { + &self.0.identifier + } + + pub fn coordinate(&self) -> &Coordinate { + &self.0 + } + + /// Latest announcement event for this repository. + pub fn announcement_filter(&self) -> Filter { + Filter::new() + .kind(Kind::GitRepoAnnouncement) + .author(self.public_key()) + .identifier(self.identifier()) + } + + /// Latest state event for this repository, carrying refs and HEAD. + pub fn state_filter(&self) -> Filter { + Filter::new() + .kind(Kind::RepoState) + .author(self.public_key()) + .identifier(self.identifier()) + } + + /// All NIP-34 activity addressed to this repository via its `#a` tag. + /// Covers issues, PRs, patches, statuses and kind-1111 comments. + /// The `a` tag is optional on status events per NIP-34. + /// Statuses published without it are not matched here. + pub fn activity_filter(&self) -> Filter { + Filter::new() + .kinds(crate::filters::ACTIVITY_KINDS) + .coordinate(&self.0) + } + + /// Deletion events relevant to this repository. + /// + /// Requests authored by the repository owner. + /// + /// Requests addressed to the repository coordinate via its `#a` tag. + pub fn deletion_filters(&self) -> Vec { + vec![ + Filter::new() + .kinds([Kind::EventDeletion, Kind::RequestToVanish]) + .author(self.public_key()), + Filter::new().kind(Kind::EventDeletion).coordinate(&self.0), + ] + } } -pub fn identifier_from_name(name: &str) -> String { - name.chars() - .map(|c| { - if c.is_ascii_alphanumeric() || c == '/' { - c - } else { - '-' - } - }) - .collect() +impl fmt::Display for RepoAddr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + fmt::Display::fmt(&self.0, f) + } +} + +impl FromStr for RepoAddr { + type Err = ::Err; + + fn from_str(s: &str) -> Result { + Coordinate::from_str(s).map(RepoAddr) + } +} + +impl From for RepoAddr { + fn from(coordinate: Coordinate) -> Self { + RepoAddr(coordinate) + } +} + +impl From for Coordinate { + fn from(addr: RepoAddr) -> Self { + addr.0 + } } diff --git a/crates/signed_core/src/filters.rs b/crates/signed_core/src/filters.rs index 01dcaf0..01710e2 100644 --- a/crates/signed_core/src/filters.rs +++ b/crates/signed_core/src/filters.rs @@ -2,8 +2,6 @@ use std::time::Duration; use nostr::prelude::*; -use crate::RepoAddr; - /// Kinds that make up the activity of a repository. pub const ACTIVITY_KINDS: [Kind; 9] = [ Kind::Comment, @@ -37,7 +35,7 @@ const GIT_ROOT_KINDS: [Kind; 4] = [ Kind::GitRepoAnnouncement, ]; -/// Kinds that carry repository data: announcements, states, activity and deletions. +/// Whether `kind` carries repository data: announcements, states, activity and deletions. pub fn is_repo_kind(kind: Kind) -> bool { kind == Kind::GitRepoAnnouncement || kind == Kind::RepoState @@ -60,170 +58,121 @@ fn tag_kind(event: &Event, name: &str) -> Option { tag_value(event, name)?.parse::().ok() } -/// Latest announcement event for a repository. -pub fn announcement(addr: &RepoAddr) -> Filter { - Filter::new() - .kind(Kind::GitRepoAnnouncement) - .author(addr.public_key) - .identifier(addr.identifier.clone()) -} +/// Namespace for the repository-agnostic filter constructors. +pub struct Filters; -/// Latest state event for a repository, carrying refs and HEAD. -pub fn state(addr: &RepoAddr) -> Filter { - Filter::new() - .kind(Kind::RepoState) - .author(addr.public_key) - .identifier(addr.identifier.clone()) -} +impl Filters { + /// How far back deletion requests are fetched and stored. + const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400); -/// All NIP-34 activity addressed to a repository via its `#a` tag. -/// Covers issues, PRs, patches, statuses and kind-1111 comments. -/// The `a` tag is optional on status events per NIP-34. -/// Statuses published without it are not matched here. -pub fn activity(addr: &RepoAddr) -> Filter { - Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr) -} - -/// Status events, kinds `1630..=1633`, referencing any of the given root events. -/// They are matched via the `#e` tag. One filter covers all roots. -/// -/// A negentropy sync reconciles them in a single session, not one per root. -pub fn statuses_for(roots: impl IntoIterator) -> Filter { - Filter::new() - .kinds([ - Kind::GitStatusOpen, - Kind::GitStatusApplied, - Kind::GitStatusClosed, - Kind::GitStatusDraft, - ]) - .events(roots) -} - -/// A user's grasp list, kind `10317`. -pub fn grasp_list(public_key: PublicKey) -> Filter { - Filter::new() - .kind(Kind::GitUserGraspList) - .author(public_key) -} - -/// NIP-22 comments, kind `1111`, referencing any of the given root events. -/// The roots are issues, patches and PRs. -/// -/// Returns two filters, since combining `#E` and `#e` would AND the conditions. -pub fn comments_for(roots: impl IntoIterator) -> Vec { - let roots: Vec = roots.into_iter().map(|id| id.to_hex()).collect(); - if roots.is_empty() { - return Vec::new(); + /// Status events, kinds `1630..=1633`, referencing any of the given root events. + pub fn statuses_for(roots: impl IntoIterator) -> Filter { + Filter::new() + .kinds([ + Kind::GitStatusOpen, + Kind::GitStatusApplied, + Kind::GitStatusClosed, + Kind::GitStatusDraft, + ]) + .events(roots) } - vec![ + + /// A user's grasp list, kind `10317`. + pub fn grasp_list(public_key: PublicKey) -> Filter { + Filter::new() + .kind(Kind::GitUserGraspList) + .author(public_key) + } + + /// NIP-22 comments, kind `1111`, referencing any of the given root events. + /// The roots are issues, patches and PRs. + /// + /// Returns two filters, since combining `#E` and `#e` would AND the conditions. + pub fn comments_for(roots: impl IntoIterator) -> Vec { + let roots: Vec = roots.into_iter().map(|id| id.to_hex()).collect(); + if roots.is_empty() { + return Vec::new(); + } + vec![ + Filter::new() + .kind(Kind::Comment) + .custom_tags(SingleLetterTag::UPPERCASE_E, roots.clone()), + Filter::new() + .kind(Kind::Comment) + .custom_tags(SingleLetterTag::LOWERCASE_E, roots), + ] + } + + /// NIP-22 comments on our issues, patches and pull requests. + fn notification_comments(me: PublicKey) -> Filter { Filter::new() .kind(Kind::Comment) - .custom_tags(SingleLetterTag::UPPERCASE_E, roots.clone()), + .custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()]) + .custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"]) + } + + /// Activity directed at us: comments on our roots, and git events tagging us + /// via their lowercase `p` tag. `Filter::pubkey` sets that `p` tag. + pub fn notifications(me: PublicKey) -> Vec { + vec![ + Self::notification_comments(me), + Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me), + ] + } + + /// Git activity authored by `me`, for "Continue where you left off". + /// + /// A comment on an unrelated kind matches too, so results must be filtered + /// through `GitEvent::is_git_activity` before display. + pub fn authored_activity(me: PublicKey) -> Filter { + Filter::new().kinds(ACTIVITY_KINDS).author(me) + } + + /// All repository announcements, for global discovery. + pub fn all_announcements() -> Filter { + Filter::new().kind(Kind::GitRepoAnnouncement) + } + + /// All repository state events, carrying each repository's refs and last push time. + pub fn all_states() -> Filter { + Filter::new().kind(Kind::RepoState) + } + + /// `now` minus [`Self::DELETIONS_LOOKBACK`]. + /// Quantized to whole days so identical filters hash the same. + /// + /// This lets the backend's sync dedup match identical filters. + fn deletions_since() -> Timestamp { + let now = Timestamp::now().as_secs(); + Timestamp::from_secs(now - now % 86_400) - Self::DELETIONS_LOOKBACK + } + + /// All deletion-related events within [`Self::DELETIONS_LOOKBACK`]. + /// These are NIP-09 kind `5` and NIP-62 kind `62`. + /// + /// Deletion requests must be known before any other event is shown. + pub fn deletions() -> Filter { Filter::new() - .kind(Kind::Comment) - .custom_tags(SingleLetterTag::LOWERCASE_E, roots), - ] -} - -/// NIP-22 comments on our issues, patches and pull requests. -/// They are matched via the uppercase `P` and `K` tags, not authorship. -fn notification_comments(me: PublicKey) -> Filter { - Filter::new() - .kind(Kind::Comment) - .custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()]) - .custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"]) -} - -/// Activity directed at us: comments on our roots, and git events tagging us -/// via their lowercase `p` tag. `Filter::pubkey` sets that `p` tag. -pub fn notifications(me: PublicKey) -> Vec { - vec![ - notification_comments(me), - Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me), - ] -} - -/// Git activity authored by `me`, for "Continue where you left off". -/// -/// A comment on an unrelated kind is matched too, so results must be filtered -/// through [`is_git_activity`] before display. -pub fn authored_activity(me: PublicKey) -> Filter { - Filter::new().kinds(ACTIVITY_KINDS).author(me) + .kinds([Kind::EventDeletion, Kind::RequestToVanish]) + .since(Self::deletions_since()) + } } /// Whether a kind-1111 comment targets a git root, checked via its `K` tag. -fn is_git_comment(event: &Event) -> bool { +pub(crate) fn is_git_comment(event: &Event) -> bool { event.kind == Kind::Comment && tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind)) } /// Whether a status event references a git root, checked via its `k` tag. -fn is_git_status(event: &Event) -> bool { +pub(crate) fn is_git_status(event: &Event) -> bool { tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind)) } -/// Whether `event` is git activity worth showing in the activity list. -pub fn is_git_activity(event: &Event) -> bool { - match event.kind { - Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true, - Kind::Comment => is_git_comment(event), - Kind::GitStatusOpen - | Kind::GitStatusApplied - | Kind::GitStatusClosed - | Kind::GitStatusDraft => is_git_status(event), - _ => false, - } -} - -/// All repository announcements, for global discovery. -pub fn all_announcements() -> Filter { - Filter::new().kind(Kind::GitRepoAnnouncement) -} - -/// All repository state events, carrying each repository's refs and last push time. -pub fn all_states() -> Filter { - Filter::new().kind(Kind::RepoState) -} - -/// How far back deletion requests are fetched and stored. -const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400); - -/// `now` minus [`DELETIONS_LOOKBACK`]. -/// Quantized to whole days so identical filters hash the same. -/// -/// This lets the backend's sync dedup match identical filters. -fn deletions_since() -> Timestamp { - let now = Timestamp::now().as_secs(); - Timestamp::from_secs(now - now % 86_400) - DELETIONS_LOOKBACK -} - -/// All deletion-related events within [`DELETIONS_LOOKBACK`]. -/// These are NIP-09 kind `5` and NIP-62 kind `62`. -/// -/// Deletion requests must be known before any other event is shown. -pub fn deletions() -> Filter { - Filter::new() - .kinds([Kind::EventDeletion, Kind::RequestToVanish]) - .since(deletions_since()) -} - -/// Deletion events relevant to a single repository. -/// -/// Requests authored by the repository owner. -/// -/// Requests addressed to the repository coordinate via its `#a` tag. -pub fn deletions_for_repo(addr: &RepoAddr) -> Vec { - vec![ - Filter::new() - .kinds([Kind::EventDeletion, Kind::RequestToVanish]) - .author(addr.public_key), - Filter::new().kind(Kind::EventDeletion).coordinate(addr), - ] -} - #[cfg(test)] mod tests { use super::*; + use crate::GitEvent; fn keys(seed: u8) -> Keys { let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string(); @@ -252,14 +201,10 @@ mod tests { ); let on_note = signed(&keys(1), Kind::Comment, vec![kind_tag("K", Kind::TextNote)]); - assert!(is_git_activity(&on_git)); - assert!(is_git_activity(&on_repo)); - assert!(!is_git_activity(&on_note)); - assert!(!is_git_activity(&signed( - &keys(1), - Kind::Comment, - Vec::new() - ))); + assert!(on_git.is_git_activity()); + assert!(on_repo.is_git_activity()); + assert!(!on_note.is_git_activity()); + assert!(!signed(&keys(1), Kind::Comment, Vec::new()).is_git_activity()); } #[test] @@ -275,12 +220,8 @@ mod tests { vec![kind_tag("k", Kind::Metadata)], ); - assert!(is_git_activity(&status)); - assert!(!is_git_activity(&unrelated)); - assert!(!is_git_activity(&signed( - &keys(1), - Kind::GitStatusClosed, - Vec::new() - ))); + assert!(status.is_git_activity()); + assert!(!unrelated.is_git_activity()); + assert!(!signed(&keys(1), Kind::GitStatusClosed, Vec::new()).is_git_activity()); } } diff --git a/crates/signed_core/src/inbox.rs b/crates/signed_core/src/inbox.rs index 7889bba..6f9cbaa 100644 --- a/crates/signed_core/src/inbox.rs +++ b/crates/signed_core/src/inbox.rs @@ -4,7 +4,7 @@ use std::time::Duration; use nostr::prelude::*; use serde::{Deserialize, Serialize}; -use crate::{RepoAddr, activity_subject}; +use crate::{GitEvent, RepoAddr}; /// Window before `now` that an advanced cutoff retreats to. const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60); @@ -38,7 +38,7 @@ impl InboxItem { .as_ref() .or_else(|| self.own_events.first()) .or_else(|| self.events.first()) - .map(activity_subject) + .map(|event| event.activity_subject()) .unwrap_or_else(|| "Untitled".to_string()) } @@ -112,40 +112,148 @@ impl InboxItem { } } -/// Root issue, patch or pull request of a notification event. -/// -/// Returns `None` when the event is not git-related, or when its root is a -/// coordinate rather than an event. -/// -/// - issue (1621) / PR (1618): itself -/// - patch (1617): its `e` parent patch, else itself -/// - NIP-22 comment (1111): uppercase `E` root pointer -/// - PR update (1619): uppercase `E` -/// - statuses (1630-1633): NIP-10 root `e` -fn notification_root(event: &Event, lookup: &L) -> Option +/// Resolves thread roots by following parent pointers through known events. +pub struct ThreadResolver<'a, L: ?Sized> { + lookup: &'a L, +} + +impl<'a, L> ThreadResolver<'a, L> where - L: Fn(EventId) -> Option, + L: Fn(EventId) -> Option + ?Sized, { - match event.kind { - Kind::GitIssue | Kind::GitPullRequest => Some(event.id), - Kind::GitPatch => Some(match first_e_id(event) { - Some(parent) => resolve_thread_root(parent, lookup), - None => event.id, - }), - Kind::Comment => match nip22::extract_root(event) { - Some(CommentTarget::Event { id, .. }) => Some(resolve_thread_root(id, lookup)), + pub fn new(lookup: &'a L) -> Self { + Self { lookup } + } + + /// Root issue, patch or pull request of a notification event. + /// + /// Returns `None` when the event is not git-related, or when its root is a + /// coordinate rather than an event. + /// + /// - issue (1621) / PR (1618): itself + /// - patch (1617): its `e` parent patch, else itself + /// - NIP-22 comment (1111): uppercase `E` root pointer + /// - PR update (1619): uppercase `E` + /// - statuses (1630-1633): NIP-10 root `e` + pub fn notification_root(&self, event: &Event) -> Option { + match event.kind { + Kind::GitIssue | Kind::GitPullRequest => Some(event.id), + Kind::GitPatch => Some(match self.first_e_id(event) { + Some(parent) => self.resolve_thread_root(parent), + None => event.id, + }), + Kind::Comment => match nip22::extract_root(event) { + Some(CommentTarget::Event { id, .. }) => Some(self.resolve_thread_root(id)), + _ => None, + }, + Kind::GitPullRequestUpdate => self + .first_uppercase_e_id(event) + .map(|root| self.resolve_thread_root(root)), + Kind::GitStatusOpen + | Kind::GitStatusApplied + | Kind::GitStatusClosed + | Kind::GitStatusDraft => self + .nip10_root_id(event) + .map(|root| self.resolve_thread_root(root)), _ => None, - }, - Kind::GitPullRequestUpdate => { - first_uppercase_e_id(event).map(|root| resolve_thread_root(root, lookup)) } - Kind::GitStatusOpen - | Kind::GitStatusApplied - | Kind::GitStatusClosed - | Kind::GitStatusDraft => { - nip10_root_id(event).map(|root| resolve_thread_root(root, lookup)) + } + + /// Follow NIP-10/NIP-22 parent pointers until a root item is reached. + pub fn resolve_thread_root(&self, id: EventId) -> EventId { + let mut seen = HashSet::new(); + let mut root = id; + + loop { + if !seen.insert(root) { + return id; + } + + let Some(event) = (self.lookup)(root) else { + return root; + }; + + if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) { + return root; + } + + match self.parent_id(&event) { + Some(parent) => root = parent, + None => return root, + } } - _ => None, + } + + /// Parent of a thread event, mirroring gitworkshop's `getParentId`. + fn parent_id(&self, event: &Event) -> Option { + for marker in ["reply", "root"] { + if let Some(id) = event + .tags + .iter() + .find_map(|tag| self.e_tag_with_marker(tag, marker)) + { + return Some(id); + } + } + + if let Some(id) = event.tags.iter().find_map(|tag| { + if tag.kind() != "e" { + return None; + } + + let slice = tag.as_slice(); + let is_mention = slice.len() == 4 && slice[3] == "mention"; + + if is_mention { + return None; + } + + tag.content() + .and_then(|content| EventId::from_hex(content).ok()) + }) { + return Some(id); + } + + self.first_uppercase_e_id(event) + } + + /// NIP-10 root of an event: the `e` tag marked `root`, else the first `e` tag. + fn nip10_root_id(&self, event: &Event) -> Option { + event + .tags + .iter() + .find_map(|tag| self.e_tag_with_marker(tag, "root")) + .or_else(|| self.first_e_id(event)) + } + + /// First `e` tag id, in document order. + fn first_e_id(&self, event: &Event) -> Option { + self.first_tag_id(event, "e") + } + + /// First uppercase `E` tag id, in document order. + fn first_uppercase_e_id(&self, event: &Event) -> Option { + self.first_tag_id(event, "E") + } + + fn first_tag_id(&self, event: &Event, name: &str) -> Option { + event.tags.iter().find_map(|tag| { + if tag.kind() != name { + return None; + } + tag.content() + .and_then(|content| EventId::from_hex(content).ok()) + }) + } + + /// Event id from a four-element `e` tag carrying `marker`. + fn e_tag_with_marker(&self, tag: &Tag, marker: &str) -> Option { + let slice = tag.as_slice(); + if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker { + return None; + } + tag.content() + .and_then(|content| EventId::from_hex(content).ok()) } } @@ -162,12 +270,13 @@ where O: IntoIterator, L: Fn(EventId) -> Option, { + let resolver = ThreadResolver::new(lookup); let mut groups: HashMap> = HashMap::new(); for event in events { if event.pubkey == me { continue; } - let Some(root) = notification_root(&event, lookup) else { + let Some(root) = resolver.notification_root(&event) else { continue; }; groups.entry(root).or_default().push(event); @@ -175,7 +284,7 @@ where let mut own_groups: HashMap> = HashMap::new(); for event in own { - let root = notification_root(&event, lookup).unwrap_or(event.id); + let root = resolver.notification_root(&event).unwrap_or(event.id); own_groups.entry(root).or_default().push(event); } @@ -197,7 +306,8 @@ where root, address: root_event .as_ref() - .and_then(|event| event.tags.coordinates().next()), + .and_then(|event| event.tags.coordinates().next()) + .map(RepoAddr::from), root_event, events, own_events, @@ -321,103 +431,6 @@ fn prune_ids(ids: &mut HashSet, all: &[Event], cutoff: Timestamp) { ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff)); } -/// Follow NIP-10/NIP-22 parent pointers until a root item is reached. -fn resolve_thread_root(id: EventId, lookup: &impl Fn(EventId) -> Option) -> EventId { - let mut seen = HashSet::new(); - let mut root = id; - - loop { - if !seen.insert(root) { - return id; - } - - let Some(event) = lookup(root) else { - return root; - }; - - if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) { - return root; - } - - match parent_id(&event) { - Some(parent) => root = parent, - None => return root, - } - } -} - -/// Parent of a thread event, mirroring gitworkshop's `getParentId`. -fn parent_id(event: &Event) -> Option { - for marker in ["reply", "root"] { - if let Some(id) = event - .tags - .iter() - .find_map(|tag| e_tag_with_marker(tag, marker)) - { - return Some(id); - } - } - - if let Some(id) = event.tags.iter().find_map(|tag| { - if tag.kind() != "e" { - return None; - } - - let slice = tag.as_slice(); - let is_mention = slice.len() == 4 && slice[3] == "mention"; - - if is_mention { - return None; - } - - tag.content() - .and_then(|content| EventId::from_hex(content).ok()) - }) { - return Some(id); - } - - first_uppercase_e_id(event) -} - -/// NIP-10 root of an event: the `e` tag marked `root`, else the first `e` tag. -fn nip10_root_id(event: &Event) -> Option { - event - .tags - .iter() - .find_map(|tag| e_tag_with_marker(tag, "root")) - .or_else(|| first_e_id(event)) -} - -/// First `e` tag id, in document order. -fn first_e_id(event: &Event) -> Option { - first_tag_id(event, "e") -} - -/// First uppercase `E` tag id, in document order. -fn first_uppercase_e_id(event: &Event) -> Option { - first_tag_id(event, "E") -} - -fn first_tag_id(event: &Event, name: &str) -> Option { - event.tags.iter().find_map(|tag| { - if tag.kind() != name { - return None; - } - tag.content() - .and_then(|content| EventId::from_hex(content).ok()) - }) -} - -/// Event id from a four-element `e` tag carrying `marker`. -fn e_tag_with_marker(tag: &Tag, marker: &str) -> Option { - let slice = tag.as_slice(); - if tag.kind() != "e" || slice.len() != 4 || slice[3] != marker { - return None; - } - tag.content() - .and_then(|content| EventId::from_hex(content).ok()) -} - #[cfg(test)] mod tests { use super::*; @@ -480,7 +493,7 @@ mod tests { ); let events = [issue.clone(), comment.clone()]; assert_eq!( - notification_root(&comment, &lookup(&events)), + ThreadResolver::new(&lookup(&events)).notification_root(&comment), Some(issue.id) ); } @@ -491,7 +504,7 @@ mod tests { let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200); let events = [root_patch.clone(), child_patch.clone()]; assert_eq!( - notification_root(&child_patch, &lookup(&events)), + ThreadResolver::new(&lookup(&events)).notification_root(&child_patch), Some(root_patch.id) ); } @@ -506,7 +519,10 @@ mod tests { 200, ); let events = [issue.clone(), status.clone()]; - assert_eq!(notification_root(&status, &lookup(&events)), Some(issue.id)); + assert_eq!( + ThreadResolver::new(&lookup(&events)).notification_root(&status), + Some(issue.id) + ); } #[test] @@ -515,7 +531,10 @@ mod tests { let reply = signed(&keys(2), Kind::Comment, vec![uppercase_e_tag(&issue)], 200); let nested = signed(&keys(3), Kind::Comment, vec![uppercase_e_tag(&reply)], 300); let events = [issue.clone(), reply, nested.clone()]; - assert_eq!(notification_root(&nested, &lookup(&events)), Some(issue.id)); + assert_eq!( + ThreadResolver::new(&lookup(&events)).notification_root(&nested), + Some(issue.id) + ); } #[test] diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index 645d04c..328e3fc 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -6,12 +6,10 @@ pub mod model; pub mod state; pub mod status; -pub use addr::{RepoAddr, identifier_from_name, repo_addr}; +pub use addr::RepoAddr; pub use deletions::Deletions; -pub use inbox::{InboxItem, InboxReadState}; -pub use model::{ - Announcement, activity_subject, branch_name_of, clone_urls_of, current_commit_of, - fork_candidates, latest_update, merge_base_of, pull_request_patch, pull_request_patches, -}; -pub use state::{build_state, parse_state}; -pub use status::{RepoStatus, references_root, resolve_status}; +pub use filters::Filters; +pub use inbox::{InboxItem, InboxReadState, ThreadResolver}; +pub use model::{Announcement, GitEvent, PullRequest}; +pub use state::RepoState; +pub use status::RepoStatus; diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index 365864f..479e6a2 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use nostr::prelude::*; -use crate::{RepoAddr, repo_addr}; +use crate::RepoAddr; /// Parsed NIP-34 repository announcement, plain data ready for the UI. #[derive(Debug, Clone, PartialEq, Eq)] @@ -49,7 +49,8 @@ impl Upstream { let addr = coordinate .parse::() .ok() - .filter(|c| c.kind == Kind::GitRepoAnnouncement); + .filter(|coordinate| coordinate.kind == Kind::GitRepoAnnouncement) + .map(RepoAddr::from); Self { raw: raw.to_owned(), addr, @@ -64,216 +65,288 @@ impl Upstream { } } -/// Subject of a NIP-34 issue or pull request event. -/// Taken from the `subject` tag, else the first non-empty line of the content. -pub fn activity_subject(event: &Event) -> String { - let subject = event - .tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::Subject(subject)) => Some(subject), - _ => None, - }); +/// Tag accessors for NIP-34 git collaboration events. +pub trait GitEvent { + /// Subject of an issue or pull request event. + fn activity_subject(&self) -> String; - subject - .or_else(|| { - event - .content - .lines() - .map(str::trim) - .find(|line| !line.is_empty()) - .map(|value| value.to_string()) - }) - .unwrap_or("Untitled".to_string()) + /// The `c` tag, the tip of the proposed branch, as hex. + fn current_commit(&self) -> Option; + + /// The `merge-base` tag, the base commit a pull request diffs against. + fn merge_base(&self) -> Option; + + /// The `clone` tag, URLs the tip commit can be fetched from. + fn clone_urls(&self) -> Option>; + + /// The `branch-name` tag, the proposed branch's name. + fn branch_name(&self) -> Option; + + /// Whether the event is git activity worth showing in the activity list. + fn is_git_activity(&self) -> bool; + + /// Whether the event carries an `e`/`E` tag pointing at `root`. + fn references_root(&self, root: &EventId) -> bool; } -/// The patch set of a pull request. -/// -/// Returns an empty list when no patch event can be linked to the PR. -pub fn pull_request_patches<'a>( - pr: &Event, - patches: impl IntoIterator, -) -> Vec<&'a Event> { - let patches: Vec<&'a Event> = patches.into_iter().collect(); +impl GitEvent for Event { + fn activity_subject(&self) -> String { + let subject = self + .tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::Subject(subject)) => Some(subject), + _ => None, + }); - // The PR references its root patch via an `e` tag. - // Follow the NIP-10 reply chain forward from there. - // Each patch replies to the previous one, and among several replies the newest wins. - if let Some(root_id) = pr.tags.event_ids().next() - && let Some(root) = patches.iter().find(|patch| patch.id == root_id) - { - return forward_series(root, &patches); + subject + .or_else(|| { + self.content + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(|value| value.to_string()) + }) + .unwrap_or("Untitled".to_string()) } - // The PR has no `e` tag. - // The last patch of the set carries the tip commit in its `commit` or `r` tag. - // Walk the reply chain backward to the root. - let Some(tip) = current_commit_of(pr) else { - return Vec::new(); - }; - let Some(last) = patches - .iter() - .filter(|patch| patch_produces_commit(patch, &tip)) - .max_by_key(|patch| patch.created_at) - .copied() - else { - return Vec::new(); - }; - - let mut series = vec![last]; - loop { - let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else { - break; - }; - let Some(prev) = patches + fn current_commit(&self) -> Option { + self.tags .iter() - .find(|patch| patch.id == prev_id && !series.contains(patch)) + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()), + _ => None, + }) + } + + fn merge_base(&self) -> Option { + self.tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()), + _ => None, + }) + } + + fn clone_urls(&self) -> Option> { + self.tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::Clone(urls)) => Some(urls), + _ => None, + }) + } + + fn branch_name(&self) -> Option { + self.tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::BranchName(name)) => Some(name), + _ => None, + }) + } + + fn is_git_activity(&self) -> bool { + match self.kind { + Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true, + Kind::Comment => crate::filters::is_git_comment(self), + Kind::GitStatusOpen + | Kind::GitStatusApplied + | Kind::GitStatusClosed + | Kind::GitStatusDraft => crate::filters::is_git_status(self), + _ => false, + } + } + + fn references_root(&self, root: &EventId) -> bool { + let root = root.to_hex(); + self.tags + .iter() + .any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str())) + } +} + +impl GitEvent for &T { + fn activity_subject(&self) -> String { + (*self).activity_subject() + } + + fn current_commit(&self) -> Option { + (*self).current_commit() + } + + fn merge_base(&self) -> Option { + (*self).merge_base() + } + + fn clone_urls(&self) -> Option> { + (*self).clone_urls() + } + + fn branch_name(&self) -> Option { + (*self).branch_name() + } + + fn is_git_activity(&self) -> bool { + (*self).is_git_activity() + } + + fn references_root(&self, root: &EventId) -> bool { + (*self).references_root(root) + } +} + +/// A NIP-34 pull request root event, with its patch set and update history. +pub struct PullRequest<'a>(pub &'a Event); + +impl<'a> PullRequest<'a> { + pub fn new(event: &'a Event) -> Self { + Self(event) + } + + /// The patch set of the pull request. + /// + /// Returns an empty list when no patch event can be linked to the PR. + pub fn patches(&self, patches: impl IntoIterator) -> Vec<&'a Event> { + let pr = self.0; + let patches: Vec<&'a Event> = patches.into_iter().collect(); + + // The PR references its root patch via an `e` tag. + if let Some(root_id) = pr.tags.event_ids().next() + && let Some(root) = patches.iter().find(|patch| patch.id == root_id) + { + return Self::forward_series(root, &patches); + } + + // The PR has no `e` tag. + // The last patch of the set carries the tip commit in its `commit` or `r` tag. + let Some(tip) = pr.current_commit() else { + return Vec::new(); + }; + let Some(last) = patches + .iter() + .filter(|patch| Self::patch_produces_commit(patch, &tip)) + .max_by_key(|patch| patch.created_at) .copied() else { - break; + return Vec::new(); }; - series.push(prev); - } - series.reverse(); - series -} -pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator) -> String { - let patches: Vec<&'a Event> = patches.into_iter().collect(); - let series = pull_request_patches(pr, patches.iter().copied()); - if series.is_empty() { - return pr.content.clone(); - } - series - .iter() - .map(|patch| patch.content.as_str()) - .collect::>() - .join("\n") -} - -/// The chain of patches replying to `root` via NIP-10 `e` tags, oldest first. -fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> { - let mut series = vec![root]; - loop { - let next = patches - .iter() - .filter(|patch| !series.contains(patch)) - .filter(|patch| { - patch - .tags - .event_ids() - .any(|id| id == series.last().unwrap().id) - }) - .max_by_key(|patch| patch.created_at); - let Some(next) = next else { - break; - }; - series.push(next); - } - series -} - -/// The `c` tag of an event, the tip of the proposed branch, as hex. -pub fn current_commit_of(event: &Event) -> Option { - event - .tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()), - _ => None, - }) -} - -/// The `merge-base` tag of an event, the base commit a pull request diffs against. -pub fn merge_base_of(event: &Event) -> Option { - event - .tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()), - _ => None, - }) -} - -/// The `clone` tag of an event, URLs the tip commit can be fetched from. -pub fn clone_urls_of(event: &Event) -> Option> { - event - .tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::Clone(urls)) => Some(urls), - _ => None, - }) -} - -/// The `branch-name` tag of an event, the proposed branch's name. -pub fn branch_name_of(event: &Event) -> Option { - event - .tags - .iter() - .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::BranchName(name)) => Some(name), - _ => None, - }) -} - -/// The newest `GitPullRequestUpdate` revising `root`, from the root's own author. -/// -/// A pull request's tip is only mutable by its author, per NIP-34; updates -/// from anyone else are ignored even if they are newer. -pub fn latest_update<'a>( - events: impl Iterator, - root: &Event, -) -> Option<&'a Event> { - let root_hex = root.id.to_hex(); - events - .filter(|e| e.kind == Kind::GitPullRequestUpdate) - .filter(|e| e.pubkey == root.pubkey) - .filter(|e| { - e.tags + let mut series = vec![last]; + loop { + let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else { + break; + }; + let Some(prev) = patches .iter() - .any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str())) - }) - .max_by_key(|e| e.created_at) -} - -/// The announced forks of `base` a new pull request compare can be built from. -/// -/// The user's own forks are listed first. -pub fn fork_candidates<'a>( - announcements: &'a [Announcement], - base: &RepoAddr, - base_euc: Option<&str>, - user: Option, -) -> Vec<&'a Announcement> { - let (mut own, mut others) = (Vec::new(), Vec::new()); - for announcement in announcements { - if announcement.clone.is_empty() || !announcement.is_fork_of(base, base_euc) { - continue; - } - if Some(announcement.owner) == user { - own.push(announcement); - } else { - others.push(announcement); + .find(|patch| patch.id == prev_id && !series.contains(patch)) + .copied() + else { + break; + }; + series.push(prev); } + series.reverse(); + series } - own.into_iter().chain(others).collect() -} -/// Whether `patch` produces `commit`, found via its `commit` or `r` tag. -/// -/// It lets clients find existing patches for a specific commit. -fn patch_produces_commit(patch: &Event, commit: &str) -> bool { - patch - .tags - .iter() - .any(|tag| match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit, - _ => false, - }) + /// The patch set of the pull request joined into one diff text. + /// + /// Falls back to the root event's content when no patch set is found. + pub fn patch(&self, patches: impl IntoIterator) -> String { + let pr = self.0; + let patches: Vec<&'a Event> = patches.into_iter().collect(); + let series = self.patches(patches.iter().copied()); + if series.is_empty() { + return pr.content.clone(); + } + series + .iter() + .map(|patch| patch.content.as_str()) + .collect::>() + .join("\n") + } + + /// The newest `GitPullRequestUpdate` revising `root`, from the root's own author. + /// + /// A pull request's tip is only mutable by its author per NIP-34, updates + /// from anyone else are ignored even if they are newer. + pub fn latest_update( + events: impl Iterator, + root: &Event, + ) -> Option<&'a Event> { + let root_hex = root.id.to_hex(); + events + .filter(|e| e.kind == Kind::GitPullRequestUpdate) + .filter(|e| e.pubkey == root.pubkey) + .filter(|e| { + e.tags + .iter() + .any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str())) + }) + .max_by_key(|e| e.created_at) + } + + /// The chain of patches replying to `root` via NIP-10 `e` tags, oldest first. + fn forward_series(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> { + let mut series = vec![root]; + loop { + let next = patches + .iter() + .filter(|patch| !series.contains(patch)) + .filter(|patch| { + patch + .tags + .event_ids() + .any(|id| id == series.last().unwrap().id) + }) + .max_by_key(|patch| patch.created_at); + let Some(next) = next else { + break; + }; + series.push(next); + } + series + } + + /// Whether `patch` produces `commit`, found via its `commit` or `r` tag. + /// + /// It lets clients find existing patches for a specific commit. + fn patch_produces_commit(patch: &Event, commit: &str) -> bool { + patch + .tags + .iter() + .any(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit, + _ => false, + }) + } } impl Announcement { + /// The announced forks of `base` a new pull request compare can be built from. + /// + /// The user's own forks are listed first. + pub fn forks_in<'a>( + announcements: &'a [Announcement], + base: &RepoAddr, + base_euc: Option<&str>, + user: Option, + ) -> Vec<&'a Announcement> { + let (mut own, mut others) = (Vec::new(), Vec::new()); + for announcement in announcements { + if announcement.clone.is_empty() || !announcement.is_fork_of(base, base_euc) { + continue; + } + if Some(announcement.owner) == user { + own.push(announcement); + } else { + others.push(announcement); + } + } + own.into_iter().chain(others).collect() + } + /// Parse a kind `30617` event. /// /// Returns `None` when the kind is wrong or the `d` tag is missing. @@ -337,7 +410,7 @@ impl Announcement { } pub fn addr(&self) -> RepoAddr { - repo_addr(self.owner, self.id.clone()) + RepoAddr::new(self.owner, self.id.clone()) } /// The name of the repository, or a default if none is provided. @@ -499,7 +572,7 @@ mod tests { // The raw value keeps the `|git-url` suffix. assert_eq!( upstream.addr, - Some(crate::repo_addr( + Some(RepoAddr::new( PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"), "upstream" )) @@ -517,7 +590,7 @@ mod tests { #[test] fn is_fork_of_matches_the_u_tag_coordinate() { // The base repository, announced by the `u` tag's owner. - let base = crate::repo_addr( + let base = RepoAddr::new( PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"), "upstream", ); @@ -559,7 +632,7 @@ mod tests { fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() { // A permanent fork re-announces its EUC, the first commit after the fork. // Only the `u` tag still relates it to the base. - let base = crate::repo_addr( + let base = RepoAddr::new( PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"), "upstream", ); @@ -632,11 +705,11 @@ mod tests { let pr = pr_event("description", vec![Tag::event(root.id)]); assert_eq!( - pull_request_patch(&pr, [&root, &second]), + PullRequest::new(&pr).patch([&root, &second]), "patch-one\npatch-two" ); assert_eq!( - pull_request_patches(&pr, [&root, &second]), + PullRequest::new(&pr).patches([&root, &second]), vec![&root, &second] ); } @@ -648,7 +721,7 @@ mod tests { let third = patch_event("patch-three", vec![Tag::event(second.id)], 300); let pr = pr_event("description", vec![Tag::event(root.id)]); - let series = pull_request_patches(&pr, [&third, &root, &second]); + let series = PullRequest::new(&pr).patches([&third, &root, &second]); assert_eq!( series .iter() @@ -677,7 +750,7 @@ mod tests { vec![Tag::parse(["c", tip]).expect("valid tag")], ); - let series = pull_request_patches(&pr, [&root, &last]); + let series = PullRequest::new(&pr).patches([&root, &last]); assert_eq!( series .iter() @@ -729,7 +802,7 @@ mod tests { ); let events = [unrelated, revision(200), root.clone(), revision(300)]; - let latest = latest_update(events.iter(), &root).expect("an update"); + let latest = PullRequest::latest_update(events.iter(), &root).expect("an update"); assert_eq!(latest.created_at.as_secs(), 300); assert_eq!(latest.kind, Kind::GitPullRequestUpdate); @@ -751,7 +824,7 @@ mod tests { // The tip of a PR is only mutable by its author. // A newer update from anyone else must not win. - assert!(latest_update([&stranger, &root].into_iter(), &root).is_none()); + assert!(PullRequest::latest_update([&stranger, &root].into_iter(), &root).is_none()); } const OWNER_KEYS: [&str; 3] = [ @@ -784,7 +857,7 @@ mod tests { let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; let clone = "https://grasp.example/npub1x/my-fork.git"; - let base_addr = crate::repo_addr( + let base_addr = RepoAddr::new( PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"), "upstream", ); @@ -819,7 +892,7 @@ mod tests { ]; let user = PublicKey::from_hex(OWNER_KEYS[1]).expect("pubkey"); - let forks = fork_candidates(&all, &base_addr, Some(euc), Some(user)); + let forks = Announcement::forks_in(&all, &base_addr, Some(euc), Some(user)); let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect(); assert_eq!(ids, vec!["my-fork", "their-fork"]); @@ -829,7 +902,7 @@ mod tests { fn fork_candidates_excludes_base_unrelated_and_unfetchable() { let euc = "aa231c4c6a5777dc89b42207b499891a344add5c"; let base_owner = PublicKey::from_hex(OWNER_KEYS[0]).expect("pubkey"); - let base_addr = crate::repo_addr(base_owner, "upstream"); + let base_addr = RepoAddr::new(base_owner, "upstream"); let mut all = vec![ owned_announcements(0, &[&["d", "upstream"], &["r", euc, "euc"]]) @@ -859,7 +932,7 @@ mod tests { .unwrap(), ]; - let forks = fork_candidates(&all, &base_addr, Some(euc), Some(base_owner)); + let forks = Announcement::forks_in(&all, &base_addr, Some(euc), Some(base_owner)); assert_eq!(forks.len(), 1); assert_eq!(forks[0].id, "mirror"); @@ -876,7 +949,7 @@ mod tests { .pop() .unwrap(), ); - let forks = fork_candidates(&all, &base_addr, None, Some(base_owner)); + let forks = Announcement::forks_in(&all, &base_addr, None, Some(base_owner)); let ids: Vec<&str> = forks.iter().map(|a| a.id.as_str()).collect(); assert_eq!(ids, vec!["u-fork"]); } diff --git a/crates/signed_core/src/state.rs b/crates/signed_core/src/state.rs index 1e36932..fcf488f 100644 --- a/crates/signed_core/src/state.rs +++ b/crates/signed_core/src/state.rs @@ -1,44 +1,52 @@ use nostr::prelude::*; -/// Build a kind `30618` repository state event from refs and HEAD, -/// it is 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) +/// Refs and HEAD parsed from a kind `30618` repository state event. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RepoState { + /// `(refname, commit-id)` pairs. + pub refs: Vec<(String, String)>, + /// Branch pointed to by the `HEAD` tag, if any. + pub head: Option, } -/// Parse a kind `30618` repository state event into refs and HEAD. -/// -/// `refs` are `(refname, commit-id)` pairs. -/// `head` is the branch pointed to by the `HEAD` tag, if any. -pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option) { - let mut refs = Vec::new(); - let mut head = None; - - for tag in event.tags.iter() { - match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::Head(branch)) => head = Some(branch), - Ok(Nip34Tag::RefHead { branch, commit }) => { - refs.push((format!("refs/heads/{branch}"), commit.to_string())); - } - Ok(Nip34Tag::RefTag { name, commit }) => { - refs.push((format!("refs/tags/{name}"), commit.to_string())); - } - _ => {} +impl RepoState { + /// Build a kind `30618` repository state event from refs and HEAD, + /// it is published as `ref: refs/heads/`. + /// + /// The `d` tag matches the repository id. + pub fn build(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) } - (refs, head) + /// Parse a kind `30618` repository state event into refs and HEAD. + pub fn parse(event: &Event) -> Self { + let mut refs = Vec::new(); + let mut head = None; + + for tag in event.tags.iter() { + match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::Head(branch)) => head = Some(branch), + Ok(Nip34Tag::RefHead { branch, commit }) => { + refs.push((format!("refs/heads/{branch}"), commit.to_string())); + } + Ok(Nip34Tag::RefTag { name, commit }) => { + refs.push((format!("refs/tags/{name}"), commit.to_string())); + } + _ => {} + } + } + + Self { refs, head } + } } #[cfg(test)] @@ -76,11 +84,11 @@ mod tests { &["refs/tags/v1.0", COMMIT_A], ]); - let (refs, head) = parse_state(&event); + let state = RepoState::parse(&event); - assert_eq!(head.as_deref(), Some("main")); + assert_eq!(state.head.as_deref(), Some("main")); assert_eq!( - refs, + state.refs, vec![ ("refs/heads/main".to_owned(), COMMIT_A.to_owned()), ("refs/heads/dev".to_owned(), COMMIT_B.to_owned()), @@ -90,22 +98,22 @@ mod tests { } #[test] - fn build_state_round_trips_through_parse() { + fn build_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")) + let event = RepoState::build("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")); + let state = RepoState::parse(&event); + assert_eq!(state.refs, refs); + assert_eq!(state.head.as_deref(), Some("main")); } } diff --git a/crates/signed_core/src/status.rs b/crates/signed_core/src/status.rs index 4ce9e57..1537465 100644 --- a/crates/signed_core/src/status.rs +++ b/crates/signed_core/src/status.rs @@ -28,6 +28,26 @@ impl RepoStatus { Self::Draft => Kind::GitStatusDraft, } } + + /// Resolve the status of a root event per NIP-34. + /// + /// Defaults to [`RepoStatus::Open`]. + pub fn resolve<'a, I>( + status_events: I, + root_author: &PublicKey, + maintainers: &[PublicKey], + ) -> Self + where + I: IntoIterator, + { + status_events + .into_iter() + .filter(|e| RepoStatus::from_kind(e.kind).is_some()) + .filter(|e| &e.pubkey == root_author || maintainers.contains(&e.pubkey)) + .max_by_key(|e| e.created_at) + .and_then(|e| RepoStatus::from_kind(e.kind)) + .unwrap_or(RepoStatus::Open) + } } /// NIP-10 and NIP-34 use the lowercase `e` tag. @@ -41,26 +61,6 @@ pub fn references_root(event: &Event, root: &EventId) -> bool { .any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str())) } -/// Resolve the status of a root event per NIP-34. -/// -/// Defaults to [`RepoStatus::Open`]. -pub fn resolve_status<'a, I>( - status_events: I, - root_author: &PublicKey, - maintainers: &[PublicKey], -) -> RepoStatus -where - I: IntoIterator, -{ - status_events - .into_iter() - .filter(|e| RepoStatus::from_kind(e.kind).is_some()) - .filter(|e| &e.pubkey == root_author || maintainers.contains(&e.pubkey)) - .max_by_key(|e| e.created_at) - .and_then(|e| RepoStatus::from_kind(e.kind)) - .unwrap_or(RepoStatus::Open) -} - #[cfg(test)] mod tests { use super::*; @@ -115,7 +115,7 @@ mod tests { ]; assert_eq!( - resolve_status( + RepoStatus::resolve( statuses.iter(), &owner.public_key(), &[maintainer.public_key()] @@ -140,7 +140,7 @@ mod tests { ]; assert_eq!( - resolve_status( + RepoStatus::resolve( statuses.iter(), &owner.public_key(), &[maintainer.public_key()] diff --git a/crates/signed_git/src/cache.rs b/crates/signed_git/src/cache.rs index 2ae2e78..fdfa932 100644 --- a/crates/signed_git/src/cache.rs +++ b/crates/signed_git/src/cache.rs @@ -22,8 +22,8 @@ impl GitCache { pub fn repo_path(&self, addr: &RepoAddr) -> PathBuf { self.root - .join(addr.public_key.to_hex()) - .join(sanitize_path_component(&addr.identifier)) + .join(addr.public_key().to_hex()) + .join(sanitize_path_component(addr.identifier())) } pub fn open(&self, addr: &RepoAddr) -> Result> { diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 589fbc5..2929ac3 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -11,7 +11,7 @@ use nostr::nips::nip19::Nip19Coordinate; use nostr_connect::prelude::*; use nostr_sdk::client::SyncSummary; use nostr_sdk::prelude::*; -use signed_core::{Announcement, RepoAddr, build_state, filters, identifier_from_name, repo_addr}; +use signed_core::{Announcement, Filters, RepoAddr, RepoState, filters}; use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update}; use crate::git_store::repo_mirror_path; @@ -388,7 +388,7 @@ impl Backend { return Task::ready(Err(anyhow!("Sign in to create a repository"))); }; - let repo_id = identifier_from_name(&name); + let repo_id = RepoAddr::identifier_from_name(&name); if repo_id.is_empty() || repo_id.len() > 100 { return Task::ready(Err(anyhow!( @@ -563,7 +563,7 @@ impl Backend { return Task::ready(Err(anyhow!("Sign in to publish a repository"))); }; - let repo_id = identifier_from_name(&name); + let repo_id = RepoAddr::identifier_from_name(&name); if repo_id.is_empty() || repo_id.len() > 100 { return Task::ready(Err(anyhow!( @@ -694,8 +694,8 @@ impl Backend { // Record the ngit-compatible `nostr.repo` marker, // so the next scan detects the repository instead of offering to publish it again. - let coordinate = repo_addr(event.pubkey, repo_id.clone()); - match Nip19Coordinate::new(coordinate, servers.clone()).to_bech32() { + let coordinate = RepoAddr::new(event.pubkey, repo_id.clone()); + match Nip19Coordinate::new(coordinate.into(), servers.clone()).to_bech32() { Ok(naddr) => { let path = path.clone(); cx.background_spawn(async move { @@ -876,7 +876,7 @@ impl Backend { let Some(public_key) = self.current_user else { return Task::ready(Err(anyhow!("Sign in to delete a repository"))); }; - if public_key != addr.public_key { + if public_key != addr.public_key() { return Task::ready(Err(anyhow!("Only the repository owner can delete it"))); } @@ -888,9 +888,9 @@ impl Backend { let db = client.database(); let mut events = Vec::new(); for filter in [ - filters::announcement(&addr), - filters::state(&addr), - filters::activity(&addr), + addr.announcement_filter(), + addr.state_filter(), + addr.activity_filter(), ] { events.extend(db.query(filter).await?); } @@ -914,7 +914,7 @@ impl Backend { let result = async { sync_bootstrap_only( &client, - filters::grasp_list(public_key), + Filters::grasp_list(public_key), SyncOptions::default(), ) .await?; @@ -961,8 +961,8 @@ impl Backend { let me = self.current_user; if let Some(me) = me { - self.subscribe_bootstrap(filters::notifications(me), cx); - self.subscribe_bootstrap(vec![filters::authored_activity(me)], cx); + self.subscribe_bootstrap(Filters::notifications(me), cx); + self.subscribe_bootstrap(vec![Filters::authored_activity(me)], cx); let relays: HashSet = RepoListStore::global(cx) .read(cx) @@ -973,8 +973,8 @@ impl Backend { if !relays.is_empty() { let relays: Vec = relays.into_iter().collect(); - self.connect_repo_relays(relays.clone(), filters::notifications(me), cx); - self.connect_repo_relays(relays, vec![filters::authored_activity(me)], cx); + self.connect_repo_relays(relays.clone(), Filters::notifications(me), cx); + self.connect_repo_relays(relays, vec![Filters::authored_activity(me)], cx); } } @@ -1357,7 +1357,7 @@ pub async fn user_grasp_list_servers( ) -> Result, Error> { let events: Vec = client .database() - .query(filters::grasp_list(user)) + .query(Filters::grasp_list(user)) .await? .into_iter() .collect(); @@ -1517,7 +1517,7 @@ async fn sign_state_event( last_created_at + 1 }; - let event = build_state(repo_id, refs, head) + let event = RepoState::build(repo_id, refs, head) .custom_created_at(Timestamp::from_secs(created_at)) .finalize_async(signer) .await diff --git a/crates/signed_state/src/inbox.rs b/crates/signed_state/src/inbox.rs index 52839cf..fa557be 100644 --- a/crates/signed_state/src/inbox.rs +++ b/crates/signed_state/src/inbox.rs @@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet}; use anyhow::Error; use gpui::{AppContext, Context, Task}; use nostr_sdk::prelude::*; -use signed_core::{Deletions, InboxItem, InboxReadState, filters, inbox}; +use signed_core::{Deletions, Filters, InboxItem, InboxReadState, GitEvent, inbox}; use crate::backend::Backend; @@ -97,7 +97,7 @@ pub async fn query_inbox( me: PublicKey, state: &InboxReadState, ) -> Result<(Vec, usize), Error> { - let deletion_events = client.database().query(filters::deletions()).await?; + let deletion_events = client.database().query(Filters::deletions()).await?; let deletions = Deletions::from_events(deletion_events); let (notification_events, mut by_id) = fetch_notifications(client, me, &deletions).await?; @@ -105,10 +105,10 @@ pub async fn query_inbox( let mut activity = Vec::new(); for event in client .database() - .query(filters::authored_activity(me)) + .query(Filters::authored_activity(me)) .await? { - if deletions.is_deleted(&event) || !filters::is_git_activity(&event) { + if deletions.is_deleted(&event) || !event.is_git_activity() { continue; } by_id.entry(event.id).or_insert_with(|| event.clone()); @@ -169,7 +169,7 @@ async fn fetch_notifications( let mut notifications: Vec = Vec::new(); let mut by_id: HashMap = HashMap::new(); - for filter in filters::notifications(me) { + for filter in Filters::notifications(me) { for event in client.database().query(filter).await? { if deletions.is_deleted(&event) { continue; diff --git a/crates/signed_state/src/local_repos.rs b/crates/signed_state/src/local_repos.rs index 19bfbdd..c1ad4fb 100644 --- a/crates/signed_state/src/local_repos.rs +++ b/crates/signed_state/src/local_repos.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use anyhow::Error; use gpui::{App, AppContext, Context, Entity, Global, SharedString, Task}; -use signed_core::{Announcement, RepoAddr, repo_addr}; +use signed_core::{Announcement, RepoAddr}; use signed_git::{LocalRepo, Nip34Binding, find_git_repos}; struct GlobalLocalReposStore(Entity); @@ -113,7 +113,7 @@ pub fn local_repo_addr(repo: &LocalRepo) -> Option { let owner = binding.owner?; let identifier = binding.identifier.as_deref()?; - Some(repo_addr(owner, identifier)) + Some(RepoAddr::new(owner, identifier)) } /// A scanned repository resolved against the known announcements. diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index bd2a52e..b1e09bf 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -9,7 +9,8 @@ use nostr::event::IntoEventBuilder; use nostr_sdk::prelude::*; use settings::{EventFetchingStrategy, SettingsStore}; use signed_core::{ - Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patches, + Announcement, Deletions, Filters, GitEvent, PullRequest, RepoAddr, RepoState, RepoStatus, + filters, }; use signed_git::Nip34Binding; use signed_nostr::UniversalSigner; @@ -210,8 +211,8 @@ impl RepoStore { let deletion = update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish; - let coordinate = update.coordinate.as_ref() == Some(addr); - let author = update.author == addr.public_key; + let coordinate = update.coordinate.as_ref() == Some(addr.coordinate()); + let author = update.author == addr.public_key(); let authored = (update.kind == Kind::GitRepoAnnouncement || update.kind == Kind::RepoState) @@ -251,12 +252,12 @@ impl RepoStore { let mut filters = vec![ Filter::new() .kinds([Kind::GitRepoAnnouncement, Kind::RepoState]) - .author(addr.public_key) - .identifier(addr.identifier.clone()), - filters::activity(addr), + .author(addr.public_key()) + .identifier(addr.identifier()), + addr.activity_filter(), ]; // Deletion requests, NIP-09/62, must be known before any event is shown. - filters.extend(filters::deletions_for_repo(addr)); + filters.extend(addr.deletion_filters()); filters } @@ -290,8 +291,8 @@ impl RepoStore { let mut pubkeys = maintainers.to_vec(); // NIP-34 events tag the announcement author, // which may not be a maintainer for subordinate forks. - if !pubkeys.contains(&addr.public_key) { - pubkeys.push(addr.public_key); + if !pubkeys.contains(&addr.public_key()) { + pubkeys.push(addr.public_key()); } vec![ @@ -299,16 +300,16 @@ impl RepoStore { Filter::new() .kinds([Kind::GitRepoAnnouncement, Kind::RepoState]) .authors(pubkeys.clone()) - .identifier(addr.identifier.clone()), + .identifier(addr.identifier()), // Activity tagging a maintainer, resolved to their read relays. Filter::new() .kinds(filters::ACTIVITY_KINDS) - .coordinate(addr) + .coordinate(addr.coordinate()) .pubkeys(pubkeys.clone()), // Activity authored by a maintainer, resolved to their write relays. Filter::new() .kinds(filters::ACTIVITY_KINDS) - .coordinate(addr) + .coordinate(addr.coordinate()) .authors(pubkeys.clone()), // Deletions authored by a maintainer. Filter::new() @@ -377,10 +378,10 @@ impl RepoStore { let work = cx.background_spawn(async move { let (announcements, states, activity, deletion_events) = async { let db = client.database(); - let announcements = db.query(filters::announcement(&addr)).await?; - let states = db.query(filters::state(&addr)).await?; - let activity = db.query(filters::activity(&addr)).await?; - let deletion_events = db.query(filters::deletions()).await?; + let announcements = db.query(addr.announcement_filter()).await?; + let states = db.query(addr.state_filter()).await?; + let activity = db.query(addr.activity_filter()).await?; + let deletion_events = db.query(Filters::deletions()).await?; Ok::<_, Error>((announcements, states, activity, deletion_events)) } @@ -397,7 +398,7 @@ impl RepoStore { .and_then(Announcement::from_event); let all_states = states.into_iter().filter(|e| !deletions.is_deleted(e)); - let state = utils::latest(all_states).map(|state| parse_state(&state)); + let state = utils::latest(all_states).map(|state| RepoState::parse(&state)); let (mut issues, mut patches, mut pull_requests, mut statuses, mut comments) = (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()); @@ -425,7 +426,7 @@ impl RepoStore { .chain(&pull_requests) .map(|e| e.id); - for filter in filters::comments_for(roots) { + for filter in Filters::comments_for(roots) { for event in db.query(filter).await? { if seen_comments.insert(event.id) { comments.push(event); @@ -443,7 +444,7 @@ impl RepoStore { .map(|e| e.id); for root in roots { - for event in db.query(filters::statuses_for([root])).await? { + for event in db.query(Filters::statuses_for([root])).await? { if seen_statuses.insert(event.id) { statuses.push(event); } @@ -516,7 +517,7 @@ impl RepoStore { let head_changed = state .as_ref() - .is_some_and(|(_, head)| this.head.as_deref() != head.as_deref()); + .is_some_and(|state| this.head.as_deref() != state.head.as_deref()); let changed = first_pass || (!keep_hint && this.announcement != announcement) @@ -549,8 +550,8 @@ impl RepoStore { this.sync_maintainer_relays(&maintainers, cx); - if let Some((_, head)) = state { - this.head = head; + if let Some(state) = state { + this.head = state.head; } this.issues = issues; @@ -579,8 +580,8 @@ impl RepoStore { if !new_roots.is_empty() { this.root_fetches.extend(new_roots.iter().copied()); - let mut root_filters = filters::comments_for(new_roots.clone()); - root_filters.push(filters::statuses_for(new_roots.iter().copied())); + let mut root_filters = Filters::comments_for(new_roots.clone()); + root_filters.push(Filters::statuses_for(new_roots.iter().copied())); let announced: Vec = this.repo_relays.iter().cloned().collect(); let backend = Backend::global(cx); @@ -621,7 +622,7 @@ impl RepoStore { pub fn is_author(&self, user: &PublicKey) -> bool { self.addr .as_ref() - .is_some_and(|addr| &addr.public_key == user) + .is_some_and(|addr| addr.public_key() == *user) } pub fn open_issue(&mut self, subject: Option, content: String, cx: &mut Context) { @@ -631,7 +632,7 @@ impl RepoStore { }; let builder = GitIssue { - repository: addr, + repository: addr.into(), content, subject, labels: Vec::new(), @@ -644,7 +645,7 @@ impl RepoStore { pub fn comments_of(&self, root: &EventId) -> impl Iterator { self.comments .iter() - .filter(move |e| signed_core::references_root(e, root)) + .filter(move |e| e.references_root(root)) } /// Comment on a root event, an issue or PR, per NIP-34, kind 1111. @@ -745,9 +746,9 @@ impl RepoStore { // The author's npub names their GRASP-06 namespace, `/prs/...`. let author_npub = user.to_bech32().unwrap(); - let owner = addr.public_key; + let owner = addr.public_key(); let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); - let repo_id = addr.identifier.clone(); + let repo_id = addr.identifier().to_owned(); let base_npub = owner.to_bech32().unwrap(); let push_relays = self .announcement @@ -857,7 +858,7 @@ impl RepoStore { let clone = pr_clone_urls(prs_urls, base_clone); let builder = GitPullRequest { - repository: addr.clone(), + repository: addr.clone().into(), content: description, subject, labels: Vec::new(), @@ -1069,7 +1070,8 @@ impl RepoStore { // The first revision patch replies to the original root patch, NIP-34. // Use the PR's `e` tag, or the oldest patch of the linked set if the PR has none. let root_patch_id = root.tags.event_ids().next().or_else(|| { - pull_request_patches(root, self.patches.iter()) + PullRequest::new(root) + .patches(self.patches.iter()) .first() .map(|p| p.id) }); @@ -1079,7 +1081,7 @@ impl RepoStore { return; }; - let owner = addr.public_key; + let owner = addr.public_key(); let euc = self.announcement.as_ref().and_then(|a| a.euc.clone()); let root = root.clone(); @@ -1110,7 +1112,7 @@ impl RepoStore { let builder = { let builder = GitPullRequestUpdate { - repository: addr.clone(), + repository: addr.clone().into(), pull_request_event: root.id, pull_request_author: root.pubkey, current_commit, @@ -1182,9 +1184,9 @@ impl RepoStore { let builder = EventBuilder::new(status.kind(), "").tags([ root_ref, - Tag::public_key(addr.public_key), + Tag::public_key(addr.public_key()), Tag::public_key(root.pubkey), - Tag::coordinate(addr, None), + Tag::coordinate(addr.clone().into(), None), ]); self.publish(builder, cx); @@ -1479,7 +1481,7 @@ fn resolve_statuses( .map(|root| { let events = by_root.get(&root.id).map(Vec::as_slice).unwrap_or(&[]); let status = - signed_core::resolve_status(events.iter().copied(), &root.pubkey, maintainers); + signed_core::RepoStatus::resolve(events.iter().copied(), &root.pubkey, maintainers); (root.id, status) }) .collect() @@ -1518,7 +1520,10 @@ async fn publish_patch_series( )); }; - let mut tags = vec![Tag::coordinate(addr.clone(), None), Tag::public_key(owner)]; + let mut tags = vec![ + Tag::coordinate(addr.clone().into(), None), + Tag::public_key(owner), + ]; if ix == 0 { if let Ok(tag) = Tag::parse(["t", first_marker]) { @@ -1586,11 +1591,13 @@ fn comment_builder( CommentBuilder::new(content, parent_target) .root(root_target) .into_event_builder() - .tags([Tag::coordinate(addr.clone(), None)]) + .tags([Tag::coordinate(addr.clone().into(), None)]) } #[cfg(test)] mod tests { + use signed_core::GitEvent; + use std::collections::HashSet; use nostr_sdk::prelude::*; @@ -1612,7 +1619,7 @@ mod tests { 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 addr = signed_core::RepoAddr::new(root.pubkey, "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()) @@ -1639,14 +1646,14 @@ mod tests { 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)); + assert!(event.references_root(&root.id)); } #[test] fn maintainer_filters_name_owner_and_maintainers() { let owner = Keys::generate().public_key(); let maintainer = Keys::generate().public_key(); - let addr = Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier("my-repo"); + let addr = signed_core::RepoAddr::new(owner, "my-repo"); // The owner is not among the maintainers, as on a subordinate fork. let filters = RepoStore::maintainer_filters(&addr, &[maintainer]); diff --git a/crates/signed_state/src/repos.rs b/crates/signed_state/src/repos.rs index be79c6d..469974b 100644 --- a/crates/signed_state/src/repos.rs +++ b/crates/signed_state/src/repos.rs @@ -5,7 +5,7 @@ use std::time::Duration; use anyhow::Error; use gpui::{App, AppContext, Context, Entity, Global, Subscription}; use nostr_sdk::prelude::*; -use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr}; +use signed_core::{Announcement, Deletions, Filters, RepoAddr, filters}; use crate::backend::{Backend, BackendEvent}; use crate::refresh::{RefreshGate, RefreshRequest}; @@ -115,10 +115,10 @@ impl RepoListStore { backend.update(cx, |backend, cx| { backend.sync_bootstraps( vec![ - filters::all_announcements(), - filters::all_states(), + Filters::all_announcements(), + Filters::all_states(), // Deletion requests, NIP-09/62, must be known before any announcement is shown. - filters::deletions(), + Filters::deletions(), ], cx, ); @@ -144,7 +144,7 @@ impl RepoListStore { self.state_synced_repos.insert(addr.clone()); backend.update(cx, |backend, cx| { - backend.connect_repo_relays(relays, vec![filters::state(&addr)], cx); + backend.connect_repo_relays(relays, vec![addr.state_filter()], cx); }); } } @@ -168,10 +168,10 @@ impl RepoListStore { let client = backend.read(cx).client(); let work = cx.background_spawn(async move { - let filter = filters::all_announcements(); + let filter = Filters::all_announcements(); let events = client.database().query(filter).await?; - let deletion_events = client.database().query(filters::deletions()).await?; + let deletion_events = client.database().query(Filters::deletions()).await?; let deletions = Deletions::from_events(deletion_events); // Dedup and sort off the main thread. @@ -213,7 +213,7 @@ impl RepoListStore { let Some(id) = event.tags.identifier() else { continue; }; - let addr = repo_addr(event.pubkey, id); + let addr = RepoAddr::new(event.pubkey, id); let Some(entry) = last_activity.get_mut(&addr) else { continue; }; @@ -230,10 +230,11 @@ impl RepoListStore { if deletions.is_deleted(&event) { continue; } - for addr in event.tags.coordinates() { - if addr.kind != Kind::GitRepoAnnouncement { + for coordinate in event.tags.coordinates() { + if coordinate.kind != Kind::GitRepoAnnouncement { continue; } + let addr = RepoAddr::from(coordinate.clone()); let Some(entry) = last_activity.get_mut(&addr) else { continue; }; @@ -251,12 +252,13 @@ impl RepoListStore { if deletions.is_deleted(&event) { continue; } - for addr in event.tags.coordinates() { - if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr) + for coordinate in event.tags.coordinates() { + if coordinate.kind != Kind::GitRepoAnnouncement + || !last_activity.contains_key(&RepoAddr::from(coordinate.clone())) { continue; } - let entry = counts.entry(addr).or_default(); + let entry = counts.entry(RepoAddr::from(coordinate)).or_default(); match event.kind { Kind::GitIssue => entry.issues += 1, Kind::GitPullRequest => entry.pull_requests += 1, diff --git a/crates/workspace/src/views/issues/detail.rs b/crates/workspace/src/views/issues/detail.rs index a401252..c5a7131 100644 --- a/crates/workspace/src/views/issues/detail.rs +++ b/crates/workspace/src/views/issues/detail.rs @@ -8,7 +8,7 @@ use gpui_component::input::TextareaState; use gpui_component::scroll::ScrollableElement; use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex}; use nostr::prelude::EventId; -use signed_core::activity_subject; +use signed_core::GitEvent; use signed_state::{ProfileStore, RepoStore}; use signed_ui::{Avatar, placeholder, status_badge}; use utils::relative_time; @@ -103,7 +103,7 @@ impl Render for IssueDetailView { }; ( - activity_subject(issue), + issue.activity_subject(), profile.name(), profile.picture(), store.status_of(issue), diff --git a/crates/workspace/src/views/issues/mod.rs b/crates/workspace/src/views/issues/mod.rs index b082548..edfba02 100644 --- a/crates/workspace/src/views/issues/mod.rs +++ b/crates/workspace/src/views/issues/mod.rs @@ -16,7 +16,7 @@ use gpui_component::{ ActiveTheme, Icon, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list, }; use nostr::prelude::EventId; -use signed_core::{RepoStatus, activity_subject}; +use signed_core::{GitEvent, RepoStatus}; use signed_state::{ProfileStore, RepoStore}; use signed_ui::{Avatar, SegmentButton, placeholder, status_badge}; use utils::relative_time; @@ -146,7 +146,7 @@ impl IssuesView { fn render_row(&self, ix: usize, issue_ix: usize, cx: &mut Context) -> AnyElement { let issue = &self.store.read(cx).issues[issue_ix]; - let title = activity_subject(issue); + let title = issue.activity_subject(); let id_hex = issue.id.to_hex(); let profile = ProfileStore::global(cx).read(cx).get(&issue.pubkey); let author = profile.name(); diff --git a/crates/workspace/src/views/pull_requests/detail.rs b/crates/workspace/src/views/pull_requests/detail.rs index d1385c3..c32c71d 100644 --- a/crates/workspace/src/views/pull_requests/detail.rs +++ b/crates/workspace/src/views/pull_requests/detail.rs @@ -21,10 +21,7 @@ use gpui_component::{ v_virtual_list, }; use nostr::prelude::{Event, EventId, Kind, Url}; -use signed_core::{ - RepoAddr, activity_subject, branch_name_of, clone_urls_of, current_commit_of, latest_update, - merge_base_of, pull_request_patch, -}; +use signed_core::{GitEvent, PullRequest, RepoAddr}; use signed_git::{FileCommit, patch_commits, patch_diffs}; use signed_state::{Backend, ProfileStore, RepoStore, ensure_repo_mirror}; use signed_ui::{Avatar, CountBadge, placeholder, status_badge}; @@ -145,23 +142,24 @@ impl PullRequestDetailView { .iter() .find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest) .map(|root| { - let update = latest_update(store.pull_requests.iter(), root); + let update = PullRequest::latest_update(store.pull_requests.iter(), root); let tip = update - .and_then(current_commit_of) - .or_else(|| current_commit_of(root)); + .and_then(GitEvent::current_commit) + .or_else(|| root.current_commit()); let base = update - .and_then(merge_base_of) - .or_else(|| merge_base_of(root)); + .and_then(GitEvent::merge_base) + .or_else(|| root.merge_base()); - let clone_urls = clone_urls_of(root) + let clone_urls = root + .clone_urls() .or_else(|| store.announcement.as_ref().map(|a| a.clone.clone())) .unwrap_or_default(); PrBinding { description: root.content.clone(), - patch: pull_request_patch(root, store.patches.iter()), + patch: PullRequest::new(root).patch(store.patches.iter()), tip, base, clone_urls, @@ -620,9 +618,9 @@ impl PullRequestDetailView { return div().into_any_element(); }; ( - activity_subject(root), + root.activity_subject(), store.status_of(root), - branch_name_of(root), + root.branch_name(), root.pubkey, ) }; diff --git a/crates/workspace/src/views/pull_requests/mod.rs b/crates/workspace/src/views/pull_requests/mod.rs index b14bf99..beb3a93 100644 --- a/crates/workspace/src/views/pull_requests/mod.rs +++ b/crates/workspace/src/views/pull_requests/mod.rs @@ -14,7 +14,7 @@ use gpui_component::{ ActiveTheme, Icon, IconName, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, }; use nostr::prelude::{EventId, Kind}; -use signed_core::{RepoStatus, activity_subject}; +use signed_core::{GitEvent, RepoStatus}; use signed_state::{ProfileStore, RepoStore}; use signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge}; use utils::relative_time; @@ -159,7 +159,7 @@ impl PullRequestsView { fn render_row(&self, ix: usize, pr_ix: usize, cx: &mut Context) -> AnyElement { let pr = &self.store.read(cx).pull_requests[pr_ix]; let pr_id = pr.id; - let title = activity_subject(pr); + let title = pr.activity_subject(); let id_hex = pr.id.to_hex(); let age = relative_time(pr.created_at); diff --git a/crates/workspace/src/views/pull_requests/new.rs b/crates/workspace/src/views/pull_requests/new.rs index 88e417c..2762655 100644 --- a/crates/workspace/src/views/pull_requests/new.rs +++ b/crates/workspace/src/views/pull_requests/new.rs @@ -21,7 +21,7 @@ use gpui_component::{ v_virtual_list, }; use nostr::prelude::*; -use signed_core::{Announcement, RepoAddr, fork_candidates}; +use signed_core::{Announcement, RepoAddr}; use signed_git::{ delete_refs_with_prefix, fetch_repo_refs, fork_namespace, merge_base, refs_with_prefix, worktree_commit_range_commits, worktree_commit_range_diff, @@ -512,7 +512,7 @@ impl NewPullRequestView { }; let user = Backend::global(cx).read(cx).current_user(); let announcements = RepoListStore::global(cx).read(cx).announcements.clone(); - fork_candidates(&announcements, &base, euc.as_deref(), user) + Announcement::forks_in(&announcements, &base, euc.as_deref(), user) .into_iter() .cloned() .collect() diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index fcb7a93..23f9dac 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -531,7 +531,7 @@ impl RepoDetailView { .as_ref() .map(|name| name.to_string()) .filter(|name| !name.trim().is_empty()) - .unwrap_or_else(|| addr.identifier.clone()); + .unwrap_or_else(|| addr.identifier().to_owned()); let name = signed_git::sanitize_path_component(&name); if name.is_empty() { @@ -1857,7 +1857,7 @@ fn fork_row(announcement: &Announcement, cx: &mut Context) -> Op .map(SharedString::from) .unwrap_or_else(|| SharedString::from(a.id.clone())) }) - .unwrap_or_else(|| SharedString::from(addr.identifier.clone())); + .unwrap_or_else(|| SharedString::from(addr.identifier().to_owned())); (SharedString::from(format!("Forked from {name}")), true) } None => (SharedString::from(upstream.display().as_str()), false), @@ -1900,7 +1900,7 @@ impl ShareTargets { fn from_announcement(announcement: &Announcement) -> Self { let addr = announcement.addr(); let coordinate = addr.to_string(); - let naddr = Nip19Coordinate::new(addr, announcement.relays.iter().cloned()) + let naddr = Nip19Coordinate::new(addr.into(), announcement.relays.iter().cloned()) .to_bech32() .expect("a complete coordinate always encodes to naddr"); diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 2fde423..16fc379 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -228,7 +228,7 @@ impl RepoListView { .map(SharedString::from) .unwrap_or_else(|| SharedString::from(a.id.clone())) }) - .unwrap_or_else(|| SharedString::from(addr.identifier.clone())); + .unwrap_or_else(|| SharedString::from(addr.identifier().to_owned())); Some(SharedString::from(format!("forked from {name}"))) });