Files
signed/crates/signed_core/src/model.rs
T
2026-08-17 11:22:52 +07:00

234 lines
7.5 KiB
Rust

use gpui::SharedString;
use nostr::prelude::*;
/// Parsed NIP-34 repository announcement (plain data, ready for the UI).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Announcement {
/// Repository ID (`d` tag).
pub id: String,
/// Author of the announcement event.
pub owner: PublicKey,
/// When the announcement was published (for latest-wins resolution).
pub created_at: Timestamp,
pub name: Option<SharedString>,
pub description: Option<SharedString>,
/// Webpage URLs for browsing.
pub web: Vec<Url>,
/// URLs for `git clone`.
pub clone: Vec<Url>,
/// Relays the repository monitors for patches and issues.
pub relays: Vec<RelayUrl>,
/// Earliest unique commit ID (`r` tag with `euc` marker).
pub euc: Option<String>,
/// Other recognized maintainers.
pub maintainers: Vec<PublicKey>,
/// Hashtags labelling the repository (`t` tags).
pub hashtags: Vec<String>,
}
/// Subject of a NIP-34 issue or pull request event: the `subject` tag,
/// falling back to the first non-empty line of the content.
pub fn activity_subject(event: &Event) -> SharedString {
let subject = event
.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Subject(subject)) => Some(subject),
_ => None,
});
subject
.map(SharedString::from)
.or_else(|| {
event
.content
.lines()
.map(str::trim)
.find(|line| !line.is_empty())
.map(SharedString::from)
})
.unwrap_or(SharedString::from("Untitled"))
}
impl Announcement {
/// Parse a kind `30617` event. Returns `None` if the kind is wrong or the `d` tag is missing.
pub fn from_event(event: &Event) -> Option<Self> {
if event.kind != Kind::GitRepoAnnouncement {
return None;
}
let id = event.tags.identifier()?;
let mut hashtags: Vec<String> = Vec::new();
hashtags.extend(event.tags.hashtags().map(|t| t.to_string()));
let mut name: Option<SharedString> = None;
let mut description: Option<SharedString> = None;
let mut web: Vec<Url> = Vec::new();
let mut clone: Vec<Url> = Vec::new();
let mut relays: Vec<RelayUrl> = Vec::new();
let mut euc: Option<String> = None;
let mut maintainers: Vec<PublicKey> = Vec::new();
for tag in event.tags.iter() {
match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Name(value)) => name = Some(value.into()),
Ok(Nip34Tag::Description(value)) => description = Some(value.into()),
Ok(Nip34Tag::Web(urls)) => web.extend(urls),
Ok(Nip34Tag::Clone(urls)) => clone.extend(urls),
Ok(Nip34Tag::Relays(urls)) => relays.extend(urls),
Ok(Nip34Tag::EarliestUniqueCommitId(commit)) => euc = Some(commit.to_string()),
Ok(Nip34Tag::Maintainers(keys)) => maintainers.extend(keys),
_ => {}
}
}
Some(Self {
owner: event.pubkey,
created_at: event.created_at,
id,
name,
description,
web,
clone,
relays,
euc,
maintainers,
hashtags,
})
}
/// The repository address of this announcement.
pub fn addr(&self) -> crate::RepoAddr {
crate::repo_addr(self.owner, self.id.clone())
}
/// The description of the repository, or a default if none is provided.
pub fn description(&self) -> SharedString {
self.description
.clone()
.unwrap_or(SharedString::from("No description"))
}
}
#[cfg(test)]
mod tests {
use super::*;
const MAINTAINER_HEX: &str = "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272";
fn keys() -> Keys {
Keys::new(
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
.expect("valid secret key"),
)
}
/// Build a signed kind `30617` event from raw tag values.
fn announcement_event(tags: &[&[&str]]) -> Event {
let tags: Vec<Tag> = tags
.iter()
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
.collect();
EventBuilder::new(Kind::GitRepoAnnouncement, "")
.tags(tags)
.finalize(&keys())
.expect("signed event")
}
#[test]
fn parses_full_announcement() {
let event = announcement_event(&[
&["d", "my-repo"],
&["name", "My Repo"],
&["description", "A test repository"],
&["web", "https://example.com/repo"],
&["clone", "https://example.com/repo.git"],
&["relays", "wss://relay.example.com"],
&["r", "aa231c4c6a5777dc89b42207b499891a344add5c", "euc"],
&["maintainers", MAINTAINER_HEX],
&["t", "rust"],
&["t", "nostr"],
]);
let announcement = Announcement::from_event(&event).expect("parses");
assert_eq!(announcement.owner, keys().public_key());
assert_eq!(announcement.id, "my-repo");
assert_eq!(announcement.name.as_deref(), Some("My Repo"));
assert_eq!(
announcement.description.as_deref(),
Some("A test repository")
);
assert_eq!(
announcement.web,
vec![Url::parse("https://example.com/repo").unwrap()]
);
assert_eq!(
announcement.clone,
vec![Url::parse("https://example.com/repo.git").unwrap()]
);
assert_eq!(
announcement.relays,
vec![RelayUrl::parse("wss://relay.example.com").unwrap()]
);
assert_eq!(
announcement.euc.as_deref(),
Some("aa231c4c6a5777dc89b42207b499891a344add5c")
);
assert_eq!(
announcement.maintainers,
vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")]
);
assert_eq!(announcement.hashtags, vec!["rust", "nostr"]);
}
#[test]
fn requires_d_tag() {
let event = announcement_event(&[&["name", "No id"]]);
assert!(Announcement::from_event(&event).is_none());
}
#[test]
fn ignores_other_kinds() {
let event = EventBuilder::new(Kind::GitIssue, "")
.finalize(&keys())
.expect("signed event");
assert!(Announcement::from_event(&event).is_none());
}
#[test]
fn drops_malformed_values() {
let event = announcement_event(&[
&["d", "my-repo"],
&["clone", "not a url"],
&["relays", "wss://good.example.com"],
&["maintainers", "not-a-pubkey"],
]);
let announcement = Announcement::from_event(&event).expect("parses");
// An invalid URL keeps the whole clone tag from being parsed.
assert!(announcement.clone.is_empty());
assert_eq!(
announcement.relays,
vec![RelayUrl::parse("wss://good.example.com").unwrap()]
);
assert!(announcement.maintainers.is_empty());
}
#[test]
fn ignores_unknown_tags() {
let event = announcement_event(&[&["d", "my-repo"], &["t", "label"], &["subject", "n/a"]]);
let announcement = Announcement::from_event(&event).expect("parses");
assert_eq!(announcement.id, "my-repo");
assert!(announcement.name.is_none());
assert!(announcement.web.is_empty());
}
}