reorganize signed_core into method-based APIs

This commit is contained in:
2026-10-03 12:03:29 +07:00
parent d00ee33eab
commit 023267001d
20 changed files with 833 additions and 697 deletions
+95 -5
View File
@@ -1,15 +1,22 @@
use std::fmt;
use std::str::FromStr;
use nostr::prelude::*;
use serde::{Deserialize, Serialize};
/// Address of a NIP-34 repository announcement, `30617:<owner-pubkey>:<repo-id>`.
///
/// 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<String>) -> RepoAddr {
Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier(id)
impl RepoAddr {
pub fn new(owner: PublicKey, identifier: impl Into<String>) -> 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| {
@@ -21,3 +28,86 @@ pub fn identifier_from_name(name: &str) -> String {
})
.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<Filter> {
vec![
Filter::new()
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
.author(self.public_key()),
Filter::new().kind(Kind::EventDeletion).coordinate(&self.0),
]
}
}
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 = <Coordinate as FromStr>::Err;
fn from_str(s: &str) -> Result<Self, Self::Err> {
Coordinate::from_str(s).map(RepoAddr)
}
}
impl From<Coordinate> for RepoAddr {
fn from(coordinate: Coordinate) -> Self {
RepoAddr(coordinate)
}
}
impl From<RepoAddr> for Coordinate {
fn from(addr: RepoAddr) -> Self {
addr.0
}
}
+31 -90
View File
@@ -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,34 +58,14 @@ fn tag_kind(event: &Event, name: &str) -> Option<Kind> {
tag_value(event, name)?.parse::<Kind>().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())
}
/// 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)
}
impl Filters {
/// How far back deletion requests are fetched and stored.
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
/// 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<Item = EventId>) -> Filter {
Filter::new()
.kinds([
@@ -126,7 +104,6 @@ pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
}
/// 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)
@@ -138,43 +115,19 @@ fn notification_comments(me: PublicKey) -> Filter {
/// via their lowercase `p` tag. `Filter::pubkey` sets that `p` tag.
pub fn notifications(me: PublicKey) -> Vec<Filter> {
vec![
notification_comments(me),
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 is matched too, so results must be filtered
/// through [`is_git_activity`] before display.
/// 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)
}
/// Whether a kind-1111 comment targets a git root, checked via its `K` tag.
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 {
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)
@@ -185,45 +138,41 @@ 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`].
/// `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) - DELETIONS_LOOKBACK
Timestamp::from_secs(now - now % 86_400) - Self::DELETIONS_LOOKBACK
}
/// All deletion-related events within [`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()
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
.since(deletions_since())
.since(Self::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<Filter> {
vec![
Filter::new()
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
.author(addr.public_key),
Filter::new().kind(Kind::EventDeletion).coordinate(addr),
]
/// Whether a kind-1111 comment targets a git root, checked via its `K` tag.
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.
pub(crate) fn is_git_status(event: &Event) -> bool {
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
}
#[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());
}
}
+138 -119
View File
@@ -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,6 +112,19 @@ impl InboxItem {
}
}
/// 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<Event> + ?Sized,
{
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
@@ -122,33 +135,128 @@ impl InboxItem {
/// - NIP-22 comment (1111): uppercase `E` root pointer
/// - PR update (1619): uppercase `E`
/// - statuses (1630-1633): NIP-10 root `e`
fn notification_root<L>(event: &Event, lookup: &L) -> Option<EventId>
where
L: Fn(EventId) -> Option<Event>,
{
pub fn notification_root(&self, event: &Event) -> Option<EventId> {
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),
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(resolve_thread_root(id, lookup)),
Some(CommentTarget::Event { id, .. }) => Some(self.resolve_thread_root(id)),
_ => None,
},
Kind::GitPullRequestUpdate => {
first_uppercase_e_id(event).map(|root| resolve_thread_root(root, lookup))
}
Kind::GitPullRequestUpdate => self
.first_uppercase_e_id(event)
.map(|root| self.resolve_thread_root(root)),
Kind::GitStatusOpen
| Kind::GitStatusApplied
| Kind::GitStatusClosed
| Kind::GitStatusDraft => {
nip10_root_id(event).map(|root| resolve_thread_root(root, lookup))
}
| Kind::GitStatusDraft => self
.nip10_root_id(event)
.map(|root| self.resolve_thread_root(root)),
_ => None,
}
}
/// 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,
}
}
}
/// Parent of a thread event, mirroring gitworkshop's `getParentId`.
fn parent_id(&self, event: &Event) -> Option<EventId> {
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<EventId> {
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<EventId> {
self.first_tag_id(event, "e")
}
/// First uppercase `E` tag id, in document order.
fn first_uppercase_e_id(&self, event: &Event) -> Option<EventId> {
self.first_tag_id(event, "E")
}
fn first_tag_id(&self, event: &Event, name: &str) -> Option<EventId> {
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<EventId> {
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())
}
}
/// Group notification events and the user's own events into one item per thread.
pub fn group<E, O, L>(
events: E,
@@ -162,12 +270,13 @@ where
O: IntoIterator<Item = Event>,
L: Fn(EventId) -> Option<Event>,
{
let resolver = ThreadResolver::new(lookup);
let mut groups: HashMap<EventId, Vec<Event>> = 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<EventId, Vec<Event>> = 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<EventId>, 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<Event>) -> 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<EventId> {
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<EventId> {
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<EventId> {
first_tag_id(event, "e")
}
/// First uppercase `E` tag id, in document order.
fn first_uppercase_e_id(event: &Event) -> Option<EventId> {
first_tag_id(event, "E")
}
fn first_tag_id(event: &Event, name: &str) -> Option<EventId> {
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<EventId> {
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]
+6 -8
View File
@@ -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;
+184 -111
View File
@@ -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::<Coordinate>()
.ok()
.filter(|c| c.kind == Kind::GitRepoAnnouncement);
.filter(|coordinate| coordinate.kind == Kind::GitRepoAnnouncement)
.map(RepoAddr::from);
Self {
raw: raw.to_owned(),
addr,
@@ -64,10 +65,33 @@ 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
/// Tag accessors for NIP-34 git collaboration events.
pub trait GitEvent {
/// Subject of an issue or pull request event.
fn activity_subject(&self) -> String;
/// The `c` tag, the tip of the proposed branch, as hex.
fn current_commit(&self) -> Option<String>;
/// The `merge-base` tag, the base commit a pull request diffs against.
fn merge_base(&self) -> Option<String>;
/// The `clone` tag, URLs the tip commit can be fetched from.
fn clone_urls(&self) -> Option<Vec<Url>>;
/// The `branch-name` tag, the proposed branch's name.
fn branch_name(&self) -> Option<String>;
/// 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;
}
impl GitEvent for Event {
fn activity_subject(&self) -> String {
let subject = self
.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
@@ -77,8 +101,7 @@ pub fn activity_subject(event: &Event) -> String {
subject
.or_else(|| {
event
.content
self.content
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
@@ -87,33 +110,122 @@ pub fn activity_subject(event: &Event) -> String {
.unwrap_or("Untitled".to_string())
}
/// The patch set of a pull request.
fn current_commit(&self) -> Option<String> {
self.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()),
_ => None,
})
}
fn merge_base(&self) -> Option<String> {
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<Vec<Url>> {
self.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Clone(urls)) => Some(urls),
_ => None,
})
}
fn branch_name(&self) -> Option<String> {
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<T: GitEvent + ?Sized> GitEvent for &T {
fn activity_subject(&self) -> String {
(*self).activity_subject()
}
fn current_commit(&self) -> Option<String> {
(*self).current_commit()
}
fn merge_base(&self) -> Option<String> {
(*self).merge_base()
}
fn clone_urls(&self) -> Option<Vec<Url>> {
(*self).clone_urls()
}
fn branch_name(&self) -> Option<String> {
(*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 pull_request_patches<'a>(
pr: &Event,
patches: impl IntoIterator<Item = &'a Event>,
) -> Vec<&'a Event> {
pub fn patches(&self, patches: impl IntoIterator<Item = &'a Event>) -> 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.
// 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);
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.
// Walk the reply chain backward to the root.
let Some(tip) = current_commit_of(pr) else {
let Some(tip) = pr.current_commit() else {
return Vec::new();
};
let Some(last) = patches
.iter()
.filter(|patch| patch_produces_commit(patch, &tip))
.filter(|patch| Self::patch_produces_commit(patch, &tip))
.max_by_key(|patch| patch.created_at)
.copied()
else {
@@ -138,9 +250,13 @@ pub fn pull_request_patches<'a>(
series
}
pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a Event>) -> String {
/// 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<Item = &'a Event>) -> String {
let pr = self.0;
let patches: Vec<&'a Event> = patches.into_iter().collect();
let series = pull_request_patches(pr, patches.iter().copied());
let series = self.patches(patches.iter().copied());
if series.is_empty() {
return pr.content.clone();
}
@@ -151,8 +267,28 @@ pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a
.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<Item = &'a Event>,
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<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
fn forward_series(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
let mut series = vec![root];
loop {
let next = patches
@@ -173,74 +309,25 @@ fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event>
series
}
/// The `c` tag of an event, the tip of the proposed branch, as hex.
pub fn current_commit_of(event: &Event) -> Option<String> {
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<String> {
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<Vec<Url>> {
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<String> {
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.
/// Whether `patch` produces `commit`, found via its `commit` or `r` tag.
///
/// 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<Item = &'a Event>,
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
/// It lets clients find existing patches for a specific commit.
fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
patch
.tags
.iter()
.any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str()))
.any(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit,
_ => false,
})
.max_by_key(|e| e.created_at)
}
}
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 fork_candidates<'a>(
pub fn forks_in<'a>(
announcements: &'a [Announcement],
base: &RepoAddr,
base_euc: Option<&str>,
@@ -260,20 +347,6 @@ pub fn fork_candidates<'a>(
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,
})
}
impl Announcement {
/// 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"]);
}
+22 -14
View File
@@ -1,10 +1,20 @@
use nostr::prelude::*;
/// 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<String>,
}
impl RepoState {
/// Build a kind `30618` repository state event from refs and HEAD,
/// it is published as `ref: refs/heads/<branch>`.
///
/// The `d` tag matches the repository id.
pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
pub fn build(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
for (name, commit) in refs {
tags.push(Tag::parse([name.as_str(), commit.as_str()]).expect("valid ref tag"));
@@ -18,10 +28,7 @@ pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> E
}
/// 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<String>) {
pub fn parse(event: &Event) -> Self {
let mut refs = Vec::new();
let mut head = None;
@@ -38,7 +45,8 @@ pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option<String>) {
}
}
(refs, head)
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"));
}
}
+22 -22
View File
@@ -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<Item = &'a Event>,
{
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<Item = &'a Event>,
{
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()]
+2 -2
View File
@@ -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<Option<gix::Repository>> {
+16 -16
View File
@@ -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<RelayUrl> = RepoListStore::global(cx)
.read(cx)
@@ -973,8 +973,8 @@ impl Backend {
if !relays.is_empty() {
let relays: Vec<RelayUrl> = 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<Vec<RelayUrl>, Error> {
let events: Vec<Event> = 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
+5 -5
View File
@@ -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<InboxItem>, 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<Event> = Vec::new();
let mut by_id: HashMap<EventId, Event> = 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;
+2 -2
View File
@@ -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<LocalReposStore>);
@@ -113,7 +113,7 @@ pub fn local_repo_addr(repo: &LocalRepo) -> Option<RepoAddr> {
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.
+48 -41
View File
@@ -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<RelayUrl> = 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<String>, content: String, cx: &mut Context<Self>) {
@@ -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<Item = &Event> {
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]);
+15 -13
View File
@@ -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,
+2 -2
View File
@@ -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),
+2 -2
View File
@@ -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<Self>) -> 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();
@@ -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,
)
};
@@ -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<Self>) -> 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);
@@ -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()
+3 -3
View File
@@ -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<RepoDetailView>) -> 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");
+1 -1
View File
@@ -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}")))
});