reorganize signed_core into method-based APIs
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,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
@@ -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"]);
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()]
|
||||
|
||||
@@ -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>> {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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]);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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}")))
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user