chore: refactor backend around domain types (#26)
Reviewed-on: #26
This commit was merged in pull request #26.
This commit is contained in:
@@ -7,3 +7,4 @@ publish.workspace = true
|
||||
[dependencies]
|
||||
nostr.workspace = true
|
||||
serde.workspace = true
|
||||
utils = { path = "../utils" }
|
||||
|
||||
@@ -1,23 +1,99 @@
|
||||
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;
|
||||
#[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))
|
||||
}
|
||||
|
||||
pub fn identifier_from_name(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '/' {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn kind(&self) -> Kind {
|
||||
self.0.kind
|
||||
}
|
||||
|
||||
pub fn public_key(&self) -> PublicKey {
|
||||
self.0.public_key
|
||||
}
|
||||
|
||||
pub fn identifier(&self) -> &str {
|
||||
&self.0.identifier
|
||||
}
|
||||
|
||||
pub fn coordinate(&self) -> &Coordinate {
|
||||
&self.0
|
||||
}
|
||||
|
||||
pub fn announcement_filter(&self) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::GitRepoAnnouncement)
|
||||
.author(self.public_key())
|
||||
.identifier(self.identifier())
|
||||
}
|
||||
|
||||
pub fn state_filter(&self) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::RepoState)
|
||||
.author(self.public_key())
|
||||
.identifier(self.identifier())
|
||||
}
|
||||
|
||||
// Statuses may omit the `a` tag per NIP-34; those are not matched here.
|
||||
pub fn activity_filter(&self) -> Filter {
|
||||
Filter::new()
|
||||
.kinds(crate::filters::ACTIVITY_KINDS)
|
||||
.coordinate(&self.0)
|
||||
}
|
||||
|
||||
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),
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
pub fn identifier_from_name(name: &str) -> String {
|
||||
name.chars()
|
||||
.map(|c| {
|
||||
if c.is_ascii_alphanumeric() || c == '/' {
|
||||
c
|
||||
} else {
|
||||
'-'
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
impl fmt::Display for RepoAddr {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
fmt::Display::fmt(&self.0, f)
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for RepoAddr {
|
||||
type Err = <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,25 +2,14 @@ use std::collections::HashSet;
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// NIP-09 deletion requests and NIP-62 vanish requests,
|
||||
/// built from the kind-5 and kind-62 events in the local database.
|
||||
///
|
||||
/// Deleted events are hidden before they reach the UI.
|
||||
///
|
||||
/// Pass any event through [`Deletions::is_deleted`] before showing it.
|
||||
pub struct Deletions {
|
||||
/// `(deleted event id, expected author)` from `e` tags of kind-5 events.
|
||||
ids: HashSet<(EventId, PublicKey)>,
|
||||
/// `(coordinate, expected author, cutoff)` from `a` tags of kind-5 events.
|
||||
///
|
||||
/// All versions of the addressable event up to `cutoff` are deleted.
|
||||
// All versions of the addressable event up to `cutoff` are deleted.
|
||||
coords: Vec<(Coordinate, PublicKey, Timestamp)>,
|
||||
/// `(author, cutoff)` from kind-62 vanish requests.
|
||||
vanished: Vec<(PublicKey, Timestamp)>,
|
||||
}
|
||||
|
||||
impl Deletions {
|
||||
/// Build the deletion index from raw kind-5 and kind-62 events.
|
||||
pub fn from_events(events: impl IntoIterator<Item = Event>) -> Self {
|
||||
let mut ids = HashSet::new();
|
||||
let mut coords = Vec::new();
|
||||
@@ -36,8 +25,8 @@ impl Deletions {
|
||||
.map(|c| (c, event.pubkey, event.created_at)),
|
||||
);
|
||||
} else if event.kind == Kind::RequestToVanish {
|
||||
// Client-side we can't verify which relay the request targeted.
|
||||
// Any vanish request is then honored for the author's events.
|
||||
// We can't verify which relay the request targeted, so any
|
||||
// vanish request is honored for the author's events.
|
||||
vanished.push((event.pubkey, event.created_at));
|
||||
}
|
||||
}
|
||||
@@ -49,10 +38,8 @@ impl Deletions {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the event is covered by a valid deletion or vanish request.
|
||||
/// A request is valid when its author matches the deleted event's author, per NIP-09.
|
||||
///
|
||||
/// Addressable events are deleted up to the request's `created_at`.
|
||||
// A request is valid when its author matches the deleted event's author,
|
||||
// per NIP-09. Addressable events are deleted up to the request's `created_at`.
|
||||
pub fn is_deleted(&self, event: &Event) -> bool {
|
||||
if self
|
||||
.vanished
|
||||
|
||||
@@ -2,9 +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,
|
||||
Kind::GitPatch,
|
||||
@@ -17,8 +14,7 @@ pub const ACTIVITY_KINDS: [Kind; 9] = [
|
||||
Kind::GitStatusDraft,
|
||||
];
|
||||
|
||||
/// Kinds that notify a user when they tag them via their `p` tag.
|
||||
pub const NOTIFICATION_KINDS: [Kind; 8] = [
|
||||
const NOTIFICATION_KINDS: [Kind; 8] = [
|
||||
Kind::GitIssue,
|
||||
Kind::GitPullRequest,
|
||||
Kind::GitPatch,
|
||||
@@ -29,7 +25,6 @@ pub const NOTIFICATION_KINDS: [Kind; 8] = [
|
||||
Kind::GitStatusDraft,
|
||||
];
|
||||
|
||||
/// Git root kinds that make a comment count as git activity.
|
||||
const GIT_ROOT_KINDS: [Kind; 4] = [
|
||||
Kind::GitIssue,
|
||||
Kind::GitPatch,
|
||||
@@ -37,7 +32,6 @@ const GIT_ROOT_KINDS: [Kind; 4] = [
|
||||
Kind::GitRepoAnnouncement,
|
||||
];
|
||||
|
||||
/// Kinds that carry repository data: announcements, states, activity and deletions.
|
||||
pub fn is_repo_kind(kind: Kind) -> bool {
|
||||
kind == Kind::GitRepoAnnouncement
|
||||
|| kind == Kind::RepoState
|
||||
@@ -46,7 +40,6 @@ pub fn is_repo_kind(kind: Kind) -> bool {
|
||||
|| ACTIVITY_KINDS.contains(&kind)
|
||||
}
|
||||
|
||||
/// Value of the first tag named `name` on `event`.
|
||||
fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
|
||||
event
|
||||
.tags
|
||||
@@ -55,175 +48,104 @@ fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
|
||||
.and_then(|tag| tag.content())
|
||||
}
|
||||
|
||||
/// Kind named by the first tag `name` on `event`.
|
||||
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())
|
||||
}
|
||||
pub struct Filters;
|
||||
|
||||
/// Latest state event for a repository, carrying refs and HEAD.
|
||||
pub fn state(addr: &RepoAddr) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::RepoState)
|
||||
.author(addr.public_key)
|
||||
.identifier(addr.identifier.clone())
|
||||
}
|
||||
impl Filters {
|
||||
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
|
||||
|
||||
/// All NIP-34 activity addressed to a repository via its `#a` tag.
|
||||
/// Covers issues, PRs, patches, statuses and kind-1111 comments.
|
||||
/// The `a` tag is optional on status events per NIP-34.
|
||||
/// Statuses published without it are not matched here.
|
||||
pub fn activity(addr: &RepoAddr) -> Filter {
|
||||
Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr)
|
||||
}
|
||||
|
||||
/// Status events, kinds `1630..=1633`, referencing any of the given root events.
|
||||
/// They are matched via the `#e` tag. One filter covers all roots.
|
||||
///
|
||||
/// A negentropy sync reconciles them in a single session, not one per root.
|
||||
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
||||
Filter::new()
|
||||
.kinds([
|
||||
Kind::GitStatusOpen,
|
||||
Kind::GitStatusApplied,
|
||||
Kind::GitStatusClosed,
|
||||
Kind::GitStatusDraft,
|
||||
])
|
||||
.events(roots)
|
||||
}
|
||||
|
||||
/// A user's grasp list, kind `10317`.
|
||||
pub fn grasp_list(public_key: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::GitUserGraspList)
|
||||
.author(public_key)
|
||||
}
|
||||
|
||||
/// NIP-22 comments, kind `1111`, referencing any of the given root events.
|
||||
/// The roots are issues, patches and PRs.
|
||||
///
|
||||
/// Returns two filters, since combining `#E` and `#e` would AND the conditions.
|
||||
pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
||||
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
|
||||
if roots.is_empty() {
|
||||
return Vec::new();
|
||||
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
||||
Filter::new()
|
||||
.kinds([
|
||||
Kind::GitStatusOpen,
|
||||
Kind::GitStatusApplied,
|
||||
Kind::GitStatusClosed,
|
||||
Kind::GitStatusDraft,
|
||||
])
|
||||
.events(roots)
|
||||
}
|
||||
vec![
|
||||
|
||||
pub fn grasp_list(public_key: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::GitUserGraspList)
|
||||
.author(public_key)
|
||||
}
|
||||
|
||||
// Two filters: combining `#E` and `#e` would AND the conditions.
|
||||
pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
||||
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
|
||||
if roots.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
vec![
|
||||
Filter::new()
|
||||
.kind(Kind::Comment)
|
||||
.custom_tags(SingleLetterTag::UPPERCASE_E, roots.clone()),
|
||||
Filter::new()
|
||||
.kind(Kind::Comment)
|
||||
.custom_tags(SingleLetterTag::LOWERCASE_E, roots),
|
||||
]
|
||||
}
|
||||
|
||||
fn notification_comments(me: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::Comment)
|
||||
.custom_tags(SingleLetterTag::UPPERCASE_E, roots.clone()),
|
||||
.custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()])
|
||||
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
|
||||
}
|
||||
|
||||
// `Filter::pubkey` matches the git events' lowercase `p` tag.
|
||||
pub fn notifications(me: PublicKey) -> Vec<Filter> {
|
||||
vec![
|
||||
Self::notification_comments(me),
|
||||
Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me),
|
||||
]
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
pub fn all_announcements() -> Filter {
|
||||
Filter::new().kind(Kind::GitRepoAnnouncement)
|
||||
}
|
||||
|
||||
pub fn all_states() -> Filter {
|
||||
Filter::new().kind(Kind::RepoState)
|
||||
}
|
||||
|
||||
// Quantized to whole days so identical filters hash the same.
|
||||
fn deletions_since() -> Timestamp {
|
||||
let now = Timestamp::now().as_secs();
|
||||
Timestamp::from_secs(now - now % 86_400) - Self::DELETIONS_LOOKBACK
|
||||
}
|
||||
|
||||
// Deletion requests must be known before any other event is shown.
|
||||
pub fn deletions() -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::Comment)
|
||||
.custom_tags(SingleLetterTag::LOWERCASE_E, roots),
|
||||
]
|
||||
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
||||
.since(Self::deletions_since())
|
||||
}
|
||||
}
|
||||
|
||||
/// NIP-22 comments on our issues, patches and pull requests.
|
||||
/// They are matched via the uppercase `P` and `K` tags, not authorship.
|
||||
pub fn notification_comments(me: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::Comment)
|
||||
.custom_tags(SingleLetterTag::UPPERCASE_P, [me.to_hex()])
|
||||
.custom_tags(SingleLetterTag::UPPERCASE_K, ["1621", "1617", "1618"])
|
||||
}
|
||||
|
||||
/// Activity directed at us: comments on our roots, and git events tagging us
|
||||
/// via their lowercase `p` tag. `Filter::pubkey` sets that `p` tag.
|
||||
pub fn notifications(me: PublicKey) -> Vec<Filter> {
|
||||
vec![
|
||||
notification_comments(me),
|
||||
Filter::new().kinds(NOTIFICATION_KINDS).pubkey(me),
|
||||
]
|
||||
}
|
||||
|
||||
/// Git activity authored by `me`, for "Continue where you left off".
|
||||
///
|
||||
/// A comment on an unrelated kind is matched too, so results must be filtered
|
||||
/// through [`is_git_activity`] before display.
|
||||
pub fn authored_activity(me: PublicKey) -> Filter {
|
||||
Filter::new().kinds(ACTIVITY_KINDS).author(me)
|
||||
}
|
||||
|
||||
/// Whether a kind-1111 comment targets a git root, checked via its `K` tag.
|
||||
fn is_git_comment(event: &Event) -> bool {
|
||||
pub(crate) fn is_git_comment(event: &Event) -> bool {
|
||||
event.kind == Kind::Comment
|
||||
&& tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||
}
|
||||
|
||||
/// Whether a status event references a git root, checked via its `k` tag.
|
||||
fn is_git_status(event: &Event) -> bool {
|
||||
pub(crate) fn is_git_status(event: &Event) -> bool {
|
||||
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||
}
|
||||
|
||||
/// Whether `event` is git activity worth showing in the activity list.
|
||||
pub fn is_git_activity(event: &Event) -> bool {
|
||||
match event.kind {
|
||||
Kind::GitIssue | Kind::GitPatch | Kind::GitPullRequest => true,
|
||||
Kind::Comment => is_git_comment(event),
|
||||
Kind::GitStatusOpen
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
| Kind::GitStatusDraft => is_git_status(event),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// All repository announcements, for global discovery.
|
||||
pub fn all_announcements() -> Filter {
|
||||
Filter::new().kind(Kind::GitRepoAnnouncement)
|
||||
}
|
||||
|
||||
/// All repository state events, carrying each repository's refs and last push time.
|
||||
pub fn all_states() -> Filter {
|
||||
Filter::new().kind(Kind::RepoState)
|
||||
}
|
||||
|
||||
/// How far back deletion requests are fetched and stored.
|
||||
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
|
||||
|
||||
/// `now` minus [`DELETIONS_LOOKBACK`].
|
||||
/// Quantized to whole days so identical filters hash the same.
|
||||
///
|
||||
/// This lets the backend's sync dedup match identical filters.
|
||||
fn deletions_since() -> Timestamp {
|
||||
let now = Timestamp::now().as_secs();
|
||||
Timestamp::from_secs(now - now % 86_400) - DELETIONS_LOOKBACK
|
||||
}
|
||||
|
||||
/// All deletion-related events within [`DELETIONS_LOOKBACK`].
|
||||
/// These are NIP-09 kind `5` and NIP-62 kind `62`.
|
||||
///
|
||||
/// Deletion requests must be known before any other event is shown.
|
||||
pub fn deletions() -> Filter {
|
||||
Filter::new()
|
||||
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
||||
.since(deletions_since())
|
||||
}
|
||||
|
||||
/// Deletion events relevant to a single repository.
|
||||
///
|
||||
/// Requests authored by the repository owner.
|
||||
///
|
||||
/// Requests addressed to the repository coordinate via its `#a` tag.
|
||||
pub fn deletions_for_repo(addr: &RepoAddr) -> Vec<Filter> {
|
||||
vec![
|
||||
Filter::new()
|
||||
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
|
||||
.author(addr.public_key),
|
||||
Filter::new().kind(Kind::EventDeletion).coordinate(addr),
|
||||
]
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::GitEvent;
|
||||
|
||||
fn keys(seed: u8) -> Keys {
|
||||
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
|
||||
@@ -252,14 +174,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 +193,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());
|
||||
}
|
||||
}
|
||||
|
||||
+153
-264
@@ -4,41 +4,29 @@ 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);
|
||||
|
||||
/// Window before `now` that a mark-all cutoff retreats to.
|
||||
const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60);
|
||||
|
||||
/// A thread of notification and own-activity events sharing one root.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct InboxItem {
|
||||
/// The root issue, patch or pull request the events belong to.
|
||||
pub root: EventId,
|
||||
/// The root event itself, when it is known locally.
|
||||
pub root_event: Option<Event>,
|
||||
/// Repository the root belongs to, from the root's `a` tag.
|
||||
pub address: Option<RepoAddr>,
|
||||
/// Notification events directed at the user, newest first.
|
||||
pub events: Vec<Event>,
|
||||
/// The user's own events in the thread, newest first.
|
||||
pub own_events: Vec<Event>,
|
||||
/// Unread event ids, oldest first.
|
||||
pub unread_ids: Vec<EventId>,
|
||||
/// Whether every notification event in the thread is archived.
|
||||
pub archived: bool,
|
||||
}
|
||||
|
||||
impl InboxItem {
|
||||
/// Title of the thread, read from its root issue/patch/PR when known.
|
||||
pub fn title(&self) -> String {
|
||||
self.root_event
|
||||
.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())
|
||||
}
|
||||
|
||||
@@ -50,7 +38,6 @@ impl InboxItem {
|
||||
.map(|event| event.kind)
|
||||
}
|
||||
|
||||
/// Timestamp of the newest event in the thread.
|
||||
pub fn latest_activity(&self) -> Timestamp {
|
||||
self.root_event
|
||||
.as_ref()
|
||||
@@ -62,7 +49,6 @@ impl InboxItem {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Up to `limit` events of the thread, oldest first.
|
||||
pub fn timeline(&self, limit: usize) -> Vec<Event> {
|
||||
let mut seen: HashSet<EventId> = HashSet::new();
|
||||
let mut events: Vec<Event> = Vec::new();
|
||||
@@ -92,7 +78,6 @@ impl InboxItem {
|
||||
events
|
||||
}
|
||||
|
||||
/// Whether the thread has an unread event still visible in the inbox.
|
||||
pub fn is_unread(&self) -> bool {
|
||||
!self.archived && !self.unread_ids.is_empty()
|
||||
}
|
||||
@@ -112,44 +97,144 @@ impl InboxItem {
|
||||
}
|
||||
}
|
||||
|
||||
/// Root issue, patch or pull request of a notification event.
|
||||
///
|
||||
/// Returns `None` when the event is not git-related, or when its root is a
|
||||
/// coordinate rather than an event.
|
||||
///
|
||||
/// - issue (1621) / PR (1618): itself
|
||||
/// - patch (1617): its `e` parent patch, else itself
|
||||
/// - NIP-22 comment (1111): uppercase `E` root pointer
|
||||
/// - PR update (1619): uppercase `E`
|
||||
/// - statuses (1630-1633): NIP-10 root `e`
|
||||
pub fn notification_root<L>(event: &Event, lookup: &L) -> Option<EventId>
|
||||
pub struct ThreadResolver<'a, L: ?Sized> {
|
||||
lookup: &'a L,
|
||||
}
|
||||
|
||||
impl<'a, L> ThreadResolver<'a, L>
|
||||
where
|
||||
L: Fn(EventId) -> Option<Event>,
|
||||
L: Fn(EventId) -> Option<Event> + ?Sized,
|
||||
{
|
||||
match event.kind {
|
||||
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
||||
Kind::GitPatch => Some(match first_e_id(event) {
|
||||
Some(parent) => resolve_thread_root(parent, lookup),
|
||||
None => event.id,
|
||||
}),
|
||||
Kind::Comment => match nip22::extract_root(event) {
|
||||
Some(CommentTarget::Event { id, .. }) => Some(resolve_thread_root(id, lookup)),
|
||||
pub fn new(lookup: &'a L) -> Self {
|
||||
Self { lookup }
|
||||
}
|
||||
|
||||
// Kind → root mapping:
|
||||
// - issue (1621) / PR (1618): itself
|
||||
// - patch (1617): its `e` parent patch, else itself
|
||||
// - NIP-22 comment (1111): uppercase `E` root pointer
|
||||
// - PR update (1619): uppercase `E`
|
||||
// - statuses (1630-1633): NIP-10 root `e`
|
||||
// Returns `None` when the event is not git-related, or when its root is a
|
||||
// coordinate rather than an event.
|
||||
pub fn notification_root(&self, event: &Event) -> Option<EventId> {
|
||||
match event.kind {
|
||||
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
||||
Kind::GitPatch => Some(match self.first_e_id(event) {
|
||||
Some(parent) => self.resolve_thread_root(parent),
|
||||
None => event.id,
|
||||
}),
|
||||
Kind::Comment => match nip22::extract_root(event) {
|
||||
Some(CommentTarget::Event { id, .. }) => Some(self.resolve_thread_root(id)),
|
||||
_ => None,
|
||||
},
|
||||
Kind::GitPullRequestUpdate => self
|
||||
.first_uppercase_e_id(event)
|
||||
.map(|root| self.resolve_thread_root(root)),
|
||||
Kind::GitStatusOpen
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
| Kind::GitStatusDraft => self
|
||||
.nip10_root_id(event)
|
||||
.map(|root| self.resolve_thread_root(root)),
|
||||
_ => None,
|
||||
},
|
||||
Kind::GitPullRequestUpdate => {
|
||||
first_uppercase_e_id(event).map(|root| resolve_thread_root(root, lookup))
|
||||
}
|
||||
Kind::GitStatusOpen
|
||||
| Kind::GitStatusApplied
|
||||
| Kind::GitStatusClosed
|
||||
| Kind::GitStatusDraft => {
|
||||
nip10_root_id(event).map(|root| resolve_thread_root(root, lookup))
|
||||
}
|
||||
|
||||
// Follow NIP-10/NIP-22 parent pointers until a root item is reached.
|
||||
pub fn resolve_thread_root(&self, id: EventId) -> EventId {
|
||||
let mut seen = HashSet::new();
|
||||
let mut root = id;
|
||||
|
||||
loop {
|
||||
if !seen.insert(root) {
|
||||
return id;
|
||||
}
|
||||
|
||||
let Some(event) = (self.lookup)(root) else {
|
||||
return root;
|
||||
};
|
||||
|
||||
if matches!(event.kind, Kind::GitIssue | Kind::GitPullRequest) {
|
||||
return root;
|
||||
}
|
||||
|
||||
match self.parent_id(&event) {
|
||||
Some(parent) => root = parent,
|
||||
None => return root,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
|
||||
// Mirrors 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)
|
||||
}
|
||||
|
||||
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))
|
||||
}
|
||||
|
||||
fn first_e_id(&self, event: &Event) -> Option<EventId> {
|
||||
self.first_tag_id(event, "e")
|
||||
}
|
||||
|
||||
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())
|
||||
})
|
||||
}
|
||||
|
||||
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,
|
||||
own: O,
|
||||
@@ -162,12 +247,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 +261,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);
|
||||
}
|
||||
|
||||
@@ -188,8 +274,8 @@ where
|
||||
.map(|root| {
|
||||
let mut events = groups.remove(&root).unwrap_or_default();
|
||||
let mut own_events = own_groups.remove(&root).unwrap_or_default();
|
||||
sort_newest_first(&mut events);
|
||||
sort_newest_first(&mut own_events);
|
||||
utils::sort_newest_first(&mut events);
|
||||
utils::sort_newest_first(&mut own_events);
|
||||
|
||||
let root_event = lookup(root);
|
||||
|
||||
@@ -197,7 +283,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,
|
||||
@@ -218,16 +305,8 @@ where
|
||||
items
|
||||
}
|
||||
|
||||
/// Sort thread events newest first, ties broken by id.
|
||||
fn sort_newest_first(events: &mut [Event]) {
|
||||
events.sort_by(|a, b| {
|
||||
b.created_at
|
||||
.cmp(&a.created_at)
|
||||
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
|
||||
});
|
||||
}
|
||||
|
||||
/// Read and archive state of the inbox, a high-water-mark model.
|
||||
// High-water-mark model: events at or before the cutoff
|
||||
// are covered without an entry in the id set.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct InboxReadState {
|
||||
#[serde(default)]
|
||||
@@ -241,31 +320,26 @@ pub struct InboxReadState {
|
||||
}
|
||||
|
||||
impl InboxReadState {
|
||||
/// Whether `event` is at or before the read cutoff, or marked read.
|
||||
pub fn is_read(&self, event: &Event) -> bool {
|
||||
event.created_at <= self.read_before || self.read_ids.contains(&event.id)
|
||||
}
|
||||
|
||||
/// Whether `event` is at or before the archived cutoff, or marked archived.
|
||||
pub fn is_archived(&self, event: &Event) -> bool {
|
||||
event.created_at <= self.archived_before || self.archived_ids.contains(&event.id)
|
||||
}
|
||||
|
||||
/// Mark one event read. Events at or before the cutoff are already read.
|
||||
pub fn mark_read(&mut self, event: &Event) {
|
||||
if event.created_at > self.read_before {
|
||||
self.read_ids.insert(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark one event archived. Events at or before the cutoff are already archived.
|
||||
pub fn mark_archived(&mut self, event: &Event) {
|
||||
if event.created_at > self.archived_before {
|
||||
self.archived_ids.insert(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark every non-self event read, anchoring the cutoff ten days back.
|
||||
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||
let cutoff = now - MARK_ALL_WINDOW;
|
||||
self.read_before = cutoff;
|
||||
@@ -276,15 +350,14 @@ impl InboxReadState {
|
||||
.collect();
|
||||
}
|
||||
|
||||
/// Advance the read cutoff to the newest point that keeps unread events
|
||||
/// unread, then prune the id set.
|
||||
// Advance the cutoff to the newest point that keeps unread events unread,
|
||||
// then prune the id set.
|
||||
pub fn advance_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||
let cutoff = advance_cutoff(all, me, now, self.read_before, |event| self.is_read(event));
|
||||
self.read_before = cutoff;
|
||||
prune_ids(&mut self.read_ids, all, cutoff);
|
||||
}
|
||||
|
||||
/// Advance the archived cutoff, mirroring [`Self::advance_read`].
|
||||
pub fn advance_archived(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
|
||||
let cutoff = advance_cutoff(all, me, now, self.archived_before, |event| {
|
||||
self.is_archived(event)
|
||||
@@ -294,7 +367,6 @@ impl InboxReadState {
|
||||
}
|
||||
}
|
||||
|
||||
/// Newest cutoff that keeps unread events unread, never earlier than `current`.
|
||||
fn advance_cutoff<M>(
|
||||
all: &[Event],
|
||||
me: PublicKey,
|
||||
@@ -321,7 +393,6 @@ where
|
||||
candidate.max(current)
|
||||
}
|
||||
|
||||
/// Drop ids whose event is unknown or now covered by the cutoff.
|
||||
fn prune_ids(ids: &mut HashSet<EventId>, all: &[Event], cutoff: Timestamp) {
|
||||
let created_at: HashMap<EventId, Timestamp> = all
|
||||
.iter()
|
||||
@@ -330,103 +401,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::*;
|
||||
@@ -489,7 +463,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)
|
||||
);
|
||||
}
|
||||
@@ -500,7 +474,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)
|
||||
);
|
||||
}
|
||||
@@ -515,7 +489,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]
|
||||
@@ -524,7 +501,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]
|
||||
@@ -568,7 +548,6 @@ mod tests {
|
||||
assert_eq!(items[0].kind(), Some(Kind::GitIssue));
|
||||
assert_eq!(items[0].title(), "Add retry logic");
|
||||
assert_eq!(items[0].events, vec![reply.clone()]);
|
||||
// The own events are kept apart from the notifications, newest first.
|
||||
assert_eq!(items[0].own_events, vec![mine.clone(), issue.clone()]);
|
||||
assert_eq!(
|
||||
items[0]
|
||||
@@ -579,94 +558,4 @@ mod tests {
|
||||
vec![issue.id, mine.id, reply.id]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_all_read_marks_known_recent_events() {
|
||||
let me = keys(1);
|
||||
let now = Timestamp::from_secs(1_000_000_000);
|
||||
let recent = issue(&keys(2), now.as_secs() - 1000);
|
||||
let old = issue(&keys(2), now.as_secs() - 5 * 24 * 60 * 60);
|
||||
let ancient = issue(&keys(2), now.as_secs() - 20 * 24 * 60 * 60);
|
||||
let mine = issue(&keys(1), now.as_secs() - 100);
|
||||
|
||||
let mut state = InboxReadState::default();
|
||||
state.mark_all_read(
|
||||
&[recent.clone(), old.clone(), ancient.clone(), mine.clone()],
|
||||
me.public_key(),
|
||||
now,
|
||||
);
|
||||
|
||||
assert_eq!(state.read_before, now - MARK_ALL_WINDOW);
|
||||
assert_eq!(state.read_ids, HashSet::from([recent.id, old.id]));
|
||||
assert!(state.is_read(&recent));
|
||||
assert!(state.is_read(&ancient));
|
||||
assert!(!state.is_read(&mine));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_read_never_moves_the_cutoff_backwards() {
|
||||
let me = keys(1);
|
||||
let unread = issue(&keys(2), 1_000);
|
||||
let all = [unread];
|
||||
let now = Timestamp::from_secs(1_000_000_000);
|
||||
|
||||
let mut state = InboxReadState {
|
||||
read_before: Timestamp::from_secs(999_999_999),
|
||||
..Default::default()
|
||||
};
|
||||
state.advance_read(&all, me.public_key(), now);
|
||||
|
||||
assert_eq!(state.read_before, Timestamp::from_secs(999_999_999));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn advance_read_moves_before_the_oldest_unread_and_prunes_ids() {
|
||||
let me = keys(1);
|
||||
let now = Timestamp::from_secs(1_000_000_000);
|
||||
let five_days = 5 * 24 * 60 * 60;
|
||||
let old_unread = issue(&keys(2), now.as_secs() - five_days);
|
||||
// Read ids that fall before and after the new cutoff.
|
||||
let stale = signed(
|
||||
&keys(2),
|
||||
Kind::GitIssue,
|
||||
Vec::new(),
|
||||
now.as_secs() - five_days - 1000,
|
||||
);
|
||||
let fresh = signed(
|
||||
&keys(2),
|
||||
Kind::GitIssue,
|
||||
Vec::new(),
|
||||
now.as_secs() - 100_000,
|
||||
);
|
||||
|
||||
let mut state = InboxReadState {
|
||||
read_ids: HashSet::from([stale.id, fresh.id]),
|
||||
..Default::default()
|
||||
};
|
||||
state.advance_read(
|
||||
&[old_unread.clone(), stale.clone(), fresh.clone()],
|
||||
me.public_key(),
|
||||
now,
|
||||
);
|
||||
|
||||
assert_eq!(state.read_before, old_unread.created_at - 1);
|
||||
assert_eq!(state.read_ids, HashSet::from([fresh.id]));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_archived_skips_events_at_or_before_the_cutoff() {
|
||||
let now = Timestamp::from_secs(1_000_000_000);
|
||||
let event = issue(&keys(2), now.as_secs() - 1000);
|
||||
|
||||
let mut state = InboxReadState {
|
||||
archived_before: now,
|
||||
..Default::default()
|
||||
};
|
||||
state.mark_archived(&event);
|
||||
assert!(state.archived_ids.is_empty());
|
||||
|
||||
let mut state = InboxReadState::default();
|
||||
state.mark_archived(&event);
|
||||
assert_eq!(state.archived_ids, HashSet::from([event.id]));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,15 +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 filters::{
|
||||
NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications,
|
||||
};
|
||||
pub use inbox::{InboxItem, InboxReadState, group, notification_root};
|
||||
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;
|
||||
|
||||
+263
-261
@@ -2,44 +2,30 @@ 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)]
|
||||
pub struct Announcement {
|
||||
/// ID of the announcement event itself.
|
||||
pub event_id: EventId,
|
||||
/// Repository ID, the `d` tag.
|
||||
pub id: String,
|
||||
/// Author of the announcement event.
|
||||
pub owner: PublicKey,
|
||||
/// When the announcement was published, used for latest-wins resolution.
|
||||
pub created_at: Timestamp,
|
||||
pub name: Option<String>,
|
||||
pub description: Option<String>,
|
||||
/// Webpage URLs for browsing.
|
||||
pub web: Vec<Url>,
|
||||
/// URLs for `git clone`.
|
||||
pub clone: Vec<Url>,
|
||||
/// Relays the repository monitors for patches and issues.
|
||||
pub relays: Vec<RelayUrl>,
|
||||
/// Earliest unique commit ID, the `r` tag with `euc` marker.
|
||||
pub euc: Option<String>,
|
||||
/// Other recognized maintainers.
|
||||
pub maintainers: Vec<PublicKey>,
|
||||
/// Marks the repository as a subordinate fork of the upstream, per NIP-34.
|
||||
pub upstream: Option<Upstream>,
|
||||
/// Hashtags labelling the repository, the `t` tags.
|
||||
pub hashtags: Vec<String>,
|
||||
}
|
||||
|
||||
/// The `u` tag of a fork announcement, per NIP-34.
|
||||
// The `u` tag of a fork announcement, per NIP-34.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Upstream {
|
||||
/// Raw first value of the `u` tag, a coordinate or git URL.
|
||||
pub raw: String,
|
||||
/// Upstream repository coordinate when the `u` tag names a NIP-34 repository.
|
||||
/// `None` for the git-URL form.
|
||||
// `None` for the git-URL form.
|
||||
pub addr: Option<RepoAddr>,
|
||||
}
|
||||
|
||||
@@ -49,7 +35,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,216 +51,266 @@ impl Upstream {
|
||||
}
|
||||
}
|
||||
|
||||
/// Subject of a NIP-34 issue or pull request event.
|
||||
/// Taken from the `subject` tag, else the first non-empty line of the content.
|
||||
pub fn activity_subject(event: &Event) -> String {
|
||||
let subject = event
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Subject(subject)) => Some(subject),
|
||||
_ => None,
|
||||
});
|
||||
pub trait GitEvent {
|
||||
fn activity_subject(&self) -> String;
|
||||
|
||||
subject
|
||||
.or_else(|| {
|
||||
event
|
||||
.content
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.map(|value| value.to_string())
|
||||
})
|
||||
.unwrap_or("Untitled".to_string())
|
||||
fn current_commit(&self) -> Option<String>;
|
||||
|
||||
fn merge_base(&self) -> Option<String>;
|
||||
|
||||
fn clone_urls(&self) -> Option<Vec<Url>>;
|
||||
|
||||
fn branch_name(&self) -> Option<String>;
|
||||
|
||||
fn is_git_activity(&self) -> bool;
|
||||
|
||||
// Matches both NIP-10 lowercase `e` and NIP-22 uppercase `E` root pointers.
|
||||
fn references_root(&self, root: &EventId) -> bool;
|
||||
}
|
||||
|
||||
/// The patch set of a pull request.
|
||||
///
|
||||
/// Returns an empty list when no patch event can be linked to the PR.
|
||||
pub fn pull_request_patches<'a>(
|
||||
pr: &Event,
|
||||
patches: impl IntoIterator<Item = &'a Event>,
|
||||
) -> Vec<&'a Event> {
|
||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||
impl GitEvent for Event {
|
||||
fn activity_subject(&self) -> String {
|
||||
let subject = self
|
||||
.tags
|
||||
.iter()
|
||||
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Subject(subject)) => Some(subject),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
subject
|
||||
.or_else(|| {
|
||||
self.content
|
||||
.lines()
|
||||
.map(str::trim)
|
||||
.find(|line| !line.is_empty())
|
||||
.map(|value| value.to_string())
|
||||
})
|
||||
.unwrap_or("Untitled".to_string())
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct PullRequest<'a>(pub &'a Event);
|
||||
|
||||
impl<'a> PullRequest<'a> {
|
||||
pub fn new(event: &'a Event) -> Self {
|
||||
Self(event)
|
||||
}
|
||||
|
||||
// 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);
|
||||
}
|
||||
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 has no `e` tag.
|
||||
// The last patch of the set carries the tip commit in its `commit` or `r` tag.
|
||||
// Walk the reply chain backward to the root.
|
||||
let Some(tip) = current_commit_of(pr) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(last) = patches
|
||||
.iter()
|
||||
.filter(|patch| patch_produces_commit(patch, &tip))
|
||||
.max_by_key(|patch| patch.created_at)
|
||||
.copied()
|
||||
else {
|
||||
return Vec::new();
|
||||
};
|
||||
if let Some(root_id) = pr.tags.event_ids().next()
|
||||
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id)
|
||||
{
|
||||
return Self::forward_series(root, &patches);
|
||||
}
|
||||
|
||||
let mut series = vec![last];
|
||||
loop {
|
||||
let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else {
|
||||
break;
|
||||
let Some(tip) = pr.current_commit() else {
|
||||
return Vec::new();
|
||||
};
|
||||
let Some(prev) = patches
|
||||
let Some(last) = patches
|
||||
.iter()
|
||||
.find(|patch| patch.id == prev_id && !series.contains(patch))
|
||||
.filter(|patch| Self::patch_produces_commit(patch, &tip))
|
||||
.max_by_key(|patch| patch.created_at)
|
||||
.copied()
|
||||
else {
|
||||
break;
|
||||
return Vec::new();
|
||||
};
|
||||
series.push(prev);
|
||||
}
|
||||
series.reverse();
|
||||
series
|
||||
}
|
||||
|
||||
pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a Event>) -> String {
|
||||
let patches: Vec<&'a Event> = patches.into_iter().collect();
|
||||
let series = pull_request_patches(pr, patches.iter().copied());
|
||||
if series.is_empty() {
|
||||
return pr.content.clone();
|
||||
}
|
||||
series
|
||||
.iter()
|
||||
.map(|patch| patch.content.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// The chain of patches replying to `root` via NIP-10 `e` tags, oldest first.
|
||||
fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
|
||||
let mut series = vec![root];
|
||||
loop {
|
||||
let next = patches
|
||||
.iter()
|
||||
.filter(|patch| !series.contains(patch))
|
||||
.filter(|patch| {
|
||||
patch
|
||||
.tags
|
||||
.event_ids()
|
||||
.any(|id| id == series.last().unwrap().id)
|
||||
})
|
||||
.max_by_key(|patch| patch.created_at);
|
||||
let Some(next) = next else {
|
||||
break;
|
||||
};
|
||||
series.push(next);
|
||||
}
|
||||
series
|
||||
}
|
||||
|
||||
/// The `c` tag of an event, the tip of the proposed branch, as hex.
|
||||
pub fn current_commit_of(event: &Event) -> Option<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.
|
||||
///
|
||||
/// 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
|
||||
let mut series = vec![last];
|
||||
loop {
|
||||
let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else {
|
||||
break;
|
||||
};
|
||||
let Some(prev) = patches
|
||||
.iter()
|
||||
.any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str()))
|
||||
})
|
||||
.max_by_key(|e| e.created_at)
|
||||
}
|
||||
|
||||
/// The announced forks of `base` a new pull request compare can be built from.
|
||||
///
|
||||
/// The user's own forks are listed first.
|
||||
pub fn fork_candidates<'a>(
|
||||
announcements: &'a [Announcement],
|
||||
base: &RepoAddr,
|
||||
base_euc: Option<&str>,
|
||||
user: Option<PublicKey>,
|
||||
) -> Vec<&'a Announcement> {
|
||||
let (mut own, mut others) = (Vec::new(), Vec::new());
|
||||
for announcement in announcements {
|
||||
if announcement.clone.is_empty() || !announcement.is_fork_of(base, base_euc) {
|
||||
continue;
|
||||
}
|
||||
if Some(announcement.owner) == user {
|
||||
own.push(announcement);
|
||||
} else {
|
||||
others.push(announcement);
|
||||
.find(|patch| patch.id == prev_id && !series.contains(patch))
|
||||
.copied()
|
||||
else {
|
||||
break;
|
||||
};
|
||||
series.push(prev);
|
||||
}
|
||||
series.reverse();
|
||||
series
|
||||
}
|
||||
own.into_iter().chain(others).collect()
|
||||
}
|
||||
|
||||
/// Whether `patch` produces `commit`, found via its `commit` or `r` tag.
|
||||
///
|
||||
/// It lets clients find existing patches for a specific commit.
|
||||
fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
|
||||
patch
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit,
|
||||
_ => false,
|
||||
})
|
||||
// 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 = self.patches(patches.iter().copied());
|
||||
if series.is_empty() {
|
||||
return pr.content.clone();
|
||||
}
|
||||
series
|
||||
.iter()
|
||||
.map(|patch| patch.content.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
fn forward_series(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
|
||||
let mut series = vec![root];
|
||||
loop {
|
||||
let next = patches
|
||||
.iter()
|
||||
.filter(|patch| !series.contains(patch))
|
||||
.filter(|patch| {
|
||||
patch
|
||||
.tags
|
||||
.event_ids()
|
||||
.any(|id| id == series.last().unwrap().id)
|
||||
})
|
||||
.max_by_key(|patch| patch.created_at);
|
||||
let Some(next) = next else {
|
||||
break;
|
||||
};
|
||||
series.push(next);
|
||||
}
|
||||
series
|
||||
}
|
||||
|
||||
// Lets clients find existing patches for a specific commit.
|
||||
fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
|
||||
patch
|
||||
.tags
|
||||
.iter()
|
||||
.any(|tag| match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit,
|
||||
_ => false,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl Announcement {
|
||||
// The user's own forks are listed first.
|
||||
pub fn forks_in<'a>(
|
||||
announcements: &'a [Announcement],
|
||||
base: &RepoAddr,
|
||||
base_euc: Option<&str>,
|
||||
user: Option<PublicKey>,
|
||||
) -> Vec<&'a Announcement> {
|
||||
let (mut own, mut others) = (Vec::new(), Vec::new());
|
||||
for announcement in announcements {
|
||||
if announcement.clone.is_empty() || !announcement.is_fork_of(base, base_euc) {
|
||||
continue;
|
||||
}
|
||||
if Some(announcement.owner) == user {
|
||||
own.push(announcement);
|
||||
} else {
|
||||
others.push(announcement);
|
||||
}
|
||||
}
|
||||
own.into_iter().chain(others).collect()
|
||||
}
|
||||
|
||||
/// Parse a kind `30617` event.
|
||||
///
|
||||
/// Returns `None` when the kind is wrong or the `d` tag is missing.
|
||||
@@ -337,18 +374,14 @@ 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.
|
||||
pub fn name(&self) -> String {
|
||||
self.name.clone().unwrap_or("Untitled".into())
|
||||
}
|
||||
|
||||
/// Whether this announcement is a fork of the repository at `base`.
|
||||
/// Its `u` tag points at `base`, which also covers permanent forks whose EUC diverged.
|
||||
///
|
||||
/// Or it shares `base`'s earliest unique commit and is not the base itself.
|
||||
// The `u` tag pointing at `base` also covers permanent forks whose EUC diverged.
|
||||
pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool {
|
||||
if self.addr() == *base {
|
||||
return false;
|
||||
@@ -359,17 +392,14 @@ impl Announcement {
|
||||
base_euc.is_some_and(|euc| self.euc.as_deref() == Some(euc))
|
||||
}
|
||||
|
||||
/// The description of the repository, or a default if none is provided.
|
||||
pub fn description(&self) -> String {
|
||||
self.description
|
||||
.clone()
|
||||
.unwrap_or("No description".to_string())
|
||||
}
|
||||
|
||||
/// The effective maintainers of this repository,
|
||||
/// the announced `maintainers` plus the announcement author.
|
||||
///
|
||||
/// A `u` tag that marks the repository as a subordinate fork excludes them, per NIP-34.
|
||||
// A `u` tag marking the repository as a subordinate fork excludes the
|
||||
// announcement author from the maintainers, per NIP-34.
|
||||
pub fn effective_maintainers(&self) -> Vec<PublicKey> {
|
||||
let mut maintainers = self.maintainers.clone();
|
||||
if self.upstream.is_none() && !maintainers.contains(&self.owner) {
|
||||
@@ -378,7 +408,6 @@ impl Announcement {
|
||||
maintainers
|
||||
}
|
||||
|
||||
/// The `git clone` URLs for this repository, deduplicated.
|
||||
pub fn clone_urls(&self) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
self.clone
|
||||
@@ -472,7 +501,6 @@ mod tests {
|
||||
|
||||
let announcement = Announcement::from_event(&event).expect("parses");
|
||||
|
||||
// An invalid URL keeps the whole clone tag from being parsed.
|
||||
assert!(announcement.clone.is_empty());
|
||||
assert_eq!(
|
||||
announcement.relays,
|
||||
@@ -495,11 +523,9 @@ mod tests {
|
||||
let announcement = Announcement::from_event(&event).expect("parses");
|
||||
let upstream = announcement.upstream.expect("parses the u tag");
|
||||
|
||||
// The coordinate part resolves to a repository address.
|
||||
// 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"
|
||||
))
|
||||
@@ -516,34 +542,27 @@ 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",
|
||||
);
|
||||
let event = announcement_event(&[&["d", "my-fork"], &["u", &base.to_string()]]);
|
||||
let fork = Announcement::from_event(&event).expect("parses");
|
||||
|
||||
// A `u` tag pointing at the base address marks a fork.
|
||||
// This holds even when neither side announces an EUC.
|
||||
assert!(fork.is_fork_of(&base, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_fork_of_matches_a_shared_euc() {
|
||||
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
|
||||
// The base repo has no `u` tag. It announces the family EUC.
|
||||
let base_event = announcement_event(&[&["d", "upstream"], &["r", euc, "euc"]]);
|
||||
let base = Announcement::from_event(&base_event).expect("parses");
|
||||
let base_addr = base.addr();
|
||||
|
||||
// A fork with no `u` tag, a pure mirror or cross-hosted clone, shares the EUC.
|
||||
// Clients of the family can then find it.
|
||||
let fork_event = announcement_event(&[&["d", "mirror"], &["r", euc, "euc"]]);
|
||||
let fork = Announcement::from_event(&fork_event).expect("parses");
|
||||
assert!(fork.is_fork_of(&base_addr, base.euc.as_deref()));
|
||||
|
||||
// An unrelated repository with a different EUC is not a fork.
|
||||
let other_event = announcement_event(&[
|
||||
&["d", "other"],
|
||||
&["r", "bb231c4c6a5777dc89b42207b499891a344add5c", "euc"],
|
||||
@@ -551,15 +570,12 @@ mod tests {
|
||||
let other = Announcement::from_event(&other_event).expect("parses");
|
||||
assert!(!other.is_fork_of(&base_addr, base.euc.as_deref()));
|
||||
|
||||
// Without a base EUC there is nothing to compare against.
|
||||
assert!(!fork.is_fork_of(&base_addr, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
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",
|
||||
);
|
||||
@@ -581,8 +597,6 @@ mod tests {
|
||||
let announcement = Announcement::from_event(&event).expect("parses");
|
||||
let maintainers = announcement.effective_maintainers();
|
||||
|
||||
// The owner asserts themselves as a maintainer of the primary project, per NIP-34.
|
||||
// Announced co-maintainers are included too.
|
||||
assert_eq!(maintainers.len(), 2);
|
||||
assert!(maintainers.contains(&announcement.owner));
|
||||
assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")));
|
||||
@@ -599,8 +613,6 @@ mod tests {
|
||||
let announcement = Announcement::from_event(&event).expect("parses");
|
||||
let maintainers = announcement.effective_maintainers();
|
||||
|
||||
// A `u` tag marks the repository as a subordinate fork.
|
||||
// The author is then not a maintainer of the primary project, per NIP-34.
|
||||
assert!(!maintainers.contains(&announcement.owner));
|
||||
assert_eq!(
|
||||
maintainers,
|
||||
@@ -625,18 +637,16 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pull_request_patch_joins_the_whole_patch_set() {
|
||||
// A PR references the root patch, per NIP-34.
|
||||
// Later patches of the set reply to the previous one via NIP-10 `e` tags.
|
||||
let root = patch_event("patch-one", vec![], 100);
|
||||
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
|
||||
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 +658,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()
|
||||
@@ -660,8 +670,6 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn pull_request_patches_finds_the_set_via_the_tip_commit() {
|
||||
// PRs without an `e` tag fall back to the patch producing the tip commit.
|
||||
// Walk the reply chain backward to the root.
|
||||
let root = patch_event("patch-one", vec![], 100);
|
||||
let tip = "1111111111111111111111111111111111111111";
|
||||
let last = patch_event(
|
||||
@@ -677,7 +685,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()
|
||||
@@ -721,7 +729,6 @@ mod tests {
|
||||
created_at,
|
||||
)
|
||||
};
|
||||
// An update revising a different PR must be ignored even though it is newer.
|
||||
let unrelated = signed_at(
|
||||
Kind::GitPullRequestUpdate,
|
||||
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
|
||||
@@ -729,7 +736,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);
|
||||
@@ -749,9 +756,7 @@ mod tests {
|
||||
.finalize(&other)
|
||||
.expect("signed event");
|
||||
|
||||
// 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,12 +789,10 @@ 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",
|
||||
);
|
||||
// Newest first, as RepoListStore keeps them.
|
||||
// Unrelated repo, the user's fork with the shared EUC, another fork with a `u` tag.
|
||||
let all = vec![
|
||||
owned_announcements(
|
||||
2,
|
||||
@@ -819,7 +822,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 +832,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,11 +862,10 @@ 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");
|
||||
|
||||
// Without a base EUC only `u`-tag forks match.
|
||||
all.push(
|
||||
owned_announcements(
|
||||
2,
|
||||
@@ -876,7 +878,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,44 +1,45 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// 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 {
|
||||
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
|
||||
for (name, commit) in refs {
|
||||
tags.push(Tag::parse([name.as_str(), commit.as_str()]).expect("valid ref tag"));
|
||||
}
|
||||
if let Some(head) = head {
|
||||
tags.push(
|
||||
Tag::parse(["HEAD", &format!("ref: refs/heads/{head}")]).expect("valid HEAD tag"),
|
||||
);
|
||||
}
|
||||
EventBuilder::new(Kind::RepoState, "").tags(tags)
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RepoState {
|
||||
pub refs: Vec<(String, String)>,
|
||||
pub head: Option<String>,
|
||||
}
|
||||
|
||||
/// 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>) {
|
||||
let mut refs = Vec::new();
|
||||
let mut head = None;
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Head(branch)) => head = Some(branch),
|
||||
Ok(Nip34Tag::RefHead { branch, commit }) => {
|
||||
refs.push((format!("refs/heads/{branch}"), commit.to_string()));
|
||||
}
|
||||
Ok(Nip34Tag::RefTag { name, commit }) => {
|
||||
refs.push((format!("refs/tags/{name}"), commit.to_string()));
|
||||
}
|
||||
_ => {}
|
||||
impl RepoState {
|
||||
// The `d` tag matches the repository id.
|
||||
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"));
|
||||
}
|
||||
if let Some(head) = head {
|
||||
tags.push(
|
||||
Tag::parse(["HEAD", &format!("ref: refs/heads/{head}")]).expect("valid HEAD tag"),
|
||||
);
|
||||
}
|
||||
EventBuilder::new(Kind::RepoState, "").tags(tags)
|
||||
}
|
||||
|
||||
(refs, head)
|
||||
pub fn parse(event: &Event) -> Self {
|
||||
let mut refs = Vec::new();
|
||||
let mut head = None;
|
||||
|
||||
for tag in event.tags.iter() {
|
||||
match Nip34Tag::parse(tag.as_slice()) {
|
||||
Ok(Nip34Tag::Head(branch)) => head = Some(branch),
|
||||
Ok(Nip34Tag::RefHead { branch, commit }) => {
|
||||
refs.push((format!("refs/heads/{branch}"), commit.to_string()));
|
||||
}
|
||||
Ok(Nip34Tag::RefTag { name, commit }) => {
|
||||
refs.push((format!("refs/tags/{name}"), commit.to_string()));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
Self { refs, head }
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -76,11 +77,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 +91,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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
use nostr::prelude::*;
|
||||
|
||||
/// Status of a root patch, pull request or issue, kinds `1630..=1633`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum RepoStatus {
|
||||
Open,
|
||||
@@ -28,42 +27,30 @@ impl RepoStatus {
|
||||
Self::Draft => Kind::GitStatusDraft,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// NIP-10 and NIP-34 use the lowercase `e` tag.
|
||||
///
|
||||
/// NIP-22 comments, kind `1111`, use the uppercase `E` tag for the thread root.
|
||||
pub fn references_root(event: &Event, root: &EventId) -> bool {
|
||||
let root = root.to_hex();
|
||||
event
|
||||
.tags
|
||||
.iter()
|
||||
.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)
|
||||
// Defaults to Open when no authorized status event exists.
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::GitEvent;
|
||||
|
||||
const ROOT_ID_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111";
|
||||
const OTHER_ID_HEX: &str = "2222222222222222222222222222222222222222222222222222222222222222";
|
||||
@@ -94,11 +81,8 @@ mod tests {
|
||||
))
|
||||
.expect("signed event");
|
||||
|
||||
assert!(references_root(&event, &root));
|
||||
assert!(!references_root(
|
||||
&event,
|
||||
&EventId::from_hex(OTHER_ID_HEX).expect("valid id")
|
||||
));
|
||||
assert!(event.references_root(&root));
|
||||
assert!(!event.references_root(&EventId::from_hex(OTHER_ID_HEX).expect("valid id")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -115,7 +99,7 @@ mod tests {
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
resolve_status(
|
||||
RepoStatus::resolve(
|
||||
statuses.iter(),
|
||||
&owner.public_key(),
|
||||
&[maintainer.public_key()]
|
||||
@@ -140,7 +124,7 @@ mod tests {
|
||||
];
|
||||
|
||||
assert_eq!(
|
||||
resolve_status(
|
||||
RepoStatus::resolve(
|
||||
statuses.iter(),
|
||||
&owner.public_key(),
|
||||
&[maintainer.public_key()]
|
||||
|
||||
Reference in New Issue
Block a user