.
This commit is contained in:
@@ -3,6 +3,7 @@ use std::time::Duration;
|
||||
|
||||
use anyhow::{Error, anyhow};
|
||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
|
||||
use nostr::event::IntoEventBuilder;
|
||||
use nostr_connect::prelude::*;
|
||||
use nostr_sdk::client::SyncSummary;
|
||||
use nostr_sdk::prelude::*;
|
||||
@@ -838,6 +839,17 @@ impl Backend {
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish a NIP-34 repository announcement (kind 30617) with the
|
||||
/// current signer. The returned task yields the published event, so
|
||||
/// callers can show inline progress/errors.
|
||||
pub fn publish_announcement(
|
||||
&mut self,
|
||||
announcement: GitRepositoryAnnouncement,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Task<Result<Event, Error>> {
|
||||
self.send(announcement.into_event_builder(), cx)
|
||||
}
|
||||
|
||||
/// Sign, broadcast and store an event without awaiting the result;
|
||||
/// failures surface through [`BackendEvent::Error`]. The spawned task is
|
||||
/// owned by the backend, so it is cancelled when the backend is dropped.
|
||||
|
||||
+386
-50
@@ -1,11 +1,14 @@
|
||||
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, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patch,
|
||||
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};
|
||||
@@ -31,15 +34,21 @@ pub struct RepoStore {
|
||||
/// Comments on issues / PRs, oldest first.
|
||||
pub comments: Vec<Event>,
|
||||
statuses: Vec<Event>,
|
||||
/// Kind-1624 cover notes and kind-1985 label events referencing this
|
||||
/// repository's roots (ngit / GitWorkshop extensions).
|
||||
cover_notes: Vec<Event>,
|
||||
labels: Vec<Event>,
|
||||
/// Error of the last action initiated from this store, if any.
|
||||
pub last_error: Option<String>,
|
||||
/// 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<RelayUrl>,
|
||||
/// Root events (issues, patches, PRs) for which a NIP-22 comment fetch
|
||||
/// has already been requested, to avoid re-fetching on every refresh.
|
||||
comment_roots: HashSet<EventId>,
|
||||
/// 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<EventId>,
|
||||
refreshing: bool,
|
||||
refresh_dirty: bool,
|
||||
/// A refresh is waiting out [`REFRESH_DEBOUNCE`].
|
||||
@@ -65,8 +74,13 @@ impl RepoStore {
|
||||
// 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
|
||||
deletion || coordinate || (author && kind) || comment || status || annotation
|
||||
}
|
||||
BackendEvent::Published(event) => {
|
||||
let kind = event.kind == Kind::GitRepoAnnouncement;
|
||||
@@ -93,9 +107,11 @@ impl RepoStore {
|
||||
pull_requests: Vec::new(),
|
||||
comments: Vec::new(),
|
||||
statuses: Vec::new(),
|
||||
cover_notes: Vec::new(),
|
||||
labels: Vec::new(),
|
||||
last_error: None,
|
||||
repo_relays: HashSet::new(),
|
||||
comment_roots: HashSet::new(),
|
||||
root_fetches: HashSet::new(),
|
||||
refreshing: false,
|
||||
refresh_dirty: false,
|
||||
debouncing: false,
|
||||
@@ -227,6 +243,7 @@ impl RepoStore {
|
||||
|
||||
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<Event>, Vec<Event>) = (Vec::new(), Vec::new());
|
||||
|
||||
for event in activity {
|
||||
if deletions.is_deleted(&event) {
|
||||
@@ -260,10 +277,54 @@ impl RepoStore {
|
||||
}
|
||||
}
|
||||
|
||||
// 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<EventId> = 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<EventId> = cover_notes.iter().map(|e| e.id).collect();
|
||||
let mut seen_labels: HashSet<EventId> = 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,
|
||||
@@ -273,23 +334,34 @@ impl RepoStore {
|
||||
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) =
|
||||
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 (
|
||||
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;
|
||||
@@ -313,10 +385,13 @@ impl RepoStore {
|
||||
this.pull_requests = pull_requests;
|
||||
this.comments = comments;
|
||||
this.statuses = statuses;
|
||||
this.cover_notes = cover_notes;
|
||||
this.labels = labels;
|
||||
|
||||
// Comments 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.
|
||||
// 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()
|
||||
@@ -326,17 +401,30 @@ impl RepoStore {
|
||||
.collect::<HashSet<EventId>>();
|
||||
let new_roots: Vec<EventId> = roots
|
||||
.iter()
|
||||
.filter(|id| !this.comment_roots.contains(id))
|
||||
.filter(|id| !this.root_fetches.contains(id))
|
||||
.copied()
|
||||
.collect();
|
||||
if !new_roots.is_empty() {
|
||||
this.comment_roots.extend(new_roots.iter().copied());
|
||||
let comment_filters = filters::comments_for(new_roots);
|
||||
this.root_fetches.extend(new_roots.iter().copied());
|
||||
let comment_filters = filters::comments_for(new_roots.clone());
|
||||
let status_filters: Vec<Filter> = new_roots
|
||||
.iter()
|
||||
.copied()
|
||||
.map(filters::statuses_for)
|
||||
.collect();
|
||||
let annotation_filters: Vec<Filter> = new_roots
|
||||
.into_iter()
|
||||
.map(filters::annotations_for)
|
||||
.collect();
|
||||
let announced: Vec<RelayUrl> = 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, comment_filters, 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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -366,15 +454,52 @@ impl RepoStore {
|
||||
let maintainers = self
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|a| a.maintainers.as_slice())
|
||||
.unwrap_or(&[]);
|
||||
.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)
|
||||
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<String> {
|
||||
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<String> {
|
||||
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
|
||||
@@ -423,19 +548,32 @@ impl RepoStore {
|
||||
.filter(move |e| signed_core::references_root(e, root))
|
||||
}
|
||||
|
||||
/// Comment on a root event (issue / PR) per NIP-34 (kind 1111).
|
||||
/// 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>) {
|
||||
let Ok(root_ref) = Tag::parse(["e", &root.id.to_hex(), "", "root"]) else {
|
||||
return;
|
||||
};
|
||||
self.reply(root, None, content, cx);
|
||||
}
|
||||
|
||||
let builder = EventBuilder::new(Kind::Comment, content).tags([
|
||||
root_ref,
|
||||
Tag::public_key(root.pubkey),
|
||||
Tag::coordinate(self.addr.clone(), None),
|
||||
]);
|
||||
/// 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<Self>,
|
||||
) {
|
||||
let relay_hint = self
|
||||
.announcement
|
||||
.as_ref()
|
||||
.and_then(|a| a.relays.first())
|
||||
.cloned();
|
||||
|
||||
self.send(builder, cx);
|
||||
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)
|
||||
@@ -444,11 +582,14 @@ impl RepoStore {
|
||||
/// 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 branch
|
||||
/// metadata (branch name, clone URL, merge base) isn't known to the UI
|
||||
/// yet and is left empty; the proposed commit is parsed from the patch's
|
||||
/// `From <commit>` header, falling back to an empty hash for hand-written
|
||||
/// content.
|
||||
/// event's id is known, so the two always arrive together. The proposed
|
||||
/// commit is parsed from the patch's `From <commit>` 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<String>,
|
||||
@@ -458,18 +599,39 @@ impl RepoStore {
|
||||
) {
|
||||
self.last_error = None;
|
||||
|
||||
let current_commit = patch_current_commit(&patch)
|
||||
.and_then(|hex| hex.parse().ok())
|
||||
.unwrap_or_else(|| bitcoin_hashes::Sha1::from_byte_array([0u8; 20]));
|
||||
let Some(current_commit) =
|
||||
patch_current_commit(&patch).and_then(|hex| hex.parse::<bitcoin_hashes::Sha1>().ok())
|
||||
else {
|
||||
self.last_error = Some(
|
||||
"Patch must be `git format-patch` output with a `From <commit-id>` header".into(),
|
||||
);
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
|
||||
let Ok(root_marker) = Tag::parse(["t", "root"]) else {
|
||||
return;
|
||||
};
|
||||
let patch_builder = EventBuilder::new(Kind::GitPatch, patch).tags([
|
||||
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));
|
||||
@@ -494,7 +656,14 @@ impl RepoStore {
|
||||
subject,
|
||||
labels: Vec::new(),
|
||||
branch_name: None,
|
||||
clone: Vec::new(),
|
||||
// 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,
|
||||
@@ -515,8 +684,30 @@ impl RepoStore {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Set the status of a root event (requires being the root author or a maintainer).
|
||||
/// 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>) {
|
||||
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;
|
||||
};
|
||||
@@ -531,6 +722,57 @@ impl RepoStore {
|
||||
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>) {
|
||||
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<String> = 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.
|
||||
@@ -629,9 +871,41 @@ fn patch_current_commit(patch: &str) -> Option<&str> {
|
||||
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 super::patch_current_commit;
|
||||
use nostr_sdk::prelude::*;
|
||||
|
||||
use super::{comment_builder, patch_current_commit};
|
||||
|
||||
#[test]
|
||||
fn parses_format_patch_header() {
|
||||
@@ -648,4 +922,66 @@ mod tests {
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user