chore: clean up core crate
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run

This commit is contained in:
2026-10-07 16:46:29 +07:00
parent 5586f67f0e
commit 31256a4870
16 changed files with 1138 additions and 993 deletions
-1
View File
@@ -55,7 +55,6 @@ impl RepoAddr {
.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)
@@ -3,6 +3,7 @@ use std::collections::HashSet;
use nostr::prelude::*;
use crate::RepoAddr;
use crate::upstream::Upstream;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Announcement {
@@ -21,276 +22,8 @@ pub struct Announcement {
pub hashtags: Vec<String>,
}
// The `u` tag of a fork announcement, per NIP-34.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Upstream {
pub raw: String,
// `None` for the git-URL form.
pub addr: Option<RepoAddr>,
}
impl Upstream {
fn parse(raw: &str) -> Self {
let coordinate = raw.split('|').next().unwrap_or(raw);
let addr = coordinate
.parse::<Coordinate>()
.ok()
.filter(|coordinate| coordinate.kind == Kind::GitRepoAnnouncement)
.map(RepoAddr::from);
Self {
raw: raw.to_owned(),
addr,
}
}
pub fn display(&self) -> String {
match &self.addr {
Some(addr) => addr.to_string(),
None => self.raw.clone(),
}
}
}
pub trait GitEvent {
fn activity_subject(&self) -> 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;
}
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.
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();
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 Some(tip) = pr.current_commit() else {
return Vec::new();
};
let Some(last) = patches
.iter()
.filter(|patch| Self::patch_produces_commit(patch, &tip))
.max_by_key(|patch| patch.created_at)
.copied()
else {
return Vec::new();
};
let mut series = vec![last];
loop {
let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else {
break;
};
let Some(prev) = patches
.iter()
.find(|patch| patch.id == prev_id && !series.contains(patch))
.copied()
else {
break;
};
series.push(prev);
}
series.reverse();
series
}
// 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.
/// Lists forks of the base, the user's own first.
pub fn forks_in<'a>(
announcements: &'a [Announcement],
base: &RepoAddr,
@@ -311,9 +44,7 @@ impl 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.
/// Parses a kind 30617 event, rejecting other kinds and missing `d` tags.
pub fn from_event(event: &Event) -> Option<Self> {
if event.kind != Kind::GitRepoAnnouncement {
return None;
@@ -345,8 +76,6 @@ impl Announcement {
_ => {}
}
// The SDK's `Nip34Tag` does not model the `u` tag, so parse it manually.
// Only the first `u` tag is used.
if upstream.is_none() && tag.kind() == "u" {
let values = tag.as_slice();
let raw = values.get(1).map(String::as_str).unwrap_or_default();
@@ -373,15 +102,17 @@ impl Announcement {
})
}
/// Returns the owner and identifier as a repository address.
pub fn addr(&self) -> RepoAddr {
RepoAddr::new(self.owner, self.id.clone())
}
/// Returns the name, defaulting to "Untitled".
pub fn name(&self) -> String {
self.name.clone().unwrap_or("Untitled".into())
}
// The `u` tag pointing at `base` also covers permanent forks whose EUC diverged.
/// Matches a `u` tag pointing at the base or a shared earliest unique commit.
pub fn is_fork_of(&self, base: &RepoAddr, base_euc: Option<&str>) -> bool {
if self.addr() == *base {
return false;
@@ -392,14 +123,14 @@ impl Announcement {
base_euc.is_some_and(|euc| self.euc.as_deref() == Some(euc))
}
/// Returns the description, defaulting to "No description".
pub fn description(&self) -> String {
self.description
.clone()
.unwrap_or("No description".to_string())
}
// A `u` tag marking the repository as a subordinate fork excludes the
// announcement author from the maintainers, per NIP-34.
/// Adds the owner to the maintainers unless a `u` tag marks this a subordinate fork.
pub fn effective_maintainers(&self) -> Vec<PublicKey> {
let mut maintainers = self.maintainers.clone();
if self.upstream.is_none() && !maintainers.contains(&self.owner) {
@@ -408,6 +139,7 @@ impl Announcement {
maintainers
}
/// Returns deduplicated `git clone` commands for the advertised URLs.
pub fn clone_urls(&self) -> Vec<String> {
let mut seen = HashSet::new();
self.clone
@@ -424,6 +156,7 @@ mod tests {
const MAINTAINER_HEX: &str = "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272";
/// Returns the test signer keys.
fn keys() -> Keys {
Keys::new(
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
@@ -431,6 +164,7 @@ mod tests {
)
}
/// Builds a signed repo announcement event from raw tags.
fn announcement_event(tags: &[&[&str]]) -> Event {
let tags: Vec<Tag> = tags
.iter()
@@ -443,6 +177,7 @@ mod tests {
.expect("signed event")
}
/// Parses an event carrying every supported tag.
#[test]
fn parses_full_announcement() {
let event = announcement_event(&[
@@ -490,6 +225,7 @@ mod tests {
assert_eq!(announcement.hashtags, vec!["rust", "nostr"]);
}
/// Skips malformed clone, relay, and maintainer values.
#[test]
fn drops_malformed_values() {
let event = announcement_event(&[
@@ -509,6 +245,7 @@ mod tests {
assert!(announcement.maintainers.is_empty());
}
/// Parses the `u` tag into an upstream coordinate.
#[test]
fn parses_upstream_tag() {
let event = announcement_event(&[
@@ -540,6 +277,7 @@ mod tests {
);
}
/// Matches a fork through its `u` tag coordinate.
#[test]
fn is_fork_of_matches_the_u_tag_coordinate() {
let base = RepoAddr::new(
@@ -552,6 +290,7 @@ mod tests {
assert!(fork.is_fork_of(&base, None));
}
/// Matches forks sharing the base's earliest unique commit.
#[test]
fn is_fork_of_matches_a_shared_euc() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
@@ -573,6 +312,7 @@ mod tests {
assert!(!fork.is_fork_of(&base_addr, None));
}
/// Matches permanent forks whose `u` tag points at the base despite a diverged EUC.
#[test]
fn is_fork_of_matches_permanent_forks_with_a_diverged_euc() {
let base = RepoAddr::new(
@@ -590,6 +330,7 @@ mod tests {
assert!(fork.is_fork_of(&base, Some(base_euc)));
}
/// Includes the owner among maintainers for primary repositories.
#[test]
fn effective_maintainers_include_owner_for_primary_repos() {
let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]);
@@ -602,6 +343,7 @@ mod tests {
assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")));
}
/// Excludes the owner from maintainers for subordinate forks.
#[test]
fn effective_maintainers_exclude_owner_for_subordinate_forks() {
let event = announcement_event(&[
@@ -620,151 +362,13 @@ mod tests {
);
}
fn pr_event(content: &str, tags: Vec<Tag>) -> Event {
EventBuilder::new(Kind::GitPullRequest, content)
.tags(tags)
.finalize(&keys())
.expect("signed event")
}
fn patch_event(content: &str, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(Kind::GitPatch, content)
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.finalize(&keys())
.expect("signed event")
}
#[test]
fn pull_request_patch_joins_the_whole_patch_set() {
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!(
PullRequest::new(&pr).patch([&root, &second]),
"patch-one\npatch-two"
);
assert_eq!(
PullRequest::new(&pr).patches([&root, &second]),
vec![&root, &second]
);
}
#[test]
fn pull_request_patches_walks_the_reply_chain_in_order() {
let root = patch_event("patch-one", vec![], 100);
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
let third = patch_event("patch-three", vec![Tag::event(second.id)], 300);
let pr = pr_event("description", vec![Tag::event(root.id)]);
let series = PullRequest::new(&pr).patches([&third, &root, &second]);
assert_eq!(
series
.iter()
.map(|p| p.content.as_str())
.collect::<Vec<_>>(),
vec!["patch-one", "patch-two", "patch-three"]
);
}
#[test]
fn pull_request_patches_finds_the_set_via_the_tip_commit() {
let root = patch_event("patch-one", vec![], 100);
let tip = "1111111111111111111111111111111111111111";
let last = patch_event(
"patch-two",
vec![
Tag::event(root.id),
Tag::parse(["r", tip]).expect("valid tag"),
],
200,
);
let pr = pr_event(
"description",
vec![Tag::parse(["c", tip]).expect("valid tag")],
);
let series = PullRequest::new(&pr).patches([&root, &last]);
assert_eq!(
series
.iter()
.map(|p| p.content.as_str())
.collect::<Vec<_>>(),
vec!["patch-one", "patch-two"]
);
}
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222";
fn signed_at(kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.finalize(&keys())
.expect("signed event")
}
fn pr_root() -> Event {
signed_at(
Kind::GitPullRequest,
vec![
Tag::parse(["c", COMMIT_HEX]).expect("valid tag"),
Tag::parse(["branch-name", "feature/x"]).expect("valid tag"),
],
100,
)
}
#[test]
fn latest_update_picks_newest_revision_of_the_root() {
let root = pr_root();
let root_hex = root.id.to_hex();
let revision = |created_at: u64| {
signed_at(
Kind::GitPullRequestUpdate,
vec![Tag::parse(["E", &root_hex]).expect("valid tag")],
created_at,
)
};
let unrelated = signed_at(
Kind::GitPullRequestUpdate,
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
999,
);
let events = [unrelated, revision(200), root.clone(), revision(300)];
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);
}
#[test]
fn latest_update_ignores_other_authors() {
let root = pr_root();
let root_hex = root.id.to_hex();
let other = Keys::new(
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
.expect("valid secret key"),
);
let stranger = EventBuilder::new(Kind::GitPullRequestUpdate, "")
.tags([Tag::parse(["E", &root_hex]).expect("valid tag")])
.custom_created_at(Timestamp::from(999))
.finalize(&other)
.expect("signed event");
assert!(PullRequest::latest_update([&stranger, &root].into_iter(), &root).is_none());
}
const OWNER_KEYS: [&str; 3] = [
"0000000000000000000000000000000000000000000000000000000000000001",
"0000000000000000000000000000000000000000000000000000000000000002",
"0000000000000000000000000000000000000000000000000000000000000003",
];
/// Builds an announcement event signed by the given owner key.
fn owned_announcement_event(owner: &str, tags: &[&[&str]]) -> Event {
let keys = Keys::new(SecretKey::from_hex(owner).expect("valid secret key"));
let tags: Vec<Tag> = tags
@@ -777,6 +381,7 @@ mod tests {
.expect("signed event")
}
/// Builds one announcement owned by the owner key at the given index.
fn owned_announcements(owner_ix: usize, tags: &[&[&str]]) -> Vec<Announcement> {
vec![
Announcement::from_event(&owned_announcement_event(OWNER_KEYS[owner_ix], tags))
@@ -784,6 +389,7 @@ mod tests {
]
}
/// Places the user's own forks before everyone else's.
#[test]
fn fork_candidates_orders_own_forks_first() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
@@ -828,6 +434,7 @@ mod tests {
assert_eq!(ids, vec!["my-fork", "their-fork"]);
}
/// Excludes the base itself, unrelated repos, and repos without clone URLs.
#[test]
fn fork_candidates_excludes_base_unrelated_and_unfetchable() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
+6 -4
View File
@@ -4,13 +4,16 @@ use nostr::prelude::*;
pub struct Deletions {
ids: HashSet<(EventId, PublicKey)>,
// All versions of the addressable event up to `cutoff` are deleted.
coords: Vec<(Coordinate, PublicKey, Timestamp)>,
vanished: Vec<(PublicKey, Timestamp)>,
}
impl Deletions {
pub fn from_events(events: impl IntoIterator<Item = Event>) -> Self {
/// Builds a [`Deletions`] instance from a collection of events.
pub fn from_events<E>(events: E) -> Self
where
E: IntoIterator<Item = Event>,
{
let mut ids = HashSet::new();
let mut coords = Vec::new();
let mut vanished = Vec::new();
@@ -38,8 +41,7 @@ impl Deletions {
}
}
// 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`.
/// Returns whether the given event is deleted.
pub fn is_deleted(&self, event: &Event) -> bool {
if self
.vanished
+3 -7
View File
@@ -81,7 +81,7 @@ impl Filters {
Filter::new().kind(Kind::Custom(10018)).author(public_key)
}
// Replaceable events, so the latest of each kind is all we need.
/// Replaceable events, so the latest of each kind is all we need.
pub fn user_metadata(public_key: PublicKey) -> Vec<Filter> {
vec![
Self::grasp_list(public_key).limit(1),
@@ -106,12 +106,13 @@ impl Filters {
]
}
// 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)
@@ -129,7 +130,6 @@ impl Filters {
.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),
@@ -137,8 +137,6 @@ impl Filters {
]
}
// 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)
}
@@ -151,13 +149,11 @@ impl Filters {
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()
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
+145
View File
@@ -0,0 +1,145 @@
use nostr::prelude::*;
pub trait GitEvent {
/// Returns the NIP-34 subject or the first non-empty content line.
fn activity_subject(&self) -> String;
/// Returns the current commit id.
fn current_commit(&self) -> Option<String>;
/// Returns the merge base commit id.
fn merge_base(&self) -> Option<String>;
/// Returns the advertised clone URLs.
fn clone_urls(&self) -> Option<Vec<Url>>;
/// Returns the branch name.
fn branch_name(&self) -> Option<String>;
/// Returns whether the event is git activity.
fn is_git_activity(&self) -> bool;
/// Returns whether an `e` or `E` tag points at the root event.
fn references_root(&self, root: &EventId) -> bool;
}
impl GitEvent for Event {
/// Reads the subject tag, then the first content line, then "Untitled".
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())
}
/// Reads the `commit` tag.
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,
})
}
/// Reads the `merge-base` tag.
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,
})
}
/// Reads the `clone` tag.
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,
})
}
/// Reads the `branch-name` tag.
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,
})
}
/// Classifies git issue, patch, pull request, comment, and status kinds.
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,
}
}
/// Compares `e` and `E` tag contents against the root id.
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 {
/// Delegates to the referenced event.
fn activity_subject(&self) -> String {
(*self).activity_subject()
}
/// Delegates to the referenced event.
fn current_commit(&self) -> Option<String> {
(*self).current_commit()
}
/// Delegates to the referenced event.
fn merge_base(&self) -> Option<String> {
(*self).merge_base()
}
/// Delegates to the referenced event.
fn clone_urls(&self) -> Option<Vec<Url>> {
(*self).clone_urls()
}
/// Delegates to the referenced event.
fn branch_name(&self) -> Option<String> {
(*self).branch_name()
}
/// Delegates to the referenced event.
fn is_git_activity(&self) -> bool {
(*self).is_git_activity()
}
/// Delegates to the referenced event.
fn references_root(&self, root: &EventId) -> bool {
(*self).references_root(root)
}
}
-561
View File
@@ -1,561 +0,0 @@
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use nostr::prelude::*;
use serde::{Deserialize, Serialize};
use crate::{GitEvent, RepoAddr};
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60);
#[derive(Debug, Clone)]
pub struct InboxItem {
pub root: EventId,
pub root_event: Option<Event>,
pub address: Option<RepoAddr>,
pub events: Vec<Event>,
pub own_events: Vec<Event>,
pub unread_ids: Vec<EventId>,
pub archived: bool,
}
impl InboxItem {
pub fn title(&self) -> String {
self.root_event
.as_ref()
.or_else(|| self.own_events.first())
.or_else(|| self.events.first())
.map(|event| event.activity_subject())
.unwrap_or_else(|| "Untitled".to_string())
}
pub fn kind(&self) -> Option<Kind> {
self.root_event
.as_ref()
.or_else(|| self.own_events.first())
.or_else(|| self.events.first())
.map(|event| event.kind)
}
pub fn latest_activity(&self) -> Timestamp {
self.root_event
.as_ref()
.into_iter()
.chain(self.own_events.first())
.chain(self.events.first())
.map(|event| event.created_at)
.max()
.unwrap_or_default()
}
pub fn timeline(&self, limit: usize) -> Vec<Event> {
let mut seen: HashSet<EventId> = HashSet::new();
let mut events: Vec<Event> = Vec::new();
if let Some(root) = &self.root_event {
seen.insert(root.id);
events.push(root.clone());
}
let mut rest: Vec<Event> = self
.own_events
.iter()
.chain(self.events.iter())
.filter(|event| seen.insert(event.id))
.cloned()
.collect();
rest.sort_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
});
rest.truncate(limit.saturating_sub(events.len()));
events.extend(rest);
events.sort_by_key(|event| event.created_at);
events
}
pub fn is_unread(&self) -> bool {
!self.archived && !self.unread_ids.is_empty()
}
pub fn apply_state(&mut self, state: &InboxReadState) {
self.unread_ids = self
.events
.iter()
.rev()
.filter(|event| !state.is_read(event))
.map(|event| event.id)
.collect();
// A thread without notification events is never archived.
self.archived =
!self.events.is_empty() && self.events.iter().all(|event| state.is_archived(event));
}
}
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 }
}
// 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,
}
}
// 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,
}
}
}
// 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())
}
}
pub fn group<E, O, L>(
events: E,
own: O,
me: PublicKey,
state: &InboxReadState,
lookup: &L,
) -> Vec<InboxItem>
where
E: IntoIterator<Item = Event>,
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) = resolver.notification_root(&event) else {
continue;
};
groups.entry(root).or_default().push(event);
}
let mut own_groups: HashMap<EventId, Vec<Event>> = HashMap::new();
for event in own {
let root = resolver.notification_root(&event).unwrap_or(event.id);
own_groups.entry(root).or_default().push(event);
}
let mut roots: Vec<EventId> = groups.keys().chain(own_groups.keys()).copied().collect();
roots.sort();
roots.dedup();
let mut items: Vec<InboxItem> = roots
.into_iter()
.map(|root| {
let mut events = groups.remove(&root).unwrap_or_default();
let mut own_events = own_groups.remove(&root).unwrap_or_default();
utils::sort_newest_first(&mut events);
utils::sort_newest_first(&mut own_events);
let root_event = lookup(root);
let mut item = InboxItem {
root,
address: root_event
.as_ref()
.and_then(|event| event.tags.coordinates().next())
.map(RepoAddr::from),
root_event,
events,
own_events,
unread_ids: Vec::new(),
archived: false,
};
item.apply_state(state);
item
})
.collect();
items.sort_by(|a, b| {
b.latest_activity()
.cmp(&a.latest_activity())
.then_with(|| b.root.to_hex().cmp(&a.root.to_hex()))
});
items
}
// 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)]
pub read_before: Timestamp,
#[serde(default)]
pub read_ids: HashSet<EventId>,
#[serde(default)]
pub archived_before: Timestamp,
#[serde(default)]
pub archived_ids: HashSet<EventId>,
}
impl InboxReadState {
pub fn is_read(&self, event: &Event) -> bool {
event.created_at <= self.read_before || self.read_ids.contains(&event.id)
}
pub fn is_archived(&self, event: &Event) -> bool {
event.created_at <= self.archived_before || self.archived_ids.contains(&event.id)
}
pub fn mark_read(&mut self, event: &Event) {
if event.created_at > self.read_before {
self.read_ids.insert(event.id);
}
}
pub fn mark_archived(&mut self, event: &Event) {
if event.created_at > self.archived_before {
self.archived_ids.insert(event.id);
}
}
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
let cutoff = now - MARK_ALL_WINDOW;
self.read_before = cutoff;
self.read_ids = all
.iter()
.filter(|event| event.pubkey != me && event.created_at > cutoff)
.map(|event| event.id)
.collect();
}
// 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);
}
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)
});
self.archived_before = cutoff;
prune_ids(&mut self.archived_ids, all, cutoff);
}
}
fn advance_cutoff<M>(
all: &[Event],
me: PublicKey,
now: Timestamp,
current: Timestamp,
is_marked: M,
) -> Timestamp
where
M: Fn(&Event) -> bool,
{
let fallback = now - ADVANCE_WINDOW;
let oldest = all
.iter()
.filter(|event| event.pubkey != me && !is_marked(event))
.map(|event| event.created_at)
.min();
let candidate = match oldest {
Some(at) if at < fallback => at - 1,
_ => fallback,
};
candidate.max(current)
}
fn prune_ids(ids: &mut HashSet<EventId>, all: &[Event], cutoff: Timestamp) {
let created_at: HashMap<EventId, Timestamp> = all
.iter()
.map(|event| (event.id, event.created_at))
.collect();
ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff));
}
#[cfg(test)]
mod tests {
use super::*;
fn keys(seed: u8) -> Keys {
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
hex.push_str(&format!("{seed:02x}"));
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
}
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from_secs(created_at))
.finalize(author)
.expect("signed event")
}
fn e_tag(event: &Event) -> Tag {
Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag")
}
fn marked_e_tag(event: &Event, marker: &str) -> Tag {
Tag::parse(["e", &event.id.to_hex(), "wss://relay.example.com", marker])
.expect("valid e tag")
}
fn uppercase_e_tag(event: &Event) -> Tag {
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
}
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
move |id| events.iter().find(|event| event.id == id).cloned()
}
fn issue(author: &Keys, at: u64) -> Event {
signed(author, Kind::GitIssue, Vec::new(), at)
}
fn titled_issue(author: &Keys, title: &str, at: u64) -> Event {
signed(
author,
Kind::GitIssue,
vec![Tag::parse(["subject", title]).expect("valid subject tag")],
at,
)
}
#[test]
fn comment_resolves_to_its_uppercase_root() {
let issue = issue(&keys(1), 100);
let comment = signed(
&keys(2),
Kind::Comment,
vec![
uppercase_e_tag(&issue),
Tag::parse(["K", "1621"]).expect("valid K tag"),
],
200,
);
let events = [issue.clone(), comment.clone()];
assert_eq!(
ThreadResolver::new(&lookup(&events)).notification_root(&comment),
Some(issue.id)
);
}
#[test]
fn child_patch_resolves_to_the_root_patch() {
let root_patch = signed(&keys(1), Kind::GitPatch, Vec::new(), 100);
let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200);
let events = [root_patch.clone(), child_patch.clone()];
assert_eq!(
ThreadResolver::new(&lookup(&events)).notification_root(&child_patch),
Some(root_patch.id)
);
}
#[test]
fn status_resolves_via_the_root_marker() {
let issue = issue(&keys(1), 100);
let status = signed(
&keys(2),
Kind::GitStatusClosed,
vec![marked_e_tag(&issue, "root")],
200,
);
let events = [issue.clone(), status.clone()];
assert_eq!(
ThreadResolver::new(&lookup(&events)).notification_root(&status),
Some(issue.id)
);
}
#[test]
fn nested_comment_chain_follows_to_the_root() {
let issue = issue(&keys(1), 100);
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!(
ThreadResolver::new(&lookup(&events)).notification_root(&nested),
Some(issue.id)
);
}
#[test]
fn group_merges_own_events_into_the_matching_thread() {
let me = keys(1);
let issue = titled_issue(&me, "Add retry logic", 100);
let mine = signed(
&me,
Kind::Comment,
vec![
uppercase_e_tag(&issue),
Tag::parse(["K", "1621"]).expect("K tag"),
],
150,
);
let reply = signed(
&keys(2),
Kind::Comment,
vec![
uppercase_e_tag(&issue),
Tag::parse(["K", "1621"]).expect("K tag"),
],
200,
);
let context = [issue.clone(), mine.clone(), reply.clone()];
let items = group(
[reply.clone()],
[issue.clone(), mine.clone()],
me.public_key(),
&InboxReadState::default(),
&lookup(&context),
);
assert_eq!(items.len(), 1);
assert_eq!(items[0].root, issue.id);
assert_eq!(
items[0].root_event.as_ref().map(|event| event.id),
Some(issue.id)
);
assert_eq!(items[0].kind(), Some(Kind::GitIssue));
assert_eq!(items[0].title(), "Add retry logic");
assert_eq!(items[0].events, vec![reply.clone()]);
assert_eq!(items[0].own_events, vec![mine.clone(), issue.clone()]);
assert_eq!(
items[0]
.timeline(5)
.iter()
.map(|event| event.id)
.collect::<Vec<_>>(),
vec![issue.id, mine.id, reply.id]
);
}
}
+172
View File
@@ -0,0 +1,172 @@
use std::collections::HashMap;
use nostr::prelude::*;
use utils::sort_newest_first;
use super::{InboxItem, InboxReadState, ThreadResolver};
use crate::RepoAddr;
/// Groups events into inbox threads, merging the user's own events.
pub fn group<E, O, L>(
events: E,
own: O,
me: PublicKey,
state: &InboxReadState,
lookup: &L,
) -> Vec<InboxItem>
where
E: IntoIterator<Item = Event>,
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) = resolver.notification_root(&event) else {
continue;
};
groups.entry(root).or_default().push(event);
}
let mut own_groups: HashMap<EventId, Vec<Event>> = HashMap::new();
for event in own {
let root = resolver.notification_root(&event).unwrap_or(event.id);
own_groups.entry(root).or_default().push(event);
}
let mut roots: Vec<EventId> = groups.keys().chain(own_groups.keys()).copied().collect();
roots.sort();
roots.dedup();
let mut items: Vec<InboxItem> = roots
.into_iter()
.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);
let root_event = lookup(root);
let mut item = InboxItem {
root,
address: root_event
.as_ref()
.and_then(|event| event.tags.coordinates().next())
.map(RepoAddr::from),
root_event,
events,
own_events,
unread_ids: Vec::new(),
archived: false,
};
item.apply_state(state);
item
})
.collect();
items.sort_by(|a, b| {
b.latest_activity()
.cmp(&a.latest_activity())
.then_with(|| b.root.to_hex().cmp(&a.root.to_hex()))
});
items
}
#[cfg(test)]
mod tests {
use super::*;
/// Derives deterministic signer keys from a seed byte.
fn keys(seed: u8) -> Keys {
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
hex.push_str(&format!("{seed:02x}"));
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
}
/// Builds a signed event with a fixed timestamp.
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from_secs(created_at))
.finalize(author)
.expect("signed event")
}
/// Builds an uppercase `E` tag for the event.
fn uppercase_e_tag(event: &Event) -> Tag {
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
}
/// Builds an event lookup closure over a slice.
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
move |id| events.iter().find(|event| event.id == id).cloned()
}
/// Builds a git issue event with a subject tag.
fn titled_issue(author: &Keys, title: &str, at: u64) -> Event {
signed(
author,
Kind::GitIssue,
vec![Tag::parse(["subject", title]).expect("valid subject tag")],
at,
)
}
/// Merges the user's own events into the matching thread.
#[test]
fn group_merges_own_events_into_the_matching_thread() {
let me = keys(1);
let issue = titled_issue(&me, "Add retry logic", 100);
let mine = signed(
&me,
Kind::Comment,
vec![
uppercase_e_tag(&issue),
Tag::parse(["K", "1621"]).expect("K tag"),
],
150,
);
let reply = signed(
&keys(2),
Kind::Comment,
vec![
uppercase_e_tag(&issue),
Tag::parse(["K", "1621"]).expect("K tag"),
],
200,
);
let context = [issue.clone(), mine.clone(), reply.clone()];
let items = group(
[reply.clone()],
[issue.clone(), mine.clone()],
me.public_key(),
&InboxReadState::default(),
&lookup(&context),
);
assert_eq!(items.len(), 1);
assert_eq!(items[0].root, issue.id);
assert_eq!(
items[0].root_event.as_ref().map(|event| event.id),
Some(issue.id)
);
assert_eq!(items[0].kind(), Some(Kind::GitIssue));
assert_eq!(items[0].title(), "Add retry logic");
assert_eq!(items[0].events, vec![reply.clone()]);
assert_eq!(items[0].own_events, vec![mine.clone(), issue.clone()]);
assert_eq!(
items[0]
.timeline(5)
.iter()
.map(|event| event.id)
.collect::<Vec<_>>(),
vec![issue.id, mine.id, reply.id]
);
}
}
+99
View File
@@ -0,0 +1,99 @@
use std::collections::HashSet;
use nostr::prelude::*;
use super::InboxReadState;
use crate::{GitEvent, RepoAddr};
#[derive(Debug, Clone)]
pub struct InboxItem {
pub root: EventId,
pub root_event: Option<Event>,
pub address: Option<RepoAddr>,
pub events: Vec<Event>,
pub own_events: Vec<Event>,
pub unread_ids: Vec<EventId>,
pub archived: bool,
}
impl InboxItem {
/// Returns the thread title from the root, own events, or events.
pub fn title(&self) -> String {
self.root_event
.as_ref()
.or_else(|| self.own_events.first())
.or_else(|| self.events.first())
.map(|event| event.activity_subject())
.unwrap_or_else(|| "Untitled".to_string())
}
/// Returns the kind of the thread's primary event.
pub fn kind(&self) -> Option<Kind> {
self.root_event
.as_ref()
.or_else(|| self.own_events.first())
.or_else(|| self.events.first())
.map(|event| event.kind)
}
/// Returns the newest timestamp among the root, own, and other events.
pub fn latest_activity(&self) -> Timestamp {
self.root_event
.as_ref()
.into_iter()
.chain(self.own_events.first())
.chain(self.events.first())
.map(|event| event.created_at)
.max()
.unwrap_or_default()
}
/// Builds a chronological timeline capped at `limit` events, root first.
pub fn timeline(&self, limit: usize) -> Vec<Event> {
let mut seen: HashSet<EventId> = HashSet::new();
let mut events: Vec<Event> = Vec::new();
if let Some(root) = &self.root_event {
seen.insert(root.id);
events.push(root.clone());
}
let mut rest: Vec<Event> = self
.own_events
.iter()
.chain(self.events.iter())
.filter(|event| seen.insert(event.id))
.cloned()
.collect();
rest.sort_by(|a, b| {
b.created_at
.cmp(&a.created_at)
.then_with(|| b.id.to_hex().cmp(&a.id.to_hex()))
});
rest.truncate(limit.saturating_sub(events.len()));
events.extend(rest);
events.sort_by_key(|event| event.created_at);
events
}
/// Returns whether the thread has unread events and is not archived.
pub fn is_unread(&self) -> bool {
!self.archived && !self.unread_ids.is_empty()
}
/// Recomputes unread ids and the archived flag from the read state.
pub fn apply_state(&mut self, state: &InboxReadState) {
self.unread_ids = self
.events
.iter()
.rev()
.filter(|event| !state.is_read(event))
.map(|event| event.id)
.collect();
self.archived =
!self.events.is_empty() && self.events.iter().all(|event| state.is_archived(event));
}
}
+9
View File
@@ -0,0 +1,9 @@
mod group;
mod item;
mod read_state;
mod resolver;
pub use group::group;
pub use item::InboxItem;
pub use read_state::InboxReadState;
pub use resolver::ThreadResolver;
+109
View File
@@ -0,0 +1,109 @@
use std::collections::{HashMap, HashSet};
use std::time::Duration;
use nostr::prelude::*;
use serde::{Deserialize, Serialize};
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
const MARK_ALL_WINDOW: Duration = Duration::from_secs(10 * 24 * 60 * 60);
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct InboxReadState {
#[serde(default)]
pub read_before: Timestamp,
#[serde(default)]
pub read_ids: HashSet<EventId>,
#[serde(default)]
pub archived_before: Timestamp,
#[serde(default)]
pub archived_ids: HashSet<EventId>,
}
impl InboxReadState {
/// Returns whether the event falls under the read high-water mark or id set.
pub fn is_read(&self, event: &Event) -> bool {
event.created_at <= self.read_before || self.read_ids.contains(&event.id)
}
/// Returns whether the event falls under the archived high-water mark or id set.
pub fn is_archived(&self, event: &Event) -> bool {
event.created_at <= self.archived_before || self.archived_ids.contains(&event.id)
}
/// Records the event as read when it is past the current cutoff.
pub fn mark_read(&mut self, event: &Event) {
if event.created_at > self.read_before {
self.read_ids.insert(event.id);
}
}
/// Records the event as archived when it is past the current cutoff.
pub fn mark_archived(&mut self, event: &Event) {
if event.created_at > self.archived_before {
self.archived_ids.insert(event.id);
}
}
/// Marks all events from other authors within the window as read.
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, now: Timestamp) {
let cutoff = now - MARK_ALL_WINDOW;
self.read_before = cutoff;
self.read_ids = all
.iter()
.filter(|event| event.pubkey != me && event.created_at > cutoff)
.map(|event| event.id)
.collect();
}
/// Advances the read cutoff as far as possible, then prunes 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);
}
/// Advances the archived cutoff as far as possible, then prunes the id set.
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)
});
self.archived_before = cutoff;
prune_ids(&mut self.archived_ids, all, cutoff);
}
}
/// Computes the newest cutoff that leaves unmarked events uncovered.
fn advance_cutoff<M>(
all: &[Event],
me: PublicKey,
now: Timestamp,
current: Timestamp,
is_marked: M,
) -> Timestamp
where
M: Fn(&Event) -> bool,
{
let fallback = now - ADVANCE_WINDOW;
let oldest = all
.iter()
.filter(|event| event.pubkey != me && !is_marked(event))
.map(|event| event.created_at)
.min();
let candidate = match oldest {
Some(at) if at < fallback => at - 1,
_ => fallback,
};
candidate.max(current)
}
/// Drops ids whose events predate the cutoff.
fn prune_ids(ids: &mut HashSet<EventId>, all: &[Event], cutoff: Timestamp) {
let created_at: HashMap<EventId, Timestamp> = all
.iter()
.map(|event| (event.id, event.created_at))
.collect();
ids.retain(|id| created_at.get(id).is_some_and(|at| *at >= cutoff));
}
+249
View File
@@ -0,0 +1,249 @@
use std::collections::HashSet;
use nostr::prelude::*;
pub struct ThreadResolver<'a, L: ?Sized> {
lookup: &'a L,
}
impl<'a, L> ThreadResolver<'a, L>
where
L: Fn(EventId) -> Option<Event> + ?Sized,
{
/// Creates a resolver over the given event lookup.
pub fn new(lookup: &'a L) -> Self {
Self { lookup }
}
/// Maps a git event kind to the thread root it notifies about.
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,
}
}
/// Follows parent pointers until a git issue or pull request root 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,
}
}
}
/// Mirrors gitworkshop's `getParentId` parent resolution.
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)
}
/// Reads the NIP-10 `root` marker, falling back to 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))
}
/// Reads the first lowercase `e` tag id.
fn first_e_id(&self, event: &Event) -> Option<EventId> {
self.first_tag_id(event, "e")
}
/// Reads the first uppercase `E` tag id.
fn first_uppercase_e_id(&self, event: &Event) -> Option<EventId> {
self.first_tag_id(event, "E")
}
/// Reads the first event id from tags of the given kind.
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())
})
}
/// Parses a four-value `e` tag carrying the given 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())
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Derives deterministic signer keys from a seed byte.
fn keys(seed: u8) -> Keys {
let mut hex = "00000000000000000000000000000000000000000000000000000000000000".to_string();
hex.push_str(&format!("{seed:02x}"));
Keys::new(SecretKey::from_hex(&hex).expect("valid secret key"))
}
/// Builds a signed event with a fixed timestamp.
fn signed(author: &Keys, kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from_secs(created_at))
.finalize(author)
.expect("signed event")
}
/// Builds a plain lowercase `e` tag for the event.
fn e_tag(event: &Event) -> Tag {
Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag")
}
/// Builds a four-value `e` tag with the given marker.
fn marked_e_tag(event: &Event, marker: &str) -> Tag {
Tag::parse(["e", &event.id.to_hex(), "wss://relay.example.com", marker])
.expect("valid e tag")
}
/// Builds an uppercase `E` tag for the event.
fn uppercase_e_tag(event: &Event) -> Tag {
Tag::parse(["E", &event.id.to_hex()]).expect("valid E tag")
}
/// Builds an event lookup closure over a slice.
fn lookup(events: &[Event]) -> impl Fn(EventId) -> Option<Event> + '_ {
move |id| events.iter().find(|event| event.id == id).cloned()
}
/// Builds a git issue event.
fn issue(author: &Keys, at: u64) -> Event {
signed(author, Kind::GitIssue, Vec::new(), at)
}
/// Resolves a comment to its uppercase `E` root.
#[test]
fn comment_resolves_to_its_uppercase_root() {
let issue = issue(&keys(1), 100);
let comment = signed(
&keys(2),
Kind::Comment,
vec![
uppercase_e_tag(&issue),
Tag::parse(["K", "1621"]).expect("valid K tag"),
],
200,
);
let events = [issue.clone(), comment.clone()];
assert_eq!(
ThreadResolver::new(&lookup(&events)).notification_root(&comment),
Some(issue.id)
);
}
/// Resolves a child patch to its root patch.
#[test]
fn child_patch_resolves_to_the_root_patch() {
let root_patch = signed(&keys(1), Kind::GitPatch, Vec::new(), 100);
let child_patch = signed(&keys(1), Kind::GitPatch, vec![e_tag(&root_patch)], 200);
let events = [root_patch.clone(), child_patch.clone()];
assert_eq!(
ThreadResolver::new(&lookup(&events)).notification_root(&child_patch),
Some(root_patch.id)
);
}
/// Resolves a status through its `root` marker tag.
#[test]
fn status_resolves_via_the_root_marker() {
let issue = issue(&keys(1), 100);
let status = signed(
&keys(2),
Kind::GitStatusClosed,
vec![marked_e_tag(&issue, "root")],
200,
);
let events = [issue.clone(), status.clone()];
assert_eq!(
ThreadResolver::new(&lookup(&events)).notification_root(&status),
Some(issue.id)
);
}
/// Follows a nested comment chain to the issue root.
#[test]
fn nested_comment_chain_follows_to_the_root() {
let issue = issue(&keys(1), 100);
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!(
ThreadResolver::new(&lookup(&events)).notification_root(&nested),
Some(issue.id)
);
}
}
+8 -2
View File
@@ -1,15 +1,21 @@
pub mod addr;
pub mod announcement;
pub mod deletions;
pub mod filters;
pub mod git_event;
pub mod inbox;
pub mod model;
pub mod pull_request;
pub mod state;
pub mod status;
pub mod upstream;
pub use addr::RepoAddr;
pub use announcement::Announcement;
pub use deletions::Deletions;
pub use filters::Filters;
pub use git_event::GitEvent;
pub use inbox::{InboxItem, InboxReadState, ThreadResolver};
pub use model::{Announcement, GitEvent, PullRequest};
pub use pull_request::PullRequest;
pub use state::RepoState;
pub use status::RepoStatus;
pub use upstream::Upstream;
+279
View File
@@ -0,0 +1,279 @@
use nostr::prelude::*;
use crate::git_event::GitEvent;
pub struct PullRequest<'a>(pub &'a Event);
impl<'a> PullRequest<'a> {
/// Wraps a pull request root event.
pub fn new(event: &'a Event) -> Self {
Self(event)
}
/// Returns the ordered patch series, found via the root reference or the tip commit.
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();
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 Some(tip) = pr.current_commit() else {
return Vec::new();
};
let Some(last) = patches
.iter()
.filter(|patch| Self::patch_produces_commit(patch, &tip))
.max_by_key(|patch| patch.created_at)
.copied()
else {
return Vec::new();
};
let mut series = vec![last];
loop {
let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else {
break;
};
let Some(prev) = patches
.iter()
.find(|patch| patch.id == prev_id && !series.contains(patch))
.copied()
else {
break;
};
series.push(prev);
}
series.reverse();
series
}
/// Joins the patch series with newlines, falling back to the event content.
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")
}
/// Returns the root author's newest update, since only the author may move the tip.
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)
}
/// Walks reply links outward from the root patch, taking the newest child first.
fn forward_series(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
let mut series = vec![root];
loop {
let next = patches
.iter()
.filter(|patch| !series.contains(patch))
.filter(|patch| {
patch
.tags
.event_ids()
.any(|id| id == series.last().unwrap().id)
})
.max_by_key(|patch| patch.created_at);
let Some(next) = next else {
break;
};
series.push(next);
}
series
}
/// Checks the commit against the patch's `c` and `r` tags.
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,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Returns the test signer keys.
fn keys() -> Keys {
Keys::new(
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
.expect("valid secret key"),
)
}
/// Builds a signed pull request event.
fn pr_event(content: &str, tags: Vec<Tag>) -> Event {
EventBuilder::new(Kind::GitPullRequest, content)
.tags(tags)
.finalize(&keys())
.expect("signed event")
}
/// Builds a signed patch event with a fixed timestamp.
fn patch_event(content: &str, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(Kind::GitPatch, content)
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.finalize(&keys())
.expect("signed event")
}
/// Joins the full patch series into one diff.
#[test]
fn pull_request_patch_joins_the_whole_patch_set() {
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!(
PullRequest::new(&pr).patch([&root, &second]),
"patch-one\npatch-two"
);
assert_eq!(
PullRequest::new(&pr).patches([&root, &second]),
vec![&root, &second]
);
}
/// Orders patches by walking the reply chain.
#[test]
fn pull_request_patches_walks_the_reply_chain_in_order() {
let root = patch_event("patch-one", vec![], 100);
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
let third = patch_event("patch-three", vec![Tag::event(second.id)], 300);
let pr = pr_event("description", vec![Tag::event(root.id)]);
let series = PullRequest::new(&pr).patches([&third, &root, &second]);
assert_eq!(
series
.iter()
.map(|p| p.content.as_str())
.collect::<Vec<_>>(),
vec!["patch-one", "patch-two", "patch-three"]
);
}
/// Finds the patch series through the declared tip commit.
#[test]
fn pull_request_patches_finds_the_set_via_the_tip_commit() {
let root = patch_event("patch-one", vec![], 100);
let tip = "1111111111111111111111111111111111111111";
let last = patch_event(
"patch-two",
vec![
Tag::event(root.id),
Tag::parse(["r", tip]).expect("valid tag"),
],
200,
);
let pr = pr_event(
"description",
vec![Tag::parse(["c", tip]).expect("valid tag")],
);
let series = PullRequest::new(&pr).patches([&root, &last]);
assert_eq!(
series
.iter()
.map(|p| p.content.as_str())
.collect::<Vec<_>>(),
vec!["patch-one", "patch-two"]
);
}
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222";
/// Builds a signed event with a fixed timestamp.
fn signed_at(kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.finalize(&keys())
.expect("signed event")
}
/// Builds the pull request root used by the update tests.
fn pr_root() -> Event {
signed_at(
Kind::GitPullRequest,
vec![
Tag::parse(["c", COMMIT_HEX]).expect("valid tag"),
Tag::parse(["branch-name", "feature/x"]).expect("valid tag"),
],
100,
)
}
/// Picks the newest update targeting the root.
#[test]
fn latest_update_picks_newest_revision_of_the_root() {
let root = pr_root();
let root_hex = root.id.to_hex();
let revision = |created_at: u64| {
signed_at(
Kind::GitPullRequestUpdate,
vec![Tag::parse(["E", &root_hex]).expect("valid tag")],
created_at,
)
};
let unrelated = signed_at(
Kind::GitPullRequestUpdate,
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
999,
);
let events = [unrelated, revision(200), root.clone(), revision(300)];
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);
}
/// Ignores updates from authors other than the root owner.
#[test]
fn latest_update_ignores_other_authors() {
let root = pr_root();
let root_hex = root.id.to_hex();
let other = Keys::new(
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
.expect("valid secret key"),
);
let stranger = EventBuilder::new(Kind::GitPullRequestUpdate, "")
.tags([Tag::parse(["E", &root_hex]).expect("valid tag")])
.custom_created_at(Timestamp::from(999))
.finalize(&other)
.expect("signed event");
assert!(PullRequest::latest_update([&stranger, &root].into_iter(), &root).is_none());
}
}
+3 -1
View File
@@ -7,17 +7,19 @@ pub struct RepoState {
}
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)
}
-1
View File
@@ -28,7 +28,6 @@ impl RepoStatus {
}
}
// Defaults to Open when no authorized status event exists.
pub fn resolve<'a, I>(
status_events: I,
root_author: &PublicKey,
+33
View File
@@ -0,0 +1,33 @@
use nostr::prelude::*;
use crate::RepoAddr;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Upstream {
pub raw: String,
pub addr: Option<RepoAddr>,
}
impl Upstream {
/// Parses a raw `u` tag value, extracting the coordinate when present.
pub(crate) fn parse(raw: &str) -> Self {
let coordinate = raw.split('|').next().unwrap_or(raw);
let addr = coordinate
.parse::<Coordinate>()
.ok()
.filter(|coordinate| coordinate.kind == Kind::GitRepoAnnouncement)
.map(RepoAddr::from);
Self {
raw: raw.to_owned(),
addr,
}
}
/// Shows the coordinate when parsed, otherwise the raw value.
pub fn display(&self) -> String {
match &self.addr {
Some(addr) => addr.to_string(),
None => self.raw.clone(),
}
}
}