.
This commit is contained in:
@@ -0,0 +1,306 @@
|
|||||||
|
use nostr::prelude::*;
|
||||||
|
|
||||||
|
/// ngit / GitWorkshop cover-note extension (kind 1624): a markdown note
|
||||||
|
/// attached to an issue, patch or PR by its author or a repository
|
||||||
|
/// maintainer. Not part of the NIP-34 draft; read support for interop.
|
||||||
|
pub const COVER_NOTE_KIND: Kind = Kind::Custom(1624);
|
||||||
|
|
||||||
|
/// Whether a kind-1985 label event is a valid annotation of `root`: it
|
||||||
|
/// references the root via a lowercase `e` tag and was authored by the root
|
||||||
|
/// author or a maintainer.
|
||||||
|
fn label_targets_root(event: &Event, root: &Event, maintainers: &[PublicKey]) -> bool {
|
||||||
|
if event.kind != Kind::Label {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if event.pubkey != root.pubkey && !maintainers.contains(&event.pubkey) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
let root_id = root.id.to_hex();
|
||||||
|
event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.any(|tag| tag.kind() == "e" && tag.content().is_some_and(|content| content == root_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a kind-1985 label event declares the `#t` namespace and carries at
|
||||||
|
/// least one `["l", "<value>", "#t"]` label.
|
||||||
|
fn has_hashtag_labels(event: &Event) -> bool {
|
||||||
|
event.tags.iter().any(|tag| tag.as_slice() == ["L", "#t"])
|
||||||
|
&& event.tags.iter().any(|tag| {
|
||||||
|
let slice = tag.as_slice();
|
||||||
|
slice.len() >= 3 && slice[0] == "l" && slice[2] == "#t" && !slice[1].is_empty()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The effective hashtag labels of `root`: the `t` tags on the event itself
|
||||||
|
/// (self-reported by its author) plus all labels attached via authorized
|
||||||
|
/// NIP-32 kind-1985 events in the `#t` namespace. Labels are additive — all
|
||||||
|
/// valid label events contribute (no latest-wins semantics).
|
||||||
|
pub fn labels(root: &Event, label_events: &[Event], maintainers: &[PublicKey]) -> Vec<String> {
|
||||||
|
let mut labels: Vec<String> = root
|
||||||
|
.tags
|
||||||
|
.hashtags()
|
||||||
|
.map(|hashtag| hashtag.to_string())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for event in label_events {
|
||||||
|
if !label_targets_root(event, root, maintainers) || !has_hashtag_labels(event) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for tag in event.tags.iter() {
|
||||||
|
let slice = tag.as_slice();
|
||||||
|
if slice.len() >= 3 && slice[0] == "l" && slice[2] == "#t" && !slice[1].is_empty() {
|
||||||
|
let label = &slice[1];
|
||||||
|
if !labels.contains(label) {
|
||||||
|
labels.push(label.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
labels
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The effective subject/title override of `root`, from authorized kind-1985
|
||||||
|
/// label events in the `#subject` namespace. Only the latest event wins
|
||||||
|
/// (tiebreak: lexicographically larger event id, per NIP-01 replaceable
|
||||||
|
/// semantics). Returns `None` when no valid override exists.
|
||||||
|
pub fn subject_override(
|
||||||
|
root: &Event,
|
||||||
|
label_events: &[Event],
|
||||||
|
maintainers: &[PublicKey],
|
||||||
|
) -> Option<String> {
|
||||||
|
label_events
|
||||||
|
.iter()
|
||||||
|
.filter(|event| label_targets_root(event, root, maintainers))
|
||||||
|
.filter(|event| {
|
||||||
|
event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.any(|tag| tag.as_slice() == ["L", "#subject"])
|
||||||
|
&& event.tags.iter().any(|tag| {
|
||||||
|
let slice = tag.as_slice();
|
||||||
|
slice.len() >= 3
|
||||||
|
&& slice[0] == "l"
|
||||||
|
&& slice[2] == "#subject"
|
||||||
|
&& !slice[1].is_empty()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.max_by(|a, b| {
|
||||||
|
a.created_at
|
||||||
|
.cmp(&b.created_at)
|
||||||
|
.then_with(|| a.id.to_string().cmp(&b.id.to_string()))
|
||||||
|
})
|
||||||
|
.and_then(|event| {
|
||||||
|
event.tags.iter().find_map(|tag| {
|
||||||
|
let slice = tag.as_slice();
|
||||||
|
(slice.len() >= 3
|
||||||
|
&& slice[0] == "l"
|
||||||
|
&& slice[2] == "#subject"
|
||||||
|
&& !slice[1].is_empty())
|
||||||
|
.then(|| slice[1].clone())
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The effective hashtag labels and subject override of `root` in one pass
|
||||||
|
/// (mirrors ngit's `get_labels_and_subject`).
|
||||||
|
pub fn labels_and_subject(
|
||||||
|
root: &Event,
|
||||||
|
label_events: &[Event],
|
||||||
|
maintainers: &[PublicKey],
|
||||||
|
) -> (Vec<String>, Option<String>) {
|
||||||
|
(
|
||||||
|
labels(root, label_events, maintainers),
|
||||||
|
subject_override(root, label_events, maintainers),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The effective cover note of `root`: the latest authorized kind-1624 event
|
||||||
|
/// (tiebreak: lexicographically larger event id, per NIP-01 replaceable
|
||||||
|
/// semantics). Returns `None` when no valid cover note exists.
|
||||||
|
pub fn cover_note<'a>(
|
||||||
|
root: &Event,
|
||||||
|
cover_notes: &'a [Event],
|
||||||
|
maintainers: &[PublicKey],
|
||||||
|
) -> Option<&'a Event> {
|
||||||
|
let root_id = root.id.to_hex();
|
||||||
|
|
||||||
|
cover_notes
|
||||||
|
.iter()
|
||||||
|
.filter(|event| {
|
||||||
|
event.kind == COVER_NOTE_KIND
|
||||||
|
&& (event.pubkey == root.pubkey || maintainers.contains(&event.pubkey))
|
||||||
|
&& event.tags.iter().any(|tag| {
|
||||||
|
tag.kind() == "e" && tag.content().is_some_and(|content| content == root_id)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.max_by(|a, b| {
|
||||||
|
a.created_at
|
||||||
|
.cmp(&b.created_at)
|
||||||
|
.then_with(|| a.id.to_string().cmp(&b.id.to_string()))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn keys_from_hex(hex: &str) -> Keys {
|
||||||
|
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(created_at))
|
||||||
|
.finalize(author)
|
||||||
|
.expect("signed event")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn root_event() -> Event {
|
||||||
|
signed(
|
||||||
|
&keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001"),
|
||||||
|
Kind::GitIssue,
|
||||||
|
vec![Tag::hashtag("bug")],
|
||||||
|
100,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn e_tag(event: &Event) -> Tag {
|
||||||
|
Tag::parse(["e", &event.id.to_hex()]).expect("valid e tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn labels_take_inline_hashtags_and_external_label_events() {
|
||||||
|
let root = root_event();
|
||||||
|
let maintainer =
|
||||||
|
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
|
||||||
|
let labels_event = signed(
|
||||||
|
&maintainer,
|
||||||
|
Kind::Label,
|
||||||
|
vec![
|
||||||
|
e_tag(&root),
|
||||||
|
Tag::parse(["L", "#t"]).expect("valid L tag"),
|
||||||
|
Tag::parse(["l", "help-wanted", "#t"]).expect("valid l tag"),
|
||||||
|
],
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
|
||||||
|
let labels = labels(&root, &[labels_event], &[maintainer.public_key()]);
|
||||||
|
|
||||||
|
assert_eq!(labels, vec!["bug", "help-wanted"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn labels_ignore_unauthorized_and_misnamed_events() {
|
||||||
|
let root = root_event();
|
||||||
|
let maintainer =
|
||||||
|
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
|
||||||
|
let stranger =
|
||||||
|
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000003");
|
||||||
|
|
||||||
|
// A stranger's label event is not authorized.
|
||||||
|
let stranger_labels = signed(
|
||||||
|
&stranger,
|
||||||
|
Kind::Label,
|
||||||
|
vec![
|
||||||
|
e_tag(&root),
|
||||||
|
Tag::parse(["L", "#t"]).expect("valid L tag"),
|
||||||
|
Tag::parse(["l", "nope", "#t"]).expect("valid l tag"),
|
||||||
|
],
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
// A valid author referencing a different event.
|
||||||
|
let other_labels = signed(
|
||||||
|
&maintainer,
|
||||||
|
Kind::Label,
|
||||||
|
vec![
|
||||||
|
Tag::parse([
|
||||||
|
"e",
|
||||||
|
"2222222222222222222222222222222222222222222222222222222222222222",
|
||||||
|
])
|
||||||
|
.expect("valid e tag"),
|
||||||
|
Tag::parse(["L", "#t"]).expect("valid L tag"),
|
||||||
|
Tag::parse(["l", "nope", "#t"]).expect("valid l tag"),
|
||||||
|
],
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
// A valid author without the namespace declaration.
|
||||||
|
let missing_namespace = signed(
|
||||||
|
&maintainer,
|
||||||
|
Kind::Label,
|
||||||
|
vec![
|
||||||
|
e_tag(&root),
|
||||||
|
Tag::parse(["l", "nope", "#t"]).expect("valid l tag"),
|
||||||
|
],
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
labels(
|
||||||
|
&root,
|
||||||
|
&[stranger_labels, other_labels, missing_namespace],
|
||||||
|
&[maintainer.public_key()]
|
||||||
|
),
|
||||||
|
vec!["bug"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn subject_override_latest_authorized_event_wins() {
|
||||||
|
let root = root_event();
|
||||||
|
let maintainer =
|
||||||
|
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
|
||||||
|
let older = signed(
|
||||||
|
&maintainer,
|
||||||
|
Kind::Label,
|
||||||
|
vec![
|
||||||
|
e_tag(&root),
|
||||||
|
Tag::parse(["L", "#subject"]).expect("valid L tag"),
|
||||||
|
Tag::parse(["l", "Old title", "#subject"]).expect("valid l tag"),
|
||||||
|
],
|
||||||
|
200,
|
||||||
|
);
|
||||||
|
let newer = signed(
|
||||||
|
&maintainer,
|
||||||
|
Kind::Label,
|
||||||
|
vec![
|
||||||
|
e_tag(&root),
|
||||||
|
Tag::parse(["L", "#subject"]).expect("valid L tag"),
|
||||||
|
Tag::parse(["l", "New title", "#subject"]).expect("valid l tag"),
|
||||||
|
],
|
||||||
|
300,
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
subject_override(&root, &[newer, older], &[maintainer.public_key()]),
|
||||||
|
Some("New title".to_owned())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cover_note_latest_authorized_event_wins() {
|
||||||
|
let root = root_event();
|
||||||
|
let maintainer =
|
||||||
|
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002");
|
||||||
|
let stranger =
|
||||||
|
keys_from_hex("0000000000000000000000000000000000000000000000000000000000000003");
|
||||||
|
let older = signed(&maintainer, COVER_NOTE_KIND, vec![e_tag(&root)], 200);
|
||||||
|
let newer = signed(&maintainer, COVER_NOTE_KIND, vec![e_tag(&root)], 300);
|
||||||
|
let unauthorized = signed(&stranger, COVER_NOTE_KIND, vec![e_tag(&root)], 400);
|
||||||
|
|
||||||
|
let newer_id = newer.id;
|
||||||
|
let events = [older, unauthorized, newer];
|
||||||
|
let maintainers = [maintainer.public_key()];
|
||||||
|
let note = cover_note(&root, &events, &maintainers);
|
||||||
|
assert_eq!(note.map(|event| event.id), Some(newer_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn cover_note_none_without_valid_events() {
|
||||||
|
let root = root_event();
|
||||||
|
|
||||||
|
assert_eq!(cover_note(&root, &[], &[]), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
|
|
||||||
|
use nostr::prelude::*;
|
||||||
|
|
||||||
|
/// A NIP-22 comment thread: a top-level comment on the root event and its
|
||||||
|
/// nested replies (oldest first at every level).
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CommentThread {
|
||||||
|
/// The thread's top-level comment.
|
||||||
|
pub comment: Event,
|
||||||
|
/// Replies to [`Self::comment`], nested recursively.
|
||||||
|
pub replies: Vec<CommentThread>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The direct parent of a comment (NIP-22 lowercase `e` tag), or `None` for
|
||||||
|
/// comments without one.
|
||||||
|
fn comment_parent(event: &Event) -> Option<EventId> {
|
||||||
|
event
|
||||||
|
.tags
|
||||||
|
.iter()
|
||||||
|
.find(|tag| tag.kind() == "e")
|
||||||
|
.and_then(Tag::content)
|
||||||
|
.and_then(|id| EventId::parse(id).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Group the comments on a root event (issue / patch / PR) into NIP-22
|
||||||
|
/// threads. A comment whose parent is the root itself starts a thread; other
|
||||||
|
/// comments nest under their parent comment. Threads and replies are ordered
|
||||||
|
/// oldest-first. Replies whose parent comment is missing (e.g. not fetched)
|
||||||
|
/// are placed as top-level threads so they are not dropped.
|
||||||
|
pub fn comment_threads(root: &Event, comments: &[Event]) -> Vec<CommentThread> {
|
||||||
|
// Index comments by their parent id. Comments without a parent tag are
|
||||||
|
// treated as replying to the root event itself.
|
||||||
|
let mut children: HashMap<EventId, Vec<&Event>> = HashMap::new();
|
||||||
|
for comment in comments {
|
||||||
|
let parent = comment_parent(comment).unwrap_or(root.id);
|
||||||
|
children.entry(parent).or_default().push(comment);
|
||||||
|
}
|
||||||
|
for list in children.values_mut() {
|
||||||
|
list.sort_by_key(|event| event.created_at);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut visited: HashSet<EventId> = HashSet::new();
|
||||||
|
|
||||||
|
fn build(
|
||||||
|
id: EventId,
|
||||||
|
children: &HashMap<EventId, Vec<&Event>>,
|
||||||
|
visited: &mut HashSet<EventId>,
|
||||||
|
) -> Vec<CommentThread> {
|
||||||
|
let Some(list) = children.get(&id) else {
|
||||||
|
return Vec::new();
|
||||||
|
};
|
||||||
|
let mut threads = Vec::new();
|
||||||
|
for event in list {
|
||||||
|
// Guards against malformed reply cycles.
|
||||||
|
if visited.insert(event.id) {
|
||||||
|
threads.push(CommentThread {
|
||||||
|
comment: (*event).clone(),
|
||||||
|
replies: build(event.id, children, visited),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
threads
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut threads = build(root.id, &children, &mut visited);
|
||||||
|
|
||||||
|
// Orphan replies: their parent comment is unknown, so they never appear
|
||||||
|
// in the tree rooted at the root event; surface them as top-level threads.
|
||||||
|
let mut orphans: Vec<&Event> = comments
|
||||||
|
.iter()
|
||||||
|
.filter(|event| !visited.contains(&event.id))
|
||||||
|
.collect();
|
||||||
|
orphans.sort_by_key(|event| event.created_at);
|
||||||
|
for comment in orphans {
|
||||||
|
if visited.insert(comment.id) {
|
||||||
|
threads.push(CommentThread {
|
||||||
|
comment: comment.clone(),
|
||||||
|
replies: build(comment.id, &children, &mut visited),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
threads
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn comment(keys: &Keys, parent: Option<&Event>, content: &str, created_at: u64) -> Event {
|
||||||
|
let tags = parent
|
||||||
|
.map(|parent| vec![Tag::parse(["e", &parent.id.to_hex()]).expect("valid e tag")])
|
||||||
|
.unwrap_or_default();
|
||||||
|
EventBuilder::new(Kind::Comment, content)
|
||||||
|
.tags(tags)
|
||||||
|
.custom_created_at(Timestamp::from(created_at))
|
||||||
|
.finalize(keys)
|
||||||
|
.expect("signed event")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn flatten(threads: &[CommentThread]) -> Vec<String> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
for thread in threads {
|
||||||
|
out.push(thread.comment.content.clone());
|
||||||
|
out.extend(flatten(&thread.replies));
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nests_replies_under_their_parents() {
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let root = EventBuilder::new(Kind::GitIssue, "issue")
|
||||||
|
.finalize(&keys)
|
||||||
|
.expect("signed event");
|
||||||
|
|
||||||
|
let a = comment(&keys, Some(&root), "a", 100);
|
||||||
|
let a1 = comment(&keys, Some(&a), "a1", 200);
|
||||||
|
let a2 = comment(&keys, Some(&a), "a2", 300);
|
||||||
|
let b = comment(&keys, Some(&root), "b", 150);
|
||||||
|
|
||||||
|
let threads = comment_threads(&root, &[a2, b, a, a1]);
|
||||||
|
|
||||||
|
assert_eq!(flatten(&threads), vec!["a", "a1", "a2", "b"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn comments_without_a_parent_tag_attach_to_the_root() {
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let root = EventBuilder::new(Kind::GitIssue, "issue")
|
||||||
|
.finalize(&keys)
|
||||||
|
.expect("signed event");
|
||||||
|
|
||||||
|
// Old-style comments carried no `e` tag at all.
|
||||||
|
let orphan = comment(&keys, None, "no parent", 100);
|
||||||
|
|
||||||
|
let threads = comment_threads(&root, &[orphan]);
|
||||||
|
|
||||||
|
assert_eq!(flatten(&threads), vec!["no parent"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn orphan_replies_are_surfaced_as_top_level_threads() {
|
||||||
|
let keys = Keys::generate();
|
||||||
|
let root = EventBuilder::new(Kind::GitIssue, "issue")
|
||||||
|
.finalize(&keys)
|
||||||
|
.expect("signed event");
|
||||||
|
let a = comment(&keys, Some(&root), "a", 100);
|
||||||
|
|
||||||
|
// `missing` is not in the comment set; its reply should still show up.
|
||||||
|
let missing = EventBuilder::new(Kind::Comment, "missing")
|
||||||
|
.finalize(&keys)
|
||||||
|
.expect("signed event");
|
||||||
|
let reply_to_missing = comment(&keys, Some(&missing), "reply to missing", 200);
|
||||||
|
|
||||||
|
let threads = comment_threads(&root, &[a, reply_to_missing]);
|
||||||
|
|
||||||
|
assert_eq!(flatten(&threads), vec!["a", "reply to missing"]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,15 @@ pub fn statuses_for(root: EventId) -> Filter {
|
|||||||
.event(root)
|
.event(root)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Cover notes (kind 1624) and NIP-32 label events (kind 1985) referencing a
|
||||||
|
/// specific root event (`#e` tag), fetched per root like comments and
|
||||||
|
/// statuses because they carry no repository `a` tag.
|
||||||
|
pub fn annotations_for(root: EventId) -> Filter {
|
||||||
|
Filter::new()
|
||||||
|
.kinds([crate::COVER_NOTE_KIND, Kind::Label])
|
||||||
|
.event(root)
|
||||||
|
}
|
||||||
|
|
||||||
/// A user's grasp list (kind `10317`).
|
/// A user's grasp list (kind `10317`).
|
||||||
pub fn grasp_list(public_key: PublicKey) -> Filter {
|
pub fn grasp_list(public_key: PublicKey) -> Filter {
|
||||||
Filter::new()
|
Filter::new()
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod addr;
|
pub mod addr;
|
||||||
|
pub mod annotations;
|
||||||
pub mod clone_url;
|
pub mod clone_url;
|
||||||
|
pub mod comments;
|
||||||
pub mod deletions;
|
pub mod deletions;
|
||||||
pub mod filters;
|
pub mod filters;
|
||||||
pub mod model;
|
pub mod model;
|
||||||
@@ -7,8 +9,10 @@ pub mod state;
|
|||||||
pub mod status;
|
pub mod status;
|
||||||
|
|
||||||
pub use addr::{RepoAddr, repo_addr};
|
pub use addr::{RepoAddr, repo_addr};
|
||||||
|
pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_override};
|
||||||
pub use clone_url::{CloneTarget, parse_clone_url};
|
pub use clone_url::{CloneTarget, parse_clone_url};
|
||||||
|
pub use comments::{CommentThread, comment_threads};
|
||||||
pub use deletions::Deletions;
|
pub use deletions::Deletions;
|
||||||
pub use model::{Announcement, activity_subject, pull_request_patch};
|
pub use model::{Announcement, activity_subject, pull_request_patch};
|
||||||
pub use state::parse_state;
|
pub use state::{build_state, parse_state};
|
||||||
pub use status::{RepoStatus, references_root, resolve_status};
|
pub use status::{RepoStatus, references_root, resolve_status};
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ pub struct Announcement {
|
|||||||
pub euc: Option<String>,
|
pub euc: Option<String>,
|
||||||
/// Other recognized maintainers.
|
/// Other recognized maintainers.
|
||||||
pub maintainers: Vec<PublicKey>,
|
pub maintainers: Vec<PublicKey>,
|
||||||
|
/// Value of a `u` tag, if any: this repository is a subordinate fork of
|
||||||
|
/// the referenced upstream (NIP-34).
|
||||||
|
pub upstream: Option<String>,
|
||||||
/// Hashtags labelling the repository (`t` tags).
|
/// Hashtags labelling the repository (`t` tags).
|
||||||
pub hashtags: Vec<String>,
|
pub hashtags: Vec<String>,
|
||||||
}
|
}
|
||||||
@@ -186,6 +189,7 @@ impl Announcement {
|
|||||||
let mut relays: Vec<RelayUrl> = Vec::new();
|
let mut relays: Vec<RelayUrl> = Vec::new();
|
||||||
let mut euc: Option<String> = None;
|
let mut euc: Option<String> = None;
|
||||||
let mut maintainers: Vec<PublicKey> = Vec::new();
|
let mut maintainers: Vec<PublicKey> = Vec::new();
|
||||||
|
let mut upstream: Option<String> = None;
|
||||||
|
|
||||||
for tag in event.tags.iter() {
|
for tag in event.tags.iter() {
|
||||||
match Nip34Tag::parse(tag.as_slice()) {
|
match Nip34Tag::parse(tag.as_slice()) {
|
||||||
@@ -198,6 +202,12 @@ impl Announcement {
|
|||||||
Ok(Nip34Tag::Maintainers(keys)) => maintainers.extend(keys),
|
Ok(Nip34Tag::Maintainers(keys)) => maintainers.extend(keys),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The `u` tag is not modelled by the SDK's `Nip34Tag`; parse it
|
||||||
|
// manually (first value wins).
|
||||||
|
if upstream.is_none() && tag.kind() == "u" {
|
||||||
|
upstream = tag.content().map(str::to_owned);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Some(Self {
|
Some(Self {
|
||||||
@@ -211,6 +221,7 @@ impl Announcement {
|
|||||||
relays,
|
relays,
|
||||||
euc,
|
euc,
|
||||||
maintainers,
|
maintainers,
|
||||||
|
upstream,
|
||||||
hashtags,
|
hashtags,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -226,6 +237,18 @@ impl Announcement {
|
|||||||
.clone()
|
.clone()
|
||||||
.unwrap_or(SharedString::from("No description"))
|
.unwrap_or(SharedString::from("No description"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The effective maintainers of this repository: the announced
|
||||||
|
/// `maintainers` plus the announcement author, who asserts themselves as
|
||||||
|
/// a maintainer of the primary project unless a `u` tag marks this
|
||||||
|
/// repository as a subordinate fork (NIP-34).
|
||||||
|
pub fn effective_maintainers(&self) -> Vec<PublicKey> {
|
||||||
|
let mut maintainers = self.maintainers.clone();
|
||||||
|
if self.upstream.is_none() && !maintainers.contains(&self.owner) {
|
||||||
|
maintainers.push(self.owner);
|
||||||
|
}
|
||||||
|
maintainers
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -348,6 +371,55 @@ mod tests {
|
|||||||
assert!(announcement.web.is_empty());
|
assert!(announcement.web.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_upstream_tag() {
|
||||||
|
let event = announcement_event(&[
|
||||||
|
&["d", "my-fork"],
|
||||||
|
&["u", "30617:abc:upstream|https://example.com/upstream.git"],
|
||||||
|
]);
|
||||||
|
|
||||||
|
let announcement = Announcement::from_event(&event).expect("parses");
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
announcement.upstream.as_deref(),
|
||||||
|
Some("30617:abc:upstream|https://example.com/upstream.git")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effective_maintainers_include_owner_for_primary_repos() {
|
||||||
|
let event = announcement_event(&[&["d", "my-repo"], &["maintainers", MAINTAINER_HEX]]);
|
||||||
|
|
||||||
|
let announcement = Announcement::from_event(&event).expect("parses");
|
||||||
|
let maintainers = announcement.effective_maintainers();
|
||||||
|
|
||||||
|
// The owner asserts themselves as a maintainer of the primary
|
||||||
|
// project (NIP-34), alongside the announced co-maintainers.
|
||||||
|
assert_eq!(maintainers.len(), 2);
|
||||||
|
assert!(maintainers.contains(&announcement.owner));
|
||||||
|
assert!(maintainers.contains(&PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn effective_maintainers_exclude_owner_for_subordinate_forks() {
|
||||||
|
let event = announcement_event(&[
|
||||||
|
&["d", "my-fork"],
|
||||||
|
&["u", "30617:abc:upstream|https://example.com/upstream.git"],
|
||||||
|
&["maintainers", MAINTAINER_HEX],
|
||||||
|
]);
|
||||||
|
|
||||||
|
let announcement = Announcement::from_event(&event).expect("parses");
|
||||||
|
let maintainers = announcement.effective_maintainers();
|
||||||
|
|
||||||
|
// A `u` tag marks the repository as a subordinate fork: the author
|
||||||
|
// is not a maintainer of the primary project (NIP-34).
|
||||||
|
assert!(!maintainers.contains(&announcement.owner));
|
||||||
|
assert_eq!(
|
||||||
|
maintainers,
|
||||||
|
vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a signed PR event with the given tags and content.
|
/// Build a signed PR event with the given tags and content.
|
||||||
fn pr_event(content: &str, tags: Vec<Tag>) -> Event {
|
fn pr_event(content: &str, tags: Vec<Tag>) -> Event {
|
||||||
EventBuilder::new(Kind::GitPullRequest, content)
|
EventBuilder::new(Kind::GitPullRequest, content)
|
||||||
|
|||||||
@@ -1,5 +1,23 @@
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
|
/// Build a kind `30618` repository state event from refs and HEAD.
|
||||||
|
///
|
||||||
|
/// `refs` are `(refname, commit-id)` pairs (e.g. `refs/heads/main`); `head`
|
||||||
|
/// is the short branch name HEAD points to, published as
|
||||||
|
/// `ref: refs/heads/<branch>`. The `d` tag matches the repository id.
|
||||||
|
pub fn build_state(id: &str, refs: &[(String, String)], head: Option<&str>) -> EventBuilder {
|
||||||
|
let mut tags: Vec<Tag> = vec![Tag::identifier(id.to_owned())];
|
||||||
|
for (name, commit) in refs {
|
||||||
|
tags.push(Tag::parse([name.as_str(), commit.as_str()]).expect("valid ref tag"));
|
||||||
|
}
|
||||||
|
if let Some(head) = head {
|
||||||
|
tags.push(
|
||||||
|
Tag::parse(["HEAD", &format!("ref: refs/heads/{head}")]).expect("valid HEAD tag"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
EventBuilder::new(Kind::RepoState, "").tags(tags)
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse a kind `30618` repository state event into refs and HEAD.
|
/// Parse a kind `30618` repository state event into refs and HEAD.
|
||||||
///
|
///
|
||||||
/// `refs` are `(refname, commit-id)` pairs; `head` is the branch pointed to
|
/// `refs` are `(refname, commit-id)` pairs; `head` is the branch pointed to
|
||||||
@@ -92,4 +110,37 @@ mod tests {
|
|||||||
assert!(refs.is_empty());
|
assert!(refs.is_empty());
|
||||||
assert!(head.is_none());
|
assert!(head.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_state_round_trips_through_parse() {
|
||||||
|
let refs = [
|
||||||
|
("refs/heads/main".to_owned(), COMMIT_A.to_owned()),
|
||||||
|
("refs/heads/dev".to_owned(), COMMIT_B.to_owned()),
|
||||||
|
("refs/tags/v1.0".to_owned(), COMMIT_A.to_owned()),
|
||||||
|
];
|
||||||
|
|
||||||
|
let event = build_state("my-repo", &refs, Some("main"))
|
||||||
|
.finalize(&keys())
|
||||||
|
.expect("signed event");
|
||||||
|
|
||||||
|
assert_eq!(event.kind, Kind::RepoState);
|
||||||
|
assert_eq!(event.tags.identifier().as_deref(), Some("my-repo"));
|
||||||
|
|
||||||
|
let (parsed_refs, head) = parse_state(&event);
|
||||||
|
assert_eq!(parsed_refs, refs);
|
||||||
|
assert_eq!(head.as_deref(), Some("main"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_state_omits_head_when_detached() {
|
||||||
|
let refs = [("refs/heads/main".to_owned(), COMMIT_A.to_owned())];
|
||||||
|
|
||||||
|
let event = build_state("my-repo", &refs, None)
|
||||||
|
.finalize(&keys())
|
||||||
|
.expect("signed event");
|
||||||
|
|
||||||
|
let (parsed_refs, head) = parse_state(&event);
|
||||||
|
assert_eq!(parsed_refs, refs);
|
||||||
|
assert!(head.is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1245,6 +1245,53 @@ pub fn current_branch(repo: &gix::Repository) -> Result<Option<String>> {
|
|||||||
Ok(Some(String::from_utf8_lossy(name.shorten()).into_owned()))
|
Ok(Some(String::from_utf8_lossy(name.shorten()).into_owned()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Branch, tag and HEAD refs of a repository, ready for a NIP-34 kind-30618
|
||||||
|
/// repository state announcement.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct RepoRefState {
|
||||||
|
/// `(full refname, commit id)` pairs for heads and tags, sorted.
|
||||||
|
pub refs: Vec<(String, String)>,
|
||||||
|
/// Short branch name HEAD points to, or `None` when detached.
|
||||||
|
pub head: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collect the refs of `repo`: local branches and tags as
|
||||||
|
/// `(refname, commit-id)` pairs, plus the branch HEAD points to.
|
||||||
|
pub fn repo_ref_state(repo: &gix::Repository) -> Result<RepoRefState> {
|
||||||
|
let mut refs = Vec::new();
|
||||||
|
|
||||||
|
for reference in repo.references()?.local_branches()? {
|
||||||
|
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
refs.push((
|
||||||
|
String::from_utf8_lossy(reference.name().as_bstr()).into_owned(),
|
||||||
|
reference.id().to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
for reference in repo.references()?.tags()? {
|
||||||
|
let reference = reference.map_err(|error| anyhow::anyhow!("{error}"))?;
|
||||||
|
refs.push((
|
||||||
|
String::from_utf8_lossy(reference.name().as_bstr()).into_owned(),
|
||||||
|
reference.id().to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
refs.sort();
|
||||||
|
|
||||||
|
let head = match repo.head() {
|
||||||
|
Ok(head) => head
|
||||||
|
.referent_name()
|
||||||
|
.filter(|name| name.as_bstr().starts_with(b"refs/heads/"))
|
||||||
|
.map(|name| String::from_utf8_lossy(name.shorten()).into_owned()),
|
||||||
|
Err(_) => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(RepoRefState { refs, head })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`repo_ref_state`] for the repository at `workdir`.
|
||||||
|
pub fn worktree_ref_state(workdir: &Path) -> Result<RepoRefState> {
|
||||||
|
repo_ref_state(&open_with_cache(workdir)?)
|
||||||
|
}
|
||||||
|
|
||||||
/// Everything the browser needs to refresh after a branch or tag switch.
|
/// Everything the browser needs to refresh after a branch or tag switch.
|
||||||
pub struct WorktreeSnapshot {
|
pub struct WorktreeSnapshot {
|
||||||
/// Relative paths of all worktree entries, directories first.
|
/// Relative paths of all worktree entries, directories first.
|
||||||
@@ -1375,6 +1422,47 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn repo_ref_state_lists_branches_tags_and_head() {
|
||||||
|
let (_dir, repo) = fixture(&[("a.txt", b"hello")]);
|
||||||
|
commit_all(&repo, "initial");
|
||||||
|
let workdir = repo.workdir().expect("workdir").to_path_buf();
|
||||||
|
|
||||||
|
let state = repo_ref_state(&repo).expect("refs");
|
||||||
|
|
||||||
|
let branch = current_branch(&repo).expect("branch").expect("on a branch");
|
||||||
|
assert_eq!(state.head.as_deref(), Some(branch.as_str()));
|
||||||
|
assert_eq!(state.refs.len(), 1);
|
||||||
|
assert_eq!(state.refs[0].0, format!("refs/heads/{branch}"));
|
||||||
|
assert_eq!(state.refs[0].1.len(), 40);
|
||||||
|
|
||||||
|
// Additional branches and tags are listed alongside.
|
||||||
|
git_run(&workdir, &["branch", "feature"]);
|
||||||
|
git_run(&workdir, &["tag", "v1.0"]);
|
||||||
|
|
||||||
|
let state = repo_ref_state(&repo).expect("refs");
|
||||||
|
let mut expected: Vec<String> = vec![
|
||||||
|
format!("refs/heads/{branch}"),
|
||||||
|
"refs/heads/feature".to_owned(),
|
||||||
|
"refs/tags/v1.0".to_owned(),
|
||||||
|
];
|
||||||
|
expected.sort();
|
||||||
|
assert_eq!(
|
||||||
|
state
|
||||||
|
.refs
|
||||||
|
.iter()
|
||||||
|
.map(|(name, _)| name.clone())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
expected
|
||||||
|
);
|
||||||
|
|
||||||
|
// A detached HEAD yields no head branch.
|
||||||
|
git_run(&workdir, &["checkout", "--detach"]);
|
||||||
|
let state = repo_ref_state(&repo).expect("refs");
|
||||||
|
assert!(state.head.is_none());
|
||||||
|
assert_eq!(state.refs.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
/// Build a throwaway non-bare repository with the given files (rel → bytes).
|
/// Build a throwaway non-bare repository with the given files (rel → bytes).
|
||||||
fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, gix::Repository) {
|
fn fixture(files: &[(&str, &[u8])]) -> (tempfile::TempDir, gix::Repository) {
|
||||||
let dir = tempfile::tempdir().expect("tempdir");
|
let dir = tempfile::tempdir().expect("tempdir");
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use anyhow::{Error, anyhow};
|
use anyhow::{Error, anyhow};
|
||||||
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task};
|
||||||
|
use nostr::event::IntoEventBuilder;
|
||||||
use nostr_connect::prelude::*;
|
use nostr_connect::prelude::*;
|
||||||
use nostr_sdk::client::SyncSummary;
|
use nostr_sdk::client::SyncSummary;
|
||||||
use nostr_sdk::prelude::*;
|
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;
|
/// Sign, broadcast and store an event without awaiting the result;
|
||||||
/// failures surface through [`BackendEvent::Error`]. The spawned task is
|
/// failures surface through [`BackendEvent::Error`]. The spawned task is
|
||||||
/// owned by the backend, so it is cancelled when the backend is dropped.
|
/// 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::collections::HashSet;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use gpui::{AppContext, Context, Subscription, Task};
|
use gpui::{AppContext, Context, Subscription, Task};
|
||||||
|
use nostr::event::IntoEventBuilder;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use signed_core::{
|
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};
|
use crate::backend::{Backend, BackendEvent};
|
||||||
@@ -31,15 +34,21 @@ pub struct RepoStore {
|
|||||||
/// Comments on issues / PRs, oldest first.
|
/// Comments on issues / PRs, oldest first.
|
||||||
pub comments: Vec<Event>,
|
pub comments: Vec<Event>,
|
||||||
statuses: 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.
|
/// Error of the last action initiated from this store, if any.
|
||||||
pub last_error: Option<String>,
|
pub last_error: Option<String>,
|
||||||
/// Relays announced by this repository (NIP-34 `relays` tag) that we
|
/// Relays announced by this repository (NIP-34 `relays` tag) that we
|
||||||
/// have already been asked to connect to and fetch from, to avoid
|
/// have already been asked to connect to and fetch from, to avoid
|
||||||
/// re-subscribing on every refresh.
|
/// re-subscribing on every refresh.
|
||||||
repo_relays: HashSet<RelayUrl>,
|
repo_relays: HashSet<RelayUrl>,
|
||||||
/// Root events (issues, patches, PRs) for which a NIP-22 comment fetch
|
/// Root events (issues, patches, PRs) for which the per-root fetches
|
||||||
/// has already been requested, to avoid re-fetching on every refresh.
|
/// (NIP-22 comments, statuses without an `a` tag, cover notes and
|
||||||
comment_roots: HashSet<EventId>,
|
/// labels) have already been requested, to avoid re-fetching on every
|
||||||
|
/// refresh.
|
||||||
|
root_fetches: HashSet<EventId>,
|
||||||
refreshing: bool,
|
refreshing: bool,
|
||||||
refresh_dirty: bool,
|
refresh_dirty: bool,
|
||||||
/// A refresh is waiting out [`REFRESH_DEBOUNCE`].
|
/// A refresh is waiting out [`REFRESH_DEBOUNCE`].
|
||||||
@@ -65,8 +74,13 @@ impl RepoStore {
|
|||||||
// matched by coordinate; any comment may reference this
|
// matched by coordinate; any comment may reference this
|
||||||
// repository's roots.
|
// repository's roots.
|
||||||
let comment = update.kind == Kind::Comment;
|
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) => {
|
BackendEvent::Published(event) => {
|
||||||
let kind = event.kind == Kind::GitRepoAnnouncement;
|
let kind = event.kind == Kind::GitRepoAnnouncement;
|
||||||
@@ -93,9 +107,11 @@ impl RepoStore {
|
|||||||
pull_requests: Vec::new(),
|
pull_requests: Vec::new(),
|
||||||
comments: Vec::new(),
|
comments: Vec::new(),
|
||||||
statuses: Vec::new(),
|
statuses: Vec::new(),
|
||||||
|
cover_notes: Vec::new(),
|
||||||
|
labels: Vec::new(),
|
||||||
last_error: None,
|
last_error: None,
|
||||||
repo_relays: HashSet::new(),
|
repo_relays: HashSet::new(),
|
||||||
comment_roots: HashSet::new(),
|
root_fetches: HashSet::new(),
|
||||||
refreshing: false,
|
refreshing: false,
|
||||||
refresh_dirty: false,
|
refresh_dirty: false,
|
||||||
debouncing: false,
|
debouncing: false,
|
||||||
@@ -227,6 +243,7 @@ impl RepoStore {
|
|||||||
|
|
||||||
let (mut issues, mut patches, mut pull_requests, mut statuses, mut comments) =
|
let (mut issues, mut patches, mut pull_requests, mut statuses, mut comments) =
|
||||||
(Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new());
|
(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 {
|
for event in activity {
|
||||||
if deletions.is_deleted(&event) {
|
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 issues);
|
||||||
sort_newest_first(&mut patches);
|
sort_newest_first(&mut patches);
|
||||||
sort_newest_first(&mut pull_requests);
|
sort_newest_first(&mut pull_requests);
|
||||||
sort_oldest_first(&mut comments);
|
sort_oldest_first(&mut comments);
|
||||||
|
sort_newest_first(&mut cover_notes);
|
||||||
|
sort_newest_first(&mut labels);
|
||||||
|
|
||||||
Ok::<_, Error>((
|
Ok::<_, Error>((
|
||||||
announcement,
|
announcement,
|
||||||
@@ -273,23 +334,34 @@ impl RepoStore {
|
|||||||
pull_requests,
|
pull_requests,
|
||||||
statuses,
|
statuses,
|
||||||
comments,
|
comments,
|
||||||
|
cover_notes,
|
||||||
|
labels,
|
||||||
))
|
))
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.retain(|task| !task.is_ready());
|
self.tasks.retain(|task| !task.is_ready());
|
||||||
|
|
||||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let (announcement, state, issues, patches, pull_requests, statuses, comments) =
|
let (
|
||||||
match work.await {
|
announcement,
|
||||||
Ok(data) => data,
|
state,
|
||||||
Err(e) => {
|
issues,
|
||||||
return this.update(cx, |this, cx| {
|
patches,
|
||||||
this.refreshing = false;
|
pull_requests,
|
||||||
this.last_error = Some(e.to_string());
|
statuses,
|
||||||
cx.notify();
|
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| {
|
let again = this.update(cx, |this, cx| {
|
||||||
this.announcement = announcement;
|
this.announcement = announcement;
|
||||||
@@ -313,10 +385,13 @@ impl RepoStore {
|
|||||||
this.pull_requests = pull_requests;
|
this.pull_requests = pull_requests;
|
||||||
this.comments = comments;
|
this.comments = comments;
|
||||||
this.statuses = statuses;
|
this.statuses = statuses;
|
||||||
|
this.cover_notes = cover_notes;
|
||||||
|
this.labels = labels;
|
||||||
|
|
||||||
// Comments are not addressed to the repository, so fetch
|
// Comments, statuses without an `a` tag, cover notes and
|
||||||
// them by the root events they reference, on the bootstrap
|
// labels are not addressed to the repository, so fetch them
|
||||||
// relays and on the relays this repository announced.
|
// by the root events they reference, on the bootstrap relays
|
||||||
|
// and on the relays this repository announced.
|
||||||
let roots = this
|
let roots = this
|
||||||
.issues
|
.issues
|
||||||
.iter()
|
.iter()
|
||||||
@@ -326,17 +401,30 @@ impl RepoStore {
|
|||||||
.collect::<HashSet<EventId>>();
|
.collect::<HashSet<EventId>>();
|
||||||
let new_roots: Vec<EventId> = roots
|
let new_roots: Vec<EventId> = roots
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|id| !this.comment_roots.contains(id))
|
.filter(|id| !this.root_fetches.contains(id))
|
||||||
.copied()
|
.copied()
|
||||||
.collect();
|
.collect();
|
||||||
if !new_roots.is_empty() {
|
if !new_roots.is_empty() {
|
||||||
this.comment_roots.extend(new_roots.iter().copied());
|
this.root_fetches.extend(new_roots.iter().copied());
|
||||||
let comment_filters = filters::comments_for(new_roots);
|
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 announced: Vec<RelayUrl> = this.repo_relays.iter().cloned().collect();
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
backend.update(cx, |backend, cx| {
|
backend.update(cx, |backend, cx| {
|
||||||
backend.subscribe_bootstrap(comment_filters.clone(), 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
|
let maintainers = self
|
||||||
.announcement
|
.announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|a| a.maintainers.as_slice())
|
.map(Announcement::effective_maintainers)
|
||||||
.unwrap_or(&[]);
|
.unwrap_or_default();
|
||||||
|
|
||||||
let events = self
|
let events = self
|
||||||
.statuses
|
.statuses
|
||||||
.iter()
|
.iter()
|
||||||
.filter(|e| signed_core::references_root(e, &root.id));
|
.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
|
/// Number of open issues: issues whose resolved status is
|
||||||
@@ -423,19 +548,32 @@ impl RepoStore {
|
|||||||
.filter(move |e| signed_core::references_root(e, root))
|
.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>) {
|
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 {
|
self.reply(root, None, content, cx);
|
||||||
return;
|
}
|
||||||
};
|
|
||||||
|
|
||||||
let builder = EventBuilder::new(Kind::Comment, content).tags([
|
/// Reply to `parent` (a comment on `root`) with a NIP-22 threaded
|
||||||
root_ref,
|
/// comment; `None` publishes a top-level comment on the root itself.
|
||||||
Tag::public_key(root.pubkey),
|
pub fn reply(
|
||||||
Tag::coordinate(self.addr.clone(), None),
|
&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)
|
/// 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).
|
/// references via an `e` tag (NIP-34).
|
||||||
///
|
///
|
||||||
/// The patch is published first and the PR is sent once the patch
|
/// 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
|
/// event's id is known, so the two always arrive together. The proposed
|
||||||
/// metadata (branch name, clone URL, merge base) isn't known to the UI
|
/// commit is parsed from the patch's `From <commit>` header; publishing
|
||||||
/// yet and is left empty; the proposed commit is parsed from the patch's
|
/// without one is refused, because the PR's `c` tag (and the patch's
|
||||||
/// `From <commit>` header, falling back to an empty hash for hand-written
|
/// `commit`/`r` tags) must carry a real commit id for other NIP-34
|
||||||
/// content.
|
/// 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(
|
pub fn open_pull_request(
|
||||||
&mut self,
|
&mut self,
|
||||||
subject: Option<String>,
|
subject: Option<String>,
|
||||||
@@ -458,18 +599,39 @@ impl RepoStore {
|
|||||||
) {
|
) {
|
||||||
self.last_error = None;
|
self.last_error = None;
|
||||||
|
|
||||||
let current_commit = patch_current_commit(&patch)
|
let Some(current_commit) =
|
||||||
.and_then(|hex| hex.parse().ok())
|
patch_current_commit(&patch).and_then(|hex| hex.parse::<bitcoin_hashes::Sha1>().ok())
|
||||||
.unwrap_or_else(|| bitcoin_hashes::Sha1::from_byte_array([0u8; 20]));
|
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 {
|
let Ok(root_marker) = Tag::parse(["t", "root"]) else {
|
||||||
return;
|
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::coordinate(self.addr.clone(), None),
|
||||||
Tag::public_key(self.addr.public_key),
|
Tag::public_key(self.addr.public_key),
|
||||||
root_marker,
|
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 =
|
let patch_task =
|
||||||
Backend::global(cx).update(cx, |backend, cx| backend.send(patch_builder, cx));
|
Backend::global(cx).update(cx, |backend, cx| backend.send(patch_builder, cx));
|
||||||
@@ -494,7 +656,14 @@ impl RepoStore {
|
|||||||
subject,
|
subject,
|
||||||
labels: Vec::new(),
|
labels: Vec::new(),
|
||||||
branch_name: None,
|
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,
|
current_commit,
|
||||||
root_patch_event: Some(patch_event.id),
|
root_patch_event: Some(patch_event.id),
|
||||||
merge_base: None,
|
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>) {
|
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 {
|
let Ok(root_ref) = Tag::parse(["e", &root.id.to_hex(), "", "root"]) else {
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
@@ -531,6 +722,57 @@ impl RepoStore {
|
|||||||
self.send(builder, cx);
|
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
|
/// Merge a pull request: apply its patch (the content of the linked
|
||||||
/// root patch event) to the local clone of this repository, then publish
|
/// root patch event) to the local clone of this repository, then publish
|
||||||
/// the merged status.
|
/// the merged status.
|
||||||
@@ -629,9 +871,41 @@ fn patch_current_commit(patch: &str) -> Option<&str> {
|
|||||||
hex.split_whitespace().next().filter(|hex| hex.len() == 40)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::patch_current_commit;
|
use nostr_sdk::prelude::*;
|
||||||
|
|
||||||
|
use super::{comment_builder, patch_current_commit};
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parses_format_patch_header() {
|
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("Subject: [PATCH] x\n\n---\n"), None);
|
||||||
assert_eq!(patch_current_commit("From short\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