use std::borrow::Cow; use std::collections::HashSet; use std::time::Duration; use anyhow::Error; use gpui::{AppContext, Context, Subscription, Task}; use nostr::event::IntoEventBuilder; use nostr_sdk::prelude::*; use signed_core::{ Announcement, COVER_NOTE_KIND, Deletions, RepoAddr, RepoStatus, build_state, cover_note, filters, labels_and_subject, parse_state, pull_request_patch, subject_override, }; use crate::backend::{Backend, BackendEvent}; use crate::git_store::GitStore; /// Delay between a refresh request and the actual re-query, so bursts of /// events (e.g. per-event `NostrUpdate`s) collapse into one query. const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); /// Per-repository store: announcement, state, issues, patches, PRs, /// comments and their resolved statuses. Always derived from the local /// database. pub struct RepoStore { addr: RepoAddr, pub announcement: Option, /// `(refname, commit-id)` pairs from the latest state announcement. pub refs: Vec<(String, String)>, /// Branch pointed to by `HEAD` in the latest state announcement. pub head: Option, pub issues: Vec, pub patches: Vec, pub pull_requests: Vec, /// Comments on issues / PRs, oldest first. pub comments: Vec, statuses: Vec, /// Kind-1624 cover notes and kind-1985 label events referencing this /// repository's roots (ngit / GitWorkshop extensions). cover_notes: Vec, labels: Vec, /// Error of the last action initiated from this store, if any. pub last_error: Option, /// Relays announced by this repository (NIP-34 `relays` tag) that we /// have already been asked to connect to and fetch from, to avoid /// re-subscribing on every refresh. repo_relays: HashSet, /// Root events (issues, patches, PRs) for which the per-root fetches /// (NIP-22 comments, statuses without an `a` tag, cover notes and /// labels) have already been requested, to avoid re-fetching on every /// refresh. root_fetches: HashSet, refreshing: bool, refresh_dirty: bool, /// A refresh is waiting out [`REFRESH_DEBOUNCE`]. debouncing: bool, tasks: Vec>>, _subscription: Subscription, } impl RepoStore { pub fn new(addr: RepoAddr, announced_relays: Vec, cx: &mut Context) -> Self { let backend = Backend::global(cx); let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { let relevant = match event { BackendEvent::NostrUpdate(update) => { // Deletions may target any event of this repository. let deletion = update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish; let coordinate = update.coordinate.as_ref() == Some(&this.addr); let author = update.author == this.addr.public_key; let kind = update.kind == Kind::GitRepoAnnouncement; // NIP-22 comments carry no `a` tag, so they can't be // matched by coordinate; any comment may reference this // repository's roots. let comment = update.kind == Kind::Comment; // Status events may omit their `a` tag (NIP-34), so any // status event may reference a root of this repository. let status = RepoStatus::from_kind(update.kind).is_some(); // Cover notes and labels carry no `a` tag either. let annotation = update.kind == COVER_NOTE_KIND || update.kind == Kind::Label; deletion || coordinate || (author && kind) || comment || status || annotation } BackendEvent::Published(event) => { let kind = event.kind == Kind::GitRepoAnnouncement; let author = event.pubkey == this.addr.public_key; let coordinate = event.tags.coordinates().into_iter().any(|c| c == this.addr); // Locally published deletions may target any event of // this repository; refresh so they take effect // immediately, like relay deletions. let deletion = event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish; coordinate || (kind && author) || deletion } _ => false, }; if relevant { this.refresh(cx); } }); let mut store = Self { addr, announcement: None, refs: Vec::new(), head: None, issues: Vec::new(), patches: Vec::new(), pull_requests: Vec::new(), comments: Vec::new(), statuses: Vec::new(), cover_notes: Vec::new(), labels: Vec::new(), last_error: None, repo_relays: HashSet::new(), root_fetches: HashSet::new(), refreshing: false, refresh_dirty: false, debouncing: false, _subscription: subscription, tasks: Vec::new(), }; store.subscribe_remote(cx); // The announcement we opened the repo from may already list its // relays; connect to them right away instead of waiting for the // bootstrap fetch to return the same event. store.connect_announced_relays(&announced_relays, cx); store.refresh(cx); store } pub fn addr(&self) -> &RepoAddr { &self.addr } /// Filters that make up a repository: announcement, state, activity and /// deletions targeting it. fn repo_filters(addr: &RepoAddr) -> Vec { let mut filters = vec![ filters::announcement(addr), filters::state(addr), filters::activity(addr), ]; // Deletion requests (NIP-09/62) must be known before any event of // this repository can be shown. filters.extend(filters::deletions_for_repo(addr)); filters } /// Fetch this repository's events from the relays announced in its /// NIP-34 `relays` tag. Deduplicated: each relay is only contacted once /// per store, so refreshes after the first are no-ops unless the /// announcement lists new relays. fn connect_announced_relays(&mut self, relays: &[RelayUrl], cx: &mut Context) { let new: Vec = relays .iter() .filter(|url| !self.repo_relays.contains(*url)) .cloned() .collect(); if new.is_empty() { return; } self.repo_relays.extend(new.iter().cloned()); let backend = Backend::global(cx); let addr = self.addr.clone(); backend.update(cx, |backend, cx| { backend.connect_repo_relays(new, Self::repo_filters(&addr), cx); }); } /// Fetch this repository's events from the bootstrap relays (one-shot, /// auto-closing subscription). fn subscribe_remote(&mut self, cx: &mut Context) { let backend = Backend::global(cx); let addr = self.addr.clone(); backend.update(cx, |backend, cx| { backend.subscribe_bootstrap(Self::repo_filters(&addr), cx); }); } /// Re-query the local database and update all fields. /// /// Debounced: a short delay collapses bursts of requests (e.g. per-event /// `NostrUpdate`s), and requests that arrive while a query is running are /// folded into one follow-up query. The query and processing run on a /// background thread; only the results are applied on the main thread. pub fn refresh(&mut self, cx: &mut Context) { if self.refreshing { self.refresh_dirty = true; return; } if self.debouncing { return; } self.debouncing = true; let task = cx.spawn(async move |this, cx| { cx.background_executor().timer(REFRESH_DEBOUNCE).await; this.update(cx, |this, cx| { this.debouncing = false; this.run_refresh(cx); }) }); self.tasks.retain(|task| !task.is_ready()); self.tasks.push(task); } /// One query + apply cycle (debounced entry point). fn run_refresh(&mut self, cx: &mut Context) { self.refreshing = true; let client = Backend::global(cx).read(cx).client(); let addr = self.addr.clone(); let work = cx.background_spawn(async move { let (announcements, states, activity, deletion_events) = async { let db = client.database(); let announcements = db.query(filters::announcement(&addr)).await?; let states = db.query(filters::state(&addr)).await?; let activity = db.query(filters::activity(&addr)).await?; let deletion_events = db.query(filters::deletions()).await?; Ok::<_, Error>((announcements, states, activity, deletion_events)) } .await?; let deletions = Deletions::from_events(deletion_events); // Parse and sort off the main thread; only plain data // crosses back into the entity. let all_announcements = announcements .into_iter() .filter(|e| !deletions.is_deleted(e)); let announcement = latest(all_announcements) .as_ref() .and_then(Announcement::from_event); let all_states = states.into_iter().filter(|e| !deletions.is_deleted(e)); let state = latest(all_states).map(|state| parse_state(&state)); let (mut issues, mut patches, mut pull_requests, mut statuses, mut comments) = (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()); let (mut cover_notes, mut labels): (Vec, Vec) = (Vec::new(), Vec::new()); for event in activity { if deletions.is_deleted(&event) { continue; } match event.kind { Kind::GitIssue => issues.push(event), Kind::GitPatch => patches.push(event), Kind::GitPullRequest | Kind::GitPullRequestUpdate => pull_requests.push(event), Kind::Comment => comments.push(event), kind if RepoStatus::from_kind(kind).is_some() => statuses.push(event), _ => {} } } // NIP-22 comments reference their root via an `E`/`e` tag rather // than the repository's `a` tag, so query them by the root events // of this repository. let db = client.database(); let mut seen_comments: HashSet = comments.iter().map(|e| e.id).collect(); let roots = issues .iter() .chain(&patches) .chain(&pull_requests) .map(|e| e.id); for filter in filters::comments_for(roots) { for event in db.query(filter).await? { if seen_comments.insert(event.id) { comments.push(event); } } } // Status events may omit their `a` tag (NIP-34 makes it // optional), so also query them by the root events they // reference. let db = client.database(); let mut seen_statuses: HashSet = statuses.iter().map(|e| e.id).collect(); let roots = issues .iter() .chain(&patches) .chain(&pull_requests) .map(|e| e.id); for root in roots { for event in db.query(filters::statuses_for(root)).await? { if seen_statuses.insert(event.id) { statuses.push(event); } } } // Cover notes (1624) and label events (1985) reference their // target via an `e` tag, so query them per root like comments // and statuses. let db = client.database(); let mut seen_cover_notes: HashSet = cover_notes.iter().map(|e| e.id).collect(); let mut seen_labels: HashSet = labels.iter().map(|e| e.id).collect(); let roots = issues .iter() .chain(&patches) .chain(&pull_requests) .map(|e| e.id); for root in roots { for event in db.query(filters::annotations_for(root)).await? { if deletions.is_deleted(&event) { continue; } if event.kind == COVER_NOTE_KIND && seen_cover_notes.insert(event.id) { cover_notes.push(event); } else if event.kind == Kind::Label && seen_labels.insert(event.id) { labels.push(event); } } } sort_newest_first(&mut issues); sort_newest_first(&mut patches); sort_newest_first(&mut pull_requests); sort_oldest_first(&mut comments); sort_newest_first(&mut cover_notes); sort_newest_first(&mut labels); Ok::<_, Error>(( announcement, state, issues, patches, pull_requests, statuses, comments, cover_notes, labels, )) }); self.tasks.retain(|task| !task.is_ready()); self.tasks.push(cx.spawn(async move |this, cx| { let ( announcement, state, issues, patches, pull_requests, statuses, comments, cover_notes, labels, ) = match work.await { Ok(data) => data, Err(e) => { return this.update(cx, |this, cx| { this.refreshing = false; this.last_error = Some(e.to_string()); cx.notify(); }); } }; let again = this.update(cx, |this, cx| { this.announcement = announcement; // The announcement may list relays for this repository's // activity; connect to any we haven't fetched from yet. let relays = this .announcement .as_ref() .map(|a| a.relays.clone()) .unwrap_or_default(); this.connect_announced_relays(&relays, cx); if let Some((refs, head)) = state { this.refs = refs; this.head = head; } this.issues = issues; this.patches = patches; this.pull_requests = pull_requests; this.comments = comments; this.statuses = statuses; this.cover_notes = cover_notes; this.labels = labels; // Comments, statuses without an `a` tag, cover notes and // labels are not addressed to the repository, so fetch them // by the root events they reference, on the bootstrap relays // and on the relays this repository announced. let roots = this .issues .iter() .chain(&this.patches) .chain(&this.pull_requests) .map(|e| e.id) .collect::>(); let new_roots: Vec = roots .iter() .filter(|id| !this.root_fetches.contains(id)) .copied() .collect(); if !new_roots.is_empty() { this.root_fetches.extend(new_roots.iter().copied()); let comment_filters = filters::comments_for(new_roots.clone()); let status_filters: Vec = new_roots .iter() .copied() .map(filters::statuses_for) .collect(); let annotation_filters: Vec = new_roots .into_iter() .map(filters::annotations_for) .collect(); let announced: Vec = this.repo_relays.iter().cloned().collect(); let backend = Backend::global(cx); backend.update(cx, |backend, cx| { backend.subscribe_bootstrap(comment_filters.clone(), cx); backend.connect_repo_relays(announced.clone(), comment_filters, cx); backend.subscribe_bootstrap(status_filters.clone(), cx); backend.connect_repo_relays(announced.clone(), status_filters, cx); backend.subscribe_bootstrap(annotation_filters.clone(), cx); backend.connect_repo_relays(announced, annotation_filters, cx); }); } cx.notify(); this.refreshing = false; if this.refresh_dirty { this.refresh_dirty = false; true } else { false } })?; // Requests that arrived while the refresh was running are // coalesced into one follow-up refresh. if again { this.update(cx, |this, cx| this.refresh(cx))?; } Ok(()) })); } /// Resolve the status of a root event (issue / patch / PR) per NIP-34. pub fn status_of(&self, root: &Event) -> RepoStatus { let maintainers = self .announcement .as_ref() .map(Announcement::effective_maintainers) .unwrap_or_default(); let events = self .statuses .iter() .filter(|e| signed_core::references_root(e, &root.id)); signed_core::resolve_status(events, &root.pubkey, &maintainers) } /// The effective cover note of `root` (kind 1624), if any: the latest /// note authored by the root author or a maintainer. pub fn cover_note_of(&self, root: &Event) -> Option<&Event> { let maintainers = self .announcement .as_ref() .map(Announcement::effective_maintainers) .unwrap_or_default(); cover_note(root, &self.cover_notes, &maintainers) } /// The effective hashtag labels of `root`: its own `t` tags plus labels /// from authorized NIP-32 kind-1985 events (`#t` namespace). pub fn labels_of(&self, root: &Event) -> Vec { let maintainers = self .announcement .as_ref() .map(Announcement::effective_maintainers) .unwrap_or_default(); let (labels, _) = labels_and_subject(root, &self.labels, &maintainers); labels } /// The effective subject/title override of `root` from authorized /// kind-1985 events (`#subject` namespace), if any. pub fn subject_of(&self, root: &Event) -> Option { let maintainers = self .announcement .as_ref() .map(Announcement::effective_maintainers) .unwrap_or_default(); subject_override(root, &self.labels, &maintainers) } /// Number of open issues: issues whose resolved status is /// [`RepoStatus::Open`] (issues without status events default to open). pub fn issue_count(&self) -> usize { self.issues .iter() .filter(|issue| self.status_of(issue) == RepoStatus::Open) .count() } /// Number of open pull requests: root PR events (not PR updates, whose /// status is carried by the root) with a resolved status of /// [`RepoStatus::Open`]. pub fn pull_request_count(&self) -> usize { self.pull_requests .iter() .filter(|pr| pr.kind == Kind::GitPullRequest && self.status_of(pr) == RepoStatus::Open) .count() } /// Whether `user` is the author (owner) of this repository: the public /// key of the repository address. Only the author may manage the /// repository's pull requests (close / reopen / merge). pub fn is_author(&self, user: &PublicKey) -> bool { &self.addr.public_key == user } /// Open an issue on this repository. pub fn open_issue(&mut self, subject: Option, content: String, cx: &mut Context) { let builder = GitIssue { repository: self.addr.clone(), content, subject, labels: Vec::new(), } .into_event_builder(); self.send(builder, cx); } /// Comments on a root event (issue / PR), oldest first. pub fn comments_of(&self, root: &EventId) -> impl Iterator { self.comments .iter() .filter(move |e| signed_core::references_root(e, root)) } /// Comment on a root event (issue / PR) per NIP-34 (kind 1111), using /// the SDK's NIP-22 `CommentBuilder` so other NIP-34 clients (ngit, /// GitWorkshop) can thread the comment. pub fn comment(&mut self, root: &Event, content: String, cx: &mut Context) { self.reply(root, None, content, cx); } /// Reply to `parent` (a comment on `root`) with a NIP-22 threaded /// comment; `None` publishes a top-level comment on the root itself. pub fn reply( &mut self, root: &Event, parent: Option<&Event>, content: String, cx: &mut Context, ) { let relay_hint = self .announcement .as_ref() .and_then(|a| a.relays.first()) .cloned(); self.send( comment_builder(root, parent, relay_hint.as_ref(), &self.addr, content), cx, ); } /// Open a pull request on this repository: a root PR event (kind 1618) /// whose content is the markdown description, plus a root patch event /// (kind 1617) carrying the `git format-patch` output, which the PR /// references via an `e` tag (NIP-34). /// /// The patch is published first and the PR is sent once the patch /// event's id is known, so the two always arrive together. The proposed /// commit is parsed from the patch's `From ` header; publishing /// without one is refused, because the PR's `c` tag (and the patch's /// `commit`/`r` tags) must carry a real commit id for other NIP-34 /// clients to verify and apply the proposal. The PR's `clone` tag /// carries the repository's announced mirror URLs (the commit may not be /// pushed there yet; the linked patch is the source of truth until a /// push backend exists). pub fn open_pull_request( &mut self, subject: Option, description: String, patch: String, cx: &mut Context, ) { self.last_error = None; let Some(current_commit) = patch_current_commit(&patch).and_then(|hex| hex.parse::().ok()) else { self.last_error = Some( "Patch must be `git format-patch` output with a `From ` header".into(), ); cx.notify(); return; }; let Ok(root_marker) = Tag::parse(["t", "root"]) else { return; }; let commit_hex = current_commit.to_string(); let mut patch_tags = vec![ Tag::coordinate(self.addr.clone(), None), Tag::public_key(self.addr.public_key), root_marker, ]; // NIP-34: the `r` EUC tag lets clients subscribe to all patches of // this repository; `commit`/`r` tags reference the proposed commit. if let Some(euc) = self.announcement.as_ref().and_then(|a| a.euc.clone()) && let Ok(tag) = Tag::parse(["r", &euc]) { patch_tags.push(tag); } if let Ok(tag) = Tag::parse(["commit", &commit_hex]) { patch_tags.push(tag); } if let Ok(tag) = Tag::parse(["r", &commit_hex]) { patch_tags.push(tag); } let patch_builder = EventBuilder::new(Kind::GitPatch, patch).tags(patch_tags); let patch_task = Backend::global(cx).update(cx, |backend, cx| backend.send(patch_builder, cx)); self.tasks.push(cx.spawn(async move |this, cx| { let patch_event = match patch_task.await { Ok(event) => event, Err(e) => { return this.update(cx, |this, cx| { this.last_error = Some(e.to_string()); cx.notify(); }); } }; // The PR references the patch event so viewers can find the // patch without carrying it inline. let pr_task = this.update(cx, |this, cx| { let builder = GitPullRequest { repository: this.addr.clone(), content: description, subject, labels: Vec::new(), branch_name: None, // NIP-34: PRs carry at least one clone URL where the // tip commit can be downloaded; use the repository's // announced mirrors until a push backend exists. clone: this .announcement .as_ref() .map(|a| a.clone.clone()) .unwrap_or_default(), current_commit, root_patch_event: Some(patch_event.id), merge_base: None, } .into_event_builder(); Backend::global(cx).update(cx, |backend, cx| backend.send(builder, cx)) })?; if let Err(e) = pr_task.await { return this.update(cx, |this, cx| { this.last_error = Some(e.to_string()); cx.notify(); }); } Ok(()) })); } /// Set the status of a root event. Per NIP-34 only the root author or a /// repository maintainer may set the status; status events from anyone /// else are ignored by clients, so refuse them up front. pub fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context) { self.last_error = None; let maintainers = self .announcement .as_ref() .map(Announcement::effective_maintainers) .unwrap_or_default(); let Some(user) = Backend::global(cx).read(cx).current_user() else { self.last_error = Some("Sign in to change the status".into()); cx.notify(); return; }; if user != root.pubkey && !maintainers.contains(&user) { self.last_error = Some("Only the author or a maintainer can change the status".into()); cx.notify(); return; } let Ok(root_ref) = Tag::parse(["e", &root.id.to_hex(), "", "root"]) else { return; }; let builder = EventBuilder::new(status.kind(), "").tags([ root_ref, Tag::public_key(self.addr.public_key), Tag::public_key(root.pubkey), Tag::coordinate(self.addr.clone(), None), ]); self.send(builder, cx); } /// Publish a repository state announcement (kind 30618) with the refs of /// the local clone: branches, tags and HEAD. Only the repository owner /// may publish state, and a local clone must exist to read the refs from. pub fn publish_state(&mut self, cx: &mut Context) { self.last_error = None; let Some(user) = Backend::global(cx).read(cx).current_user() else { self.last_error = Some("Sign in to publish repository state".into()); cx.notify(); return; }; if !self.is_author(&user) { self.last_error = Some("Only the repository owner can publish state".into()); cx.notify(); return; } let cache = GitStore::global(cx).cache().clone(); let addr = self.addr.clone(); let clone_urls: Vec = self .announcement .as_ref() .map(|a| a.clone.iter().map(ToString::to_string).collect()) .unwrap_or_default(); let work = cx.background_spawn(async move { let repo = cache.ensure_clone(&addr, &clone_urls)?; signed_git::repo_ref_state(&repo) }); self.tasks.push(cx.spawn(async move |this, cx| { let state = match work.await { Ok(state) => state, Err(e) => { return this.update(cx, |this, cx| { this.last_error = Some(e.to_string()); cx.notify(); }); } }; this.update(cx, |this, cx| { let builder = build_state(&this.addr.identifier, &state.refs, state.head.as_deref()); this.send(builder, cx); })?; Ok(()) })); } /// Merge a pull request: apply its patch (the content of the linked /// root patch event) to the local clone of this repository, then publish /// the merged status. /// /// Only the repository author may merge. The clone is created on demand /// from the announcement's clone URLs when the repository hasn't been /// mirrored locally yet. Patch application runs on a background thread /// (`git am`); failures (e.g. a patch that no longer applies) surface in /// [`Self::last_error`] and no status is sent. pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context) { self.last_error = None; let is_author = Backend::global(cx) .read(cx) .current_user() .is_some_and(|user| self.is_author(&user)); if !is_author { self.last_error = Some("Only the repository author can merge pull requests".into()); return; } let cache = GitStore::global(cx).cache().clone(); let addr = self.addr.clone(); let clone_urls: Vec = self .announcement .as_ref() .map(|a| a.clone.iter().map(ToString::to_string).collect()) .unwrap_or_default(); let patch = pull_request_patch(root, self.patches.iter()); let root = root.clone(); let apply = cx.background_spawn(async move { let repo = cache.ensure_clone(&addr, &clone_urls)?; let workdir = repo .workdir() .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?; signed_git::apply_patch(workdir, &patch) }); self.tasks.push(cx.spawn(async move |this, cx| { match apply.await { Ok(()) => { this.update(cx, |this, cx| { this.set_status(&root, RepoStatus::Applied, cx); })?; } Err(e) => { this.update(cx, |this, cx| { this.last_error = Some(e.to_string()); cx.notify(); })?; } } Ok(()) })); } fn send(&mut self, builder: EventBuilder, cx: &mut Context) { self.last_error = None; let backend = Backend::global(cx); let task = backend.update(cx, |backend, cx| backend.send(builder, cx)); self.tasks.push(cx.spawn(async move |this, cx| { if let Err(e) = task.await { this.update(cx, |this, cx| { this.last_error = Some(e.to_string()); cx.notify(); })?; } Ok(()) })); } } fn latest(events: I) -> Option where I: IntoIterator, { events.into_iter().max_by_key(|e| e.created_at) } fn sort_newest_first(events: &mut [Event]) { events.sort_by_key(|e| std::cmp::Reverse(e.created_at)); } fn sort_oldest_first(events: &mut [Event]) { events.sort_by_key(|e| e.created_at); } /// The proposed commit of a `git format-patch` output: the `From ` /// header on its first line. fn patch_current_commit(patch: &str) -> Option<&str> { let line = patch.lines().next()?; let hex = line.strip_prefix("From ")?; hex.split_whitespace().next().filter(|hex| hex.len() == 40) } /// Build a NIP-22 kind-1111 comment using the SDK's [`CommentBuilder`]: /// uppercase `E`/`K`/`P` tags scope the thread root, lowercase `e`/`k`/`p` /// tags the direct parent (`parent`, or the root itself for a top-level /// comment). An `a` tag with the repository coordinate is added so Signed's /// own activity subscriptions also match the comment (it is not part of /// NIP-22). fn comment_builder( root: &Event, parent: Option<&Event>, relay_hint: Option<&RelayUrl>, addr: &RepoAddr, content: String, ) -> EventBuilder { let target = |event: &Event| { CommentTarget::event( event.id, event.kind, Some(event.pubkey), relay_hint.cloned().map(Cow::Owned), ) }; let root_target = target(root); let parent_target = parent.map(target).unwrap_or_else(|| root_target.clone()); CommentBuilder::new(content, parent_target) .root(root_target) .into_event_builder() .tags([Tag::coordinate(addr.clone(), None)]) } #[cfg(test)] mod tests { use nostr_sdk::prelude::*; use super::{comment_builder, patch_current_commit}; #[test] fn parses_format_patch_header() { let patch = "From 1f6c0c5f3f1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a Mon Sep 17 00:00:00 2001\nFrom: A \nSubject: [PATCH] fix\n\n---\n"; assert_eq!( patch_current_commit(patch), Some("1f6c0c5f3f1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a") ); } #[test] fn no_commit_without_header() { assert_eq!(patch_current_commit(""), None); assert_eq!(patch_current_commit("Subject: [PATCH] x\n\n---\n"), None); assert_eq!(patch_current_commit("From short\n"), None); } #[test] fn comment_builder_follows_nip22() { let keys = Keys::generate(); let root = EventBuilder::new(Kind::GitIssue, "issue body") .finalize(&keys) .expect("signed event"); let addr = Coordinate::new(Kind::GitRepoAnnouncement, root.pubkey).identifier("my-repo"); let relay = RelayUrl::parse("wss://relay.example.com").expect("valid relay URL"); let event = comment_builder(&root, None, Some(&relay), &addr, "hi".into()) .finalize(&keys) .expect("signed event"); assert_eq!(event.kind, Kind::Comment); let kinds: Vec<&str> = event.tags.iter().map(Tag::kind).collect(); for expected in ["E", "K", "P", "e", "k", "p", "a"] { assert!(kinds.contains(&expected), "missing {expected} tag"); } // The uppercase `E` tag scopes the root: id, relay hint and author. let e = event.tags.iter().find(|t| t.kind() == "E").expect("E tag"); let slice = e.as_slice(); assert_eq!(slice[1], root.id.to_hex()); assert_eq!(slice[2], relay.as_str()); assert_eq!(slice[3], root.pubkey.to_hex()); // The lowercase `e` tag references the parent, which for a top-level // comment is the root itself. let e = event.tags.iter().find(|t| t.kind() == "e").expect("e tag"); assert_eq!(e.as_slice()[1], root.id.to_hex()); // Signed's own `references_root` must keep matching the comment. assert!(signed_core::references_root(&event, &root.id)); } #[test] fn comment_builder_replies_nest_under_the_parent() { let keys = Keys::generate(); let root = EventBuilder::new(Kind::GitIssue, "issue body") .finalize(&keys) .expect("signed event"); let parent = EventBuilder::new(Kind::Comment, "first comment") .finalize(&keys) .expect("signed event"); let addr = Coordinate::new(Kind::GitRepoAnnouncement, root.pubkey).identifier("my-repo"); let event = comment_builder(&root, Some(&parent), None, &addr, "reply".into()) .finalize(&keys) .expect("signed event"); // The uppercase `E` tag still scopes the root event, while the // lowercase `e` tag references the parent comment. let root_ref = event.tags.iter().find(|t| t.kind() == "E").expect("E tag"); let parent_ref = event.tags.iter().find(|t| t.kind() == "e").expect("e tag"); assert_eq!(root_ref.as_slice()[1], root.id.to_hex()); assert_eq!(parent_ref.as_slice()[1], parent.id.to_hex()); // The reply still threads under the root for Signed's own display. assert!(signed_core::references_root(&event, &root.id)); } }