Skip to main content

nula_core/nips/
nip34.rs

1//! [NIP-34] `git` stuff — typed bundles for the eleven event
2//! kinds NIP-34 reserves for code-collaboration over Nostr.
3//!
4//! # Kind map
5//!
6//! | Kind   | Role                                          | Bundle                       |
7//! |--------|-----------------------------------------------|------------------------------|
8//! | 30617  | Repository announcement (addressable)         | [`Repository`]               |
9//! | 30618  | Repository state announcement (addressable)   | [`RepositoryState`]          |
10//! | 1617   | Patch                                         | [`Patch`]                    |
11//! | 1618   | Pull request                                  | [`PullRequest`]              |
12//! | 1619   | Pull request update                           | [`PullRequestUpdate`]        |
13//! | 1621   | Issue                                         | [`Issue`]                    |
14//! | 1630   | Status: open                                  | [`StatusEvent`] (typed kind) |
15//! | 1631   | Status: applied / merged / resolved           | [`StatusEvent`] (typed kind) |
16//! | 1632   | Status: closed                                | [`StatusEvent`] (typed kind) |
17//! | 1633   | Status: draft                                 | [`StatusEvent`] (typed kind) |
18//! | 10317  | User grasp-server list (replaceable)          | [`GraspServerList`]          |
19//!
20//! [`StatusEvent::status`] is a typed [`GitStatus`] discriminator
21//! that picks the right kind constant at build time and rejects
22//! anything outside `1630..=1633` at parse time.
23//!
24//! # Why a typed module
25//!
26//! Upstream `rust-nostr` does not yet ship NIP-34. We model the
27//! complete tag vocabulary the spec defines:
28//!
29//! - **`a` tag** — addressable pointer to the repository
30//!   announcement (`30617:<author>:<d>`). Parsed back into a
31//!   [`Coordinate`] so callers can re-resolve the repo.
32//! - **`clone` / `web` / `relays` / `maintainers`** — multi-valued
33//!   metadata rows on [`Repository`].
34//! - **`r ... euc`** — earliest-unique-commit marker, kept as
35//!   [`Repository::earliest_unique_commit`].
36//! - **`refs/heads/<branch>` / `refs/tags/<tag>` / `HEAD`** — the
37//!   typed [`GitRef`] rows on [`RepositoryState`].
38//! - **`commit` / `parent-commit` / `commit-pgp-sig` / `committer`**
39//!   — the optional reproducibility columns on [`Patch`].
40//! - **`E` / `P` uppercase** — NIP-22 root references on
41//!   [`PullRequestUpdate`].
42//! - **`merge-commit` / `applied-as-commits` / `q`** — applied/merged
43//!   status columns on [`StatusEvent`].
44//!
45//! Every bundle ships a `to_tags` renderer, a `from_event` parser,
46//! and an [`EventBuilder`] constructor.
47//!
48//! [NIP-34]: https://github.com/nostr-protocol/nips/blob/master/34.md
49
50#![allow(
51    clippy::excessive_nesting,
52    reason = "the per-tag match-on-name dispatch pattern in `from_event` keeps the wire-format-to-field mapping at the surface; flattening obscures it"
53)]
54#![allow(
55    clippy::too_many_lines,
56    reason = "NIP-34 fields fan out across 8 typed bundles; splitting renders / parsers further would create indirection without clarity"
57)]
58
59use thiserror::Error;
60
61use crate::event::{
62    Alphabet, Coordinate, CoordinateError, Event, EventBuilder, EventId, EventIdError, Kind,
63    SingleLetterTag, Tag, TagError, TagKind,
64};
65use crate::key::{PublicKey, PublicKeyError};
66use crate::types::{RelayUrl, RelayUrlError};
67
68/// `kind: 30617` — repository announcement.
69pub const KIND_REPO: Kind = Kind::GIT_REPOSITORY;
70/// `kind: 30618` — repository state announcement.
71pub const KIND_REPO_STATE: Kind = Kind::GIT_REPOSITORY_STATE;
72/// `kind: 1617` — patch.
73pub const KIND_PATCH: Kind = Kind::GIT_PATCH;
74/// `kind: 1618` — pull request.
75pub const KIND_PULL_REQUEST: Kind = Kind::GIT_PULL_REQUEST;
76/// `kind: 1619` — pull request update.
77pub const KIND_PULL_REQUEST_UPDATE: Kind = Kind::GIT_PULL_REQUEST_UPDATE;
78/// `kind: 1621` — issue.
79pub const KIND_ISSUE: Kind = Kind::GIT_ISSUE;
80/// `kind: 1630` — status `open`.
81pub const KIND_STATUS_OPEN: Kind = Kind::GIT_STATUS_OPEN;
82/// `kind: 1631` — status `applied / merged / resolved`.
83pub const KIND_STATUS_APPLIED: Kind = Kind::GIT_STATUS_APPLIED;
84/// `kind: 1632` — status `closed`.
85pub const KIND_STATUS_CLOSED: Kind = Kind::GIT_STATUS_CLOSED;
86/// `kind: 1633` — status `draft`.
87pub const KIND_STATUS_DRAFT: Kind = Kind::GIT_STATUS_DRAFT;
88/// `kind: 10317` — user grasp-server list.
89pub const KIND_GRASP_LIST: Kind = Kind::GIT_GRASP_LIST;
90
91/// `personal-fork` reserved hashtag.
92pub const PERSONAL_FORK_HASHTAG: &str = "personal-fork";
93/// `euc` marker on the earliest-unique-commit `r` tag.
94pub const EUC_MARKER: &str = "euc";
95
96mod tag_names {
97    pub(super) const D: &str = "d";
98    pub(super) const NAME: &str = "name";
99    pub(super) const DESCRIPTION: &str = "description";
100    pub(super) const WEB: &str = "web";
101    pub(super) const CLONE: &str = "clone";
102    pub(super) const RELAYS: &str = "relays";
103    pub(super) const MAINTAINERS: &str = "maintainers";
104    pub(super) const COMMIT: &str = "commit";
105    pub(super) const PARENT_COMMIT: &str = "parent-commit";
106    pub(super) const COMMIT_PGP_SIG: &str = "commit-pgp-sig";
107    pub(super) const COMMITTER: &str = "committer";
108    pub(super) const SUBJECT: &str = "subject";
109    pub(super) const BRANCH_NAME: &str = "branch-name";
110    pub(super) const MERGE_BASE: &str = "merge-base";
111    pub(super) const MERGE_COMMIT: &str = "merge-commit";
112    pub(super) const APPLIED_AS_COMMITS: &str = "applied-as-commits";
113    pub(super) const HEAD: &str = "HEAD";
114    pub(super) const REFS_PREFIX: &str = "refs/";
115}
116
117/// Errors raised by the NIP-34 typed bundles.
118#[derive(Debug, Error)]
119#[non_exhaustive]
120pub enum Nip34Error {
121    /// Event kind did not match the expected NIP-34 kind.
122    #[error("expected kind {expected}, got {got}")]
123    WrongKind {
124        /// Kind the caller asked for.
125        expected: Kind,
126        /// Kind the event actually carried.
127        got: Kind,
128    },
129    /// Status kind was outside `1630..=1633`.
130    #[error("expected a status kind (1630..=1633), got {0}")]
131    InvalidStatusKind(Kind),
132    /// Repository announcement / state was missing its `d` tag.
133    #[error("NIP-34 {kind} event missing required `d` tag")]
134    MissingIdentifier {
135        /// Kind of the event missing its identifier.
136        kind: Kind,
137    },
138    /// Patch / PR / issue / status event was missing the required
139    /// repository `a` tag.
140    #[error("NIP-34 {kind} event missing required `a` repository tag")]
141    MissingRepository {
142        /// Kind of the event missing its repository pointer.
143        kind: Kind,
144    },
145    /// A coordinate was malformed.
146    #[error(transparent)]
147    Coordinate(#[from] CoordinateError),
148    /// An event id was malformed.
149    #[error(transparent)]
150    EventId(#[from] EventIdError),
151    /// A relay URL was malformed.
152    #[error(transparent)]
153    RelayUrl(#[from] RelayUrlError),
154    /// A pubkey was malformed.
155    #[error(transparent)]
156    PublicKey(#[from] PublicKeyError),
157    /// A typed [`Tag`] could not be constructed.
158    #[error(transparent)]
159    Tag(#[from] TagError),
160}
161
162fn second(values: &[String]) -> Option<&str> {
163    values.get(1).map(String::as_str)
164}
165
166fn args(values: &[String]) -> &[String] {
167    values.get(1..).unwrap_or(&[])
168}
169
170fn p_tag(pubkey: PublicKey) -> Tag {
171    Tag::with(
172        &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P)),
173        [pubkey.to_hex()],
174    )
175}
176
177fn e_tag(event_id: EventId, marker: Option<&str>) -> Tag {
178    let mut row = vec![event_id.to_hex()];
179    row.push(String::new());
180    if let Some(marker) = marker {
181        row.push(marker.to_owned());
182    }
183    Tag::with(
184        &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E)),
185        row,
186    )
187}
188
189fn r_tag(value: impl Into<String>) -> Tag {
190    Tag::with(
191        &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::R)),
192        [value.into()],
193    )
194}
195
196fn a_tag(coordinate: &Coordinate) -> Tag {
197    Tag::with(
198        &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::A)),
199        [coordinate.to_wire()],
200    )
201}
202
203/// Typed bundle for the `kind: 30617` repository announcement.
204#[derive(Debug, Clone, PartialEq, Eq)]
205pub struct Repository {
206    /// `d` tag — addressable identifier, usually kebab-case.
207    pub identifier: String,
208    /// Human-readable project name.
209    pub name: Option<String>,
210    /// Short project description.
211    pub description: Option<String>,
212    /// Web URLs (homepage, browser-side git UI, …).
213    pub web: Vec<String>,
214    /// `git clone` URLs.
215    pub clone: Vec<String>,
216    /// Relays the author monitors for patches / issues.
217    pub relays: Vec<RelayUrl>,
218    /// Earliest unique commit hex (`r` tag with `euc` marker).
219    pub earliest_unique_commit: Option<String>,
220    /// Maintainer pubkeys.
221    pub maintainers: Vec<PublicKey>,
222    /// Hashtags labelling the repository.
223    pub hashtags: Vec<String>,
224    /// True when the spec-reserved `personal-fork` hashtag is set.
225    pub personal_fork: bool,
226}
227
228impl Repository {
229    /// Construct an announcement bound to the addressable `d`
230    /// identifier.
231    #[must_use]
232    pub fn new(identifier: impl Into<String>) -> Self {
233        Self {
234            identifier: identifier.into(),
235            name: None,
236            description: None,
237            web: Vec::new(),
238            clone: Vec::new(),
239            relays: Vec::new(),
240            earliest_unique_commit: None,
241            maintainers: Vec::new(),
242            hashtags: Vec::new(),
243            personal_fork: false,
244        }
245    }
246
247    /// Build the addressable coordinate for this announcement under
248    /// `author`.
249    #[must_use]
250    pub fn coordinate(&self, author: PublicKey) -> Coordinate {
251        Coordinate::new(KIND_REPO, author, self.identifier.clone())
252    }
253
254    /// Render the typed bundle to the public tag list.
255    #[must_use]
256    pub fn to_tags(&self) -> Vec<Tag> {
257        let mut tags: Vec<Tag> = Vec::new();
258        tags.push(Tag::with(
259            &TagKind::custom(tag_names::D),
260            [self.identifier.clone()],
261        ));
262        if let Some(name) = &self.name {
263            tags.push(Tag::with(&TagKind::custom(tag_names::NAME), [name.clone()]));
264        }
265        if let Some(description) = &self.description {
266            tags.push(Tag::with(
267                &TagKind::custom(tag_names::DESCRIPTION),
268                [description.clone()],
269            ));
270        }
271        if !self.web.is_empty() {
272            tags.push(Tag::with(
273                &TagKind::custom(tag_names::WEB),
274                self.web.clone(),
275            ));
276        }
277        if !self.clone.is_empty() {
278            tags.push(Tag::with(
279                &TagKind::custom(tag_names::CLONE),
280                self.clone.clone(),
281            ));
282        }
283        if !self.relays.is_empty() {
284            let row: Vec<String> = self.relays.iter().map(|r| r.as_str().to_owned()).collect();
285            tags.push(Tag::with(&TagKind::custom(tag_names::RELAYS), row));
286        }
287        if let Some(euc) = &self.earliest_unique_commit {
288            tags.push(Tag::with(
289                &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::R)),
290                [euc.clone(), EUC_MARKER.to_owned()],
291            ));
292        }
293        if !self.maintainers.is_empty() {
294            let row: Vec<String> = self.maintainers.iter().map(|pk| pk.to_hex()).collect();
295            tags.push(Tag::with(&TagKind::custom(tag_names::MAINTAINERS), row));
296        }
297        if self.personal_fork {
298            tags.push(Tag::with(
299                &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::T)),
300                [PERSONAL_FORK_HASHTAG.to_owned()],
301            ));
302        }
303        for hashtag in &self.hashtags {
304            tags.push(Tag::with(
305                &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::T)),
306                [hashtag.clone()],
307            ));
308        }
309        tags
310    }
311
312    /// Parse a signed kind-30617 event into a typed bundle.
313    ///
314    /// # Errors
315    ///
316    /// Returns [`Nip34Error::WrongKind`] for the wrong kind and
317    /// [`Nip34Error::MissingIdentifier`] when the `d` tag is absent.
318    pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
319        if event.kind != KIND_REPO {
320            return Err(Nip34Error::WrongKind {
321                expected: KIND_REPO,
322                got: event.kind,
323            });
324        }
325        let mut identifier: Option<String> = None;
326        let mut repo = Self::new(String::new());
327        for tag in &event.tags {
328            let values = tag.values();
329            let args = args(values);
330            match tag.name() {
331                tag_names::D => identifier = second(values).map(String::from),
332                tag_names::NAME => repo.name = second(values).map(String::from),
333                tag_names::DESCRIPTION => repo.description = second(values).map(String::from),
334                tag_names::WEB => {
335                    for v in args {
336                        repo.web.push(v.clone());
337                    }
338                }
339                tag_names::CLONE => {
340                    for v in args {
341                        repo.clone.push(v.clone());
342                    }
343                }
344                tag_names::RELAYS => {
345                    for v in args {
346                        repo.relays.push(RelayUrl::parse(v)?);
347                    }
348                }
349                tag_names::MAINTAINERS => {
350                    for v in args {
351                        repo.maintainers.push(PublicKey::parse(v)?);
352                    }
353                }
354                "r" => {
355                    let marker = args.get(1).map(String::as_str);
356                    if marker == Some(EUC_MARKER) {
357                        repo.earliest_unique_commit = args.first().cloned();
358                    }
359                }
360                "t" => {
361                    if let Some(value) = args.first() {
362                        if value == PERSONAL_FORK_HASHTAG {
363                            repo.personal_fork = true;
364                        } else {
365                            repo.hashtags.push(value.clone());
366                        }
367                    }
368                }
369                _ => {}
370            }
371        }
372        repo.identifier = identifier.ok_or(Nip34Error::MissingIdentifier { kind: KIND_REPO })?;
373        Ok(repo)
374    }
375}
376
377/// One `refs/<heads|tags>/<name>` row on a [`RepositoryState`].
378#[derive(Debug, Clone, PartialEq, Eq)]
379pub struct GitRef {
380    /// Full ref path (`refs/heads/main` / `refs/tags/v1.0.0`).
381    pub ref_path: String,
382    /// Object id (lowercase hex of the SHA-1 or SHA-256 oid).
383    pub oid: String,
384}
385
386impl GitRef {
387    /// Construct a ref row.
388    #[must_use]
389    pub fn new(ref_path: impl Into<String>, oid: impl Into<String>) -> Self {
390        Self {
391            ref_path: ref_path.into(),
392            oid: oid.into(),
393        }
394    }
395}
396
397/// Typed bundle for the `kind: 30618` repository state.
398#[derive(Debug, Clone, PartialEq, Eq)]
399pub struct RepositoryState {
400    /// `d` tag — matches the [`Repository::identifier`].
401    pub identifier: String,
402    /// `refs/...` rows.
403    pub refs: Vec<GitRef>,
404    /// `HEAD` symbolic ref (e.g. `ref: refs/heads/main`).
405    pub head: Option<String>,
406}
407
408impl RepositoryState {
409    /// Construct a state bundle bound to the addressable `d`
410    /// identifier.
411    #[must_use]
412    pub fn new(identifier: impl Into<String>) -> Self {
413        Self {
414            identifier: identifier.into(),
415            refs: Vec::new(),
416            head: None,
417        }
418    }
419
420    /// Render the typed bundle to the public tag list.
421    #[must_use]
422    pub fn to_tags(&self) -> Vec<Tag> {
423        let mut tags: Vec<Tag> = Vec::new();
424        tags.push(Tag::with(
425            &TagKind::custom(tag_names::D),
426            [self.identifier.clone()],
427        ));
428        for git_ref in &self.refs {
429            tags.push(Tag::with(
430                &TagKind::custom(&git_ref.ref_path),
431                [git_ref.oid.clone()],
432            ));
433        }
434        if let Some(head) = &self.head {
435            tags.push(Tag::with(&TagKind::custom(tag_names::HEAD), [head.clone()]));
436        }
437        tags
438    }
439
440    /// Parse a signed kind-30618 event into a typed bundle.
441    ///
442    /// # Errors
443    ///
444    /// Returns [`Nip34Error::WrongKind`] for the wrong kind and
445    /// [`Nip34Error::MissingIdentifier`] when the `d` tag is absent.
446    pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
447        if event.kind != KIND_REPO_STATE {
448            return Err(Nip34Error::WrongKind {
449                expected: KIND_REPO_STATE,
450                got: event.kind,
451            });
452        }
453        let mut identifier: Option<String> = None;
454        let mut state = Self::new(String::new());
455        for tag in &event.tags {
456            let values = tag.values();
457            let name = tag.name();
458            if name == tag_names::D {
459                identifier = second(values).map(String::from);
460                continue;
461            }
462            if name == tag_names::HEAD {
463                state.head = second(values).map(String::from);
464                continue;
465            }
466            if name.starts_with(tag_names::REFS_PREFIX)
467                && let Some(oid) = second(values)
468            {
469                state.refs.push(GitRef::new(name, oid));
470            }
471        }
472        state.identifier = identifier.ok_or(Nip34Error::MissingIdentifier {
473            kind: KIND_REPO_STATE,
474        })?;
475        Ok(state)
476    }
477}
478
479/// Typed bundle for the `kind: 1617` patch event.
480#[derive(Debug, Clone, PartialEq, Eq)]
481pub struct Patch {
482    /// `.content` — full `git format-patch` output.
483    pub content: String,
484    /// Repository pointer.
485    pub repo: Coordinate,
486    /// Earliest-unique-commit hex of the target repo (mirrors
487    /// [`Repository::earliest_unique_commit`]).
488    pub repo_euc: Option<String>,
489    /// People to notify (maintainers + ad-hoc reviewers).
490    pub mentions: Vec<PublicKey>,
491    /// `t:root` on the first patch in a series.
492    pub root: bool,
493    /// `t:root-revision` on the first patch in a revision.
494    pub root_revision: bool,
495    /// Optional commit hex (for stable-id replay).
496    pub commit: Option<String>,
497    /// Optional parent-commit hex.
498    pub parent_commit: Option<String>,
499    /// Optional PGP signature blob.
500    pub commit_pgp_sig: Option<String>,
501    /// Optional `["committer", name, email, timestamp, offset]` row.
502    pub committer: Option<Committer>,
503}
504
505/// Typed `committer` row on a [`Patch`].
506#[derive(Debug, Clone, PartialEq, Eq)]
507pub struct Committer {
508    /// Committer name.
509    pub name: String,
510    /// Committer email.
511    pub email: String,
512    /// Unix-seconds timestamp.
513    pub timestamp: String,
514    /// Time-zone offset string (e.g. `"+0000"`, `"-0700"`).
515    pub offset: String,
516}
517
518impl Patch {
519    /// Construct a patch bundle.
520    #[must_use]
521    pub fn new(repo: Coordinate, content: impl Into<String>) -> Self {
522        Self {
523            content: content.into(),
524            repo,
525            repo_euc: None,
526            mentions: Vec::new(),
527            root: false,
528            root_revision: false,
529            commit: None,
530            parent_commit: None,
531            commit_pgp_sig: None,
532            committer: None,
533        }
534    }
535
536    /// Render the typed bundle to the public tag list.
537    #[must_use]
538    pub fn to_tags(&self) -> Vec<Tag> {
539        let mut tags: Vec<Tag> = Vec::new();
540        tags.push(a_tag(&self.repo));
541        if let Some(euc) = &self.repo_euc {
542            tags.push(r_tag(euc.clone()));
543        }
544        for mention in &self.mentions {
545            tags.push(p_tag(*mention));
546        }
547        if self.root {
548            tags.push(Tag::with(
549                &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::T)),
550                ["root".to_owned()],
551            ));
552        }
553        if self.root_revision {
554            tags.push(Tag::with(
555                &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::T)),
556                ["root-revision".to_owned()],
557            ));
558        }
559        if let Some(commit) = &self.commit {
560            tags.push(Tag::with(
561                &TagKind::custom(tag_names::COMMIT),
562                [commit.clone()],
563            ));
564            tags.push(r_tag(commit.clone()));
565        }
566        if let Some(parent_commit) = &self.parent_commit {
567            tags.push(Tag::with(
568                &TagKind::custom(tag_names::PARENT_COMMIT),
569                [parent_commit.clone()],
570            ));
571        }
572        if let Some(sig) = &self.commit_pgp_sig {
573            tags.push(Tag::with(
574                &TagKind::custom(tag_names::COMMIT_PGP_SIG),
575                [sig.clone()],
576            ));
577        }
578        if let Some(committer) = &self.committer {
579            tags.push(Tag::with(
580                &TagKind::custom(tag_names::COMMITTER),
581                [
582                    committer.name.clone(),
583                    committer.email.clone(),
584                    committer.timestamp.clone(),
585                    committer.offset.clone(),
586                ],
587            ));
588        }
589        tags
590    }
591
592    /// Parse a signed kind-1617 event into a typed bundle.
593    ///
594    /// # Errors
595    ///
596    /// Returns [`Nip34Error::WrongKind`] for the wrong kind;
597    /// returns [`Nip34Error::MissingRepository`] when the `a` tag
598    /// is absent; forwards every coordinate / pubkey parse error.
599    pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
600        if event.kind != KIND_PATCH {
601            return Err(Nip34Error::WrongKind {
602                expected: KIND_PATCH,
603                got: event.kind,
604            });
605        }
606        let mut repo: Option<Coordinate> = None;
607        let mut mentions: Vec<PublicKey> = Vec::new();
608        let mut root = false;
609        let mut root_revision = false;
610        let mut commit: Option<String> = None;
611        let mut parent_commit: Option<String> = None;
612        let mut commit_pgp_sig: Option<String> = None;
613        let mut committer: Option<Committer> = None;
614        let mut repo_euc: Option<String> = None;
615        for tag in &event.tags {
616            let values = tag.values();
617            let args = args(values);
618            match tag.name() {
619                "a" => {
620                    if let Some(value) = args.first() {
621                        repo = Some(Coordinate::parse(value)?);
622                    }
623                }
624                "r" => {
625                    if let Some(value) = args.first() {
626                        repo_euc.get_or_insert_with(|| value.clone());
627                    }
628                }
629                "p" => {
630                    if let Some(value) = args.first() {
631                        mentions.push(PublicKey::parse(value)?);
632                    }
633                }
634                "t" => match args.first().map(String::as_str) {
635                    Some("root") => root = true,
636                    Some("root-revision") => root_revision = true,
637                    _ => {}
638                },
639                tag_names::COMMIT => commit = args.first().cloned(),
640                tag_names::PARENT_COMMIT => parent_commit = args.first().cloned(),
641                tag_names::COMMIT_PGP_SIG => commit_pgp_sig = args.first().cloned(),
642                tag_names::COMMITTER => {
643                    if let [name, email, timestamp, offset, ..] = args {
644                        committer = Some(Committer {
645                            name: name.clone(),
646                            email: email.clone(),
647                            timestamp: timestamp.clone(),
648                            offset: offset.clone(),
649                        });
650                    }
651                }
652                _ => {}
653            }
654        }
655        let repo = repo.ok_or(Nip34Error::MissingRepository { kind: KIND_PATCH })?;
656        Ok(Self {
657            content: event.content.clone(),
658            repo,
659            repo_euc,
660            mentions,
661            root,
662            root_revision,
663            commit,
664            parent_commit,
665            commit_pgp_sig,
666            committer,
667        })
668    }
669}
670
671/// Typed bundle for the `kind: 1618` pull request event.
672#[derive(Debug, Clone, PartialEq, Eq)]
673pub struct PullRequest {
674    /// `.content` — free-form description.
675    pub content: String,
676    /// Repository pointer.
677    pub repo: Coordinate,
678    /// Earliest-unique-commit hex of the target repo.
679    pub repo_euc: Option<String>,
680    /// People to notify.
681    pub mentions: Vec<PublicKey>,
682    /// `subject` tag.
683    pub subject: Option<String>,
684    /// Hashtags.
685    pub hashtags: Vec<String>,
686    /// `c` tag — tip of the PR branch.
687    pub tip_commit: Option<String>,
688    /// `clone` URLs where the commit can be downloaded.
689    pub clone: Vec<String>,
690    /// Optional recommended branch name.
691    pub branch_name: Option<String>,
692    /// Optional `e` tag — existing PR/patch being revised.
693    pub revises_event: Option<EventId>,
694    /// `merge-base` tag — most recent common ancestor with the
695    /// target branch.
696    pub merge_base: Option<String>,
697}
698
699impl PullRequest {
700    /// Construct a pull request bundle.
701    #[must_use]
702    pub fn new(repo: Coordinate, content: impl Into<String>) -> Self {
703        Self {
704            content: content.into(),
705            repo,
706            repo_euc: None,
707            mentions: Vec::new(),
708            subject: None,
709            hashtags: Vec::new(),
710            tip_commit: None,
711            clone: Vec::new(),
712            branch_name: None,
713            revises_event: None,
714            merge_base: None,
715        }
716    }
717
718    /// Render the typed bundle to the public tag list.
719    #[must_use]
720    pub fn to_tags(&self) -> Vec<Tag> {
721        let mut tags: Vec<Tag> = Vec::new();
722        tags.push(a_tag(&self.repo));
723        if let Some(euc) = &self.repo_euc {
724            tags.push(r_tag(euc.clone()));
725        }
726        for mention in &self.mentions {
727            tags.push(p_tag(*mention));
728        }
729        if let Some(subject) = &self.subject {
730            tags.push(Tag::with(
731                &TagKind::custom(tag_names::SUBJECT),
732                [subject.clone()],
733            ));
734        }
735        for hashtag in &self.hashtags {
736            tags.push(Tag::with(
737                &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::T)),
738                [hashtag.clone()],
739            ));
740        }
741        if let Some(c) = &self.tip_commit {
742            tags.push(Tag::with(
743                &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::C)),
744                [c.clone()],
745            ));
746        }
747        if !self.clone.is_empty() {
748            tags.push(Tag::with(
749                &TagKind::custom(tag_names::CLONE),
750                self.clone.clone(),
751            ));
752        }
753        if let Some(branch) = &self.branch_name {
754            tags.push(Tag::with(
755                &TagKind::custom(tag_names::BRANCH_NAME),
756                [branch.clone()],
757            ));
758        }
759        if let Some(event_id) = self.revises_event {
760            tags.push(e_tag(event_id, None));
761        }
762        if let Some(merge_base) = &self.merge_base {
763            tags.push(Tag::with(
764                &TagKind::custom(tag_names::MERGE_BASE),
765                [merge_base.clone()],
766            ));
767        }
768        tags
769    }
770
771    /// Parse a signed kind-1618 event into a typed bundle.
772    ///
773    /// # Errors
774    ///
775    /// Returns [`Nip34Error::WrongKind`] for the wrong kind and
776    /// [`Nip34Error::MissingRepository`] when the `a` tag is
777    /// absent; forwards every parse error.
778    pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
779        if event.kind != KIND_PULL_REQUEST {
780            return Err(Nip34Error::WrongKind {
781                expected: KIND_PULL_REQUEST,
782                got: event.kind,
783            });
784        }
785        let mut repo: Option<Coordinate> = None;
786        let mut repo_euc: Option<String> = None;
787        let mut mentions: Vec<PublicKey> = Vec::new();
788        let mut subject: Option<String> = None;
789        let mut hashtags: Vec<String> = Vec::new();
790        let mut tip_commit: Option<String> = None;
791        let mut clone: Vec<String> = Vec::new();
792        let mut branch_name: Option<String> = None;
793        let mut revises_event: Option<EventId> = None;
794        let mut merge_base: Option<String> = None;
795        for tag in &event.tags {
796            let values = tag.values();
797            let args = args(values);
798            match tag.name() {
799                "a" => {
800                    if let Some(value) = args.first() {
801                        repo = Some(Coordinate::parse(value)?);
802                    }
803                }
804                "r" => {
805                    if let Some(value) = args.first() {
806                        repo_euc.get_or_insert_with(|| value.clone());
807                    }
808                }
809                "p" => {
810                    if let Some(value) = args.first() {
811                        mentions.push(PublicKey::parse(value)?);
812                    }
813                }
814                tag_names::SUBJECT => subject = args.first().cloned(),
815                "t" => {
816                    if let Some(value) = args.first() {
817                        hashtags.push(value.clone());
818                    }
819                }
820                "c" => tip_commit = args.first().cloned(),
821                tag_names::CLONE => {
822                    for v in args {
823                        clone.push(v.clone());
824                    }
825                }
826                tag_names::BRANCH_NAME => branch_name = args.first().cloned(),
827                "e" => {
828                    if let Some(value) = args.first() {
829                        revises_event = Some(EventId::parse(value)?);
830                    }
831                }
832                tag_names::MERGE_BASE => merge_base = args.first().cloned(),
833                _ => {}
834            }
835        }
836        let repo = repo.ok_or(Nip34Error::MissingRepository {
837            kind: KIND_PULL_REQUEST,
838        })?;
839        Ok(Self {
840            content: event.content.clone(),
841            repo,
842            repo_euc,
843            mentions,
844            subject,
845            hashtags,
846            tip_commit,
847            clone,
848            branch_name,
849            revises_event,
850            merge_base,
851        })
852    }
853}
854
855/// Typed bundle for the `kind: 1619` pull request update.
856#[derive(Debug, Clone, PartialEq, Eq)]
857pub struct PullRequestUpdate {
858    /// `.content` — free-form description.
859    pub content: String,
860    /// Repository pointer.
861    pub repo: Coordinate,
862    /// Earliest-unique-commit hex of the target repo.
863    pub repo_euc: Option<String>,
864    /// People to notify.
865    pub mentions: Vec<PublicKey>,
866    /// `E` (uppercase) — root PR event id (NIP-22).
867    pub root_event: EventId,
868    /// `P` (uppercase) — root PR author pubkey (NIP-22).
869    pub root_pubkey: PublicKey,
870    /// `c` tag — updated tip commit.
871    pub tip_commit: Option<String>,
872    /// `clone` URLs.
873    pub clone: Vec<String>,
874    /// `merge-base` tag.
875    pub merge_base: Option<String>,
876}
877
878impl PullRequestUpdate {
879    /// Construct a PR-update bundle.
880    #[must_use]
881    pub fn new(
882        repo: Coordinate,
883        root_event: EventId,
884        root_pubkey: PublicKey,
885        content: impl Into<String>,
886    ) -> Self {
887        Self {
888            content: content.into(),
889            repo,
890            repo_euc: None,
891            mentions: Vec::new(),
892            root_event,
893            root_pubkey,
894            tip_commit: None,
895            clone: Vec::new(),
896            merge_base: None,
897        }
898    }
899
900    /// Render the typed bundle to the public tag list.
901    #[must_use]
902    pub fn to_tags(&self) -> Vec<Tag> {
903        let mut tags: Vec<Tag> = Vec::new();
904        tags.push(a_tag(&self.repo));
905        if let Some(euc) = &self.repo_euc {
906            tags.push(r_tag(euc.clone()));
907        }
908        for mention in &self.mentions {
909            tags.push(p_tag(*mention));
910        }
911        tags.push(Tag::with(
912            &TagKind::single_letter(SingleLetterTag::uppercase(Alphabet::E)),
913            [self.root_event.to_hex()],
914        ));
915        tags.push(Tag::with(
916            &TagKind::single_letter(SingleLetterTag::uppercase(Alphabet::P)),
917            [self.root_pubkey.to_hex()],
918        ));
919        if let Some(c) = &self.tip_commit {
920            tags.push(Tag::with(
921                &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::C)),
922                [c.clone()],
923            ));
924        }
925        if !self.clone.is_empty() {
926            tags.push(Tag::with(
927                &TagKind::custom(tag_names::CLONE),
928                self.clone.clone(),
929            ));
930        }
931        if let Some(merge_base) = &self.merge_base {
932            tags.push(Tag::with(
933                &TagKind::custom(tag_names::MERGE_BASE),
934                [merge_base.clone()],
935            ));
936        }
937        tags
938    }
939
940    /// Parse a signed kind-1619 event into a typed bundle.
941    ///
942    /// # Errors
943    ///
944    /// Returns [`Nip34Error::WrongKind`] for the wrong kind,
945    /// [`Nip34Error::MissingRepository`] when the `a` tag is
946    /// absent, and forwards every parse error. The uppercase `E`
947    /// and `P` tags MUST both be present per NIP-22.
948    pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
949        if event.kind != KIND_PULL_REQUEST_UPDATE {
950            return Err(Nip34Error::WrongKind {
951                expected: KIND_PULL_REQUEST_UPDATE,
952                got: event.kind,
953            });
954        }
955        let mut repo: Option<Coordinate> = None;
956        let mut repo_euc: Option<String> = None;
957        let mut mentions: Vec<PublicKey> = Vec::new();
958        let mut root_event: Option<EventId> = None;
959        let mut root_pubkey: Option<PublicKey> = None;
960        let mut tip_commit: Option<String> = None;
961        let mut clone: Vec<String> = Vec::new();
962        let mut merge_base: Option<String> = None;
963        for tag in &event.tags {
964            let values = tag.values();
965            let args = args(values);
966            match tag.name() {
967                "a" => {
968                    if let Some(value) = args.first() {
969                        repo = Some(Coordinate::parse(value)?);
970                    }
971                }
972                "r" => {
973                    if let Some(value) = args.first() {
974                        repo_euc.get_or_insert_with(|| value.clone());
975                    }
976                }
977                "p" => {
978                    if let Some(value) = args.first() {
979                        mentions.push(PublicKey::parse(value)?);
980                    }
981                }
982                "E" => {
983                    if let Some(value) = args.first() {
984                        root_event = Some(EventId::parse(value)?);
985                    }
986                }
987                "P" => {
988                    if let Some(value) = args.first() {
989                        root_pubkey = Some(PublicKey::parse(value)?);
990                    }
991                }
992                "c" => tip_commit = args.first().cloned(),
993                tag_names::CLONE => {
994                    for v in args {
995                        clone.push(v.clone());
996                    }
997                }
998                tag_names::MERGE_BASE => merge_base = args.first().cloned(),
999                _ => {}
1000            }
1001        }
1002        let repo = repo.ok_or(Nip34Error::MissingRepository {
1003            kind: KIND_PULL_REQUEST_UPDATE,
1004        })?;
1005        let root_event = root_event.ok_or(Nip34Error::MissingRepository {
1006            kind: KIND_PULL_REQUEST_UPDATE,
1007        })?;
1008        let root_pubkey = root_pubkey.ok_or(Nip34Error::MissingRepository {
1009            kind: KIND_PULL_REQUEST_UPDATE,
1010        })?;
1011        Ok(Self {
1012            content: event.content.clone(),
1013            repo,
1014            repo_euc,
1015            mentions,
1016            root_event,
1017            root_pubkey,
1018            tip_commit,
1019            clone,
1020            merge_base,
1021        })
1022    }
1023}
1024
1025/// Typed bundle for the `kind: 1621` issue event.
1026#[derive(Debug, Clone, PartialEq, Eq)]
1027pub struct Issue {
1028    /// `.content` — Markdown issue body.
1029    pub content: String,
1030    /// Repository pointer.
1031    pub repo: Coordinate,
1032    /// People to notify.
1033    pub mentions: Vec<PublicKey>,
1034    /// Optional `subject` tag.
1035    pub subject: Option<String>,
1036    /// Issue labels.
1037    pub hashtags: Vec<String>,
1038}
1039
1040impl Issue {
1041    /// Construct an issue bundle.
1042    #[must_use]
1043    pub fn new(repo: Coordinate, content: impl Into<String>) -> Self {
1044        Self {
1045            content: content.into(),
1046            repo,
1047            mentions: Vec::new(),
1048            subject: None,
1049            hashtags: Vec::new(),
1050        }
1051    }
1052
1053    /// Render the typed bundle to the public tag list.
1054    #[must_use]
1055    pub fn to_tags(&self) -> Vec<Tag> {
1056        let mut tags: Vec<Tag> = Vec::new();
1057        tags.push(a_tag(&self.repo));
1058        for mention in &self.mentions {
1059            tags.push(p_tag(*mention));
1060        }
1061        if let Some(subject) = &self.subject {
1062            tags.push(Tag::with(
1063                &TagKind::custom(tag_names::SUBJECT),
1064                [subject.clone()],
1065            ));
1066        }
1067        for hashtag in &self.hashtags {
1068            tags.push(Tag::with(
1069                &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::T)),
1070                [hashtag.clone()],
1071            ));
1072        }
1073        tags
1074    }
1075
1076    /// Parse a signed kind-1621 event into a typed bundle.
1077    ///
1078    /// # Errors
1079    ///
1080    /// Returns [`Nip34Error::WrongKind`] for the wrong kind and
1081    /// [`Nip34Error::MissingRepository`] when the `a` tag is
1082    /// absent.
1083    pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
1084        if event.kind != KIND_ISSUE {
1085            return Err(Nip34Error::WrongKind {
1086                expected: KIND_ISSUE,
1087                got: event.kind,
1088            });
1089        }
1090        let mut repo: Option<Coordinate> = None;
1091        let mut mentions: Vec<PublicKey> = Vec::new();
1092        let mut subject: Option<String> = None;
1093        let mut hashtags: Vec<String> = Vec::new();
1094        for tag in &event.tags {
1095            let values = tag.values();
1096            let args = args(values);
1097            match tag.name() {
1098                "a" => {
1099                    if let Some(value) = args.first() {
1100                        repo = Some(Coordinate::parse(value)?);
1101                    }
1102                }
1103                "p" => {
1104                    if let Some(value) = args.first() {
1105                        mentions.push(PublicKey::parse(value)?);
1106                    }
1107                }
1108                tag_names::SUBJECT => subject = args.first().cloned(),
1109                "t" => {
1110                    if let Some(value) = args.first() {
1111                        hashtags.push(value.clone());
1112                    }
1113                }
1114                _ => {}
1115            }
1116        }
1117        let repo = repo.ok_or(Nip34Error::MissingRepository { kind: KIND_ISSUE })?;
1118        Ok(Self {
1119            content: event.content.clone(),
1120            repo,
1121            mentions,
1122            subject,
1123            hashtags,
1124        })
1125    }
1126}
1127
1128/// Typed status discriminator covering kinds `1630..=1633`.
1129#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
1130#[non_exhaustive]
1131pub enum GitStatus {
1132    /// `1630` — open.
1133    Open,
1134    /// `1631` — applied / merged for patches & PRs, resolved for issues.
1135    Applied,
1136    /// `1632` — closed.
1137    Closed,
1138    /// `1633` — draft.
1139    Draft,
1140}
1141
1142impl GitStatus {
1143    /// Map the discriminator to its on-the-wire kind.
1144    #[must_use]
1145    pub const fn to_kind(self) -> Kind {
1146        match self {
1147            Self::Open => KIND_STATUS_OPEN,
1148            Self::Applied => KIND_STATUS_APPLIED,
1149            Self::Closed => KIND_STATUS_CLOSED,
1150            Self::Draft => KIND_STATUS_DRAFT,
1151        }
1152    }
1153
1154    /// Try to recover a discriminator from a status kind.
1155    ///
1156    /// # Errors
1157    ///
1158    /// Returns [`Nip34Error::InvalidStatusKind`] when `kind` is
1159    /// outside `1630..=1633`.
1160    pub const fn from_kind(kind: Kind) -> Result<Self, Nip34Error> {
1161        match kind.as_u16() {
1162            1_630 => Ok(Self::Open),
1163            1_631 => Ok(Self::Applied),
1164            1_632 => Ok(Self::Closed),
1165            1_633 => Ok(Self::Draft),
1166            _ => Err(Nip34Error::InvalidStatusKind(kind)),
1167        }
1168    }
1169}
1170
1171/// `e` tag reference on a [`StatusEvent`] (target event id + marker).
1172#[derive(Debug, Clone, PartialEq, Eq)]
1173pub struct StatusReference {
1174    /// Target event id.
1175    pub event_id: EventId,
1176    /// `root` / `reply` marker per NIP-10.
1177    pub marker: Option<String>,
1178}
1179
1180/// Typed bundle for the `kind: 1630..=1633` status events.
1181#[derive(Debug, Clone, PartialEq, Eq)]
1182pub struct StatusEvent {
1183    /// `.content` — free-form explanation.
1184    pub content: String,
1185    /// Typed status discriminator (controls the rendered kind).
1186    pub status: GitStatus,
1187    /// `e` tag references (root + reply revisions).
1188    pub references: Vec<StatusReference>,
1189    /// People to notify.
1190    pub mentions: Vec<PublicKey>,
1191    /// Optional repository pointer.
1192    pub repo: Option<Coordinate>,
1193    /// Optional earliest-unique-commit hex.
1194    pub repo_euc: Option<String>,
1195    /// Optional `q` tag — quoted patch references on `1631` events.
1196    pub quoted_patches: Vec<EventId>,
1197    /// Optional `merge-commit` hex (only on `1631`).
1198    pub merge_commit: Option<String>,
1199    /// Optional `applied-as-commits` list (only on `1631`).
1200    pub applied_as_commits: Vec<String>,
1201}
1202
1203impl StatusEvent {
1204    /// Construct a status bundle.
1205    #[must_use]
1206    pub const fn new(status: GitStatus) -> Self {
1207        Self {
1208            content: String::new(),
1209            status,
1210            references: Vec::new(),
1211            mentions: Vec::new(),
1212            repo: None,
1213            repo_euc: None,
1214            quoted_patches: Vec::new(),
1215            merge_commit: None,
1216            applied_as_commits: Vec::new(),
1217        }
1218    }
1219
1220    /// Render the typed bundle to the public tag list.
1221    #[must_use]
1222    pub fn to_tags(&self) -> Vec<Tag> {
1223        let mut tags: Vec<Tag> = Vec::new();
1224        for reference in &self.references {
1225            tags.push(e_tag(reference.event_id, reference.marker.as_deref()));
1226        }
1227        for mention in &self.mentions {
1228            tags.push(p_tag(*mention));
1229        }
1230        if let Some(repo) = &self.repo {
1231            tags.push(a_tag(repo));
1232        }
1233        if let Some(euc) = &self.repo_euc {
1234            tags.push(r_tag(euc.clone()));
1235        }
1236        for q in &self.quoted_patches {
1237            tags.push(Tag::with(
1238                &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::Q)),
1239                [q.to_hex(), String::new(), String::new()],
1240            ));
1241        }
1242        if let Some(merge_commit) = &self.merge_commit {
1243            tags.push(Tag::with(
1244                &TagKind::custom(tag_names::MERGE_COMMIT),
1245                [merge_commit.clone()],
1246            ));
1247            tags.push(r_tag(merge_commit.clone()));
1248        }
1249        if !self.applied_as_commits.is_empty() {
1250            tags.push(Tag::with(
1251                &TagKind::custom(tag_names::APPLIED_AS_COMMITS),
1252                self.applied_as_commits.clone(),
1253            ));
1254            for commit in &self.applied_as_commits {
1255                tags.push(r_tag(commit.clone()));
1256            }
1257        }
1258        tags
1259    }
1260
1261    /// Parse a signed kind-`1630..=1633` event into a typed bundle.
1262    ///
1263    /// # Errors
1264    ///
1265    /// Returns [`Nip34Error::InvalidStatusKind`] when the event's
1266    /// kind is outside `1630..=1633`; forwards every parse error.
1267    pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
1268        let status = GitStatus::from_kind(event.kind)?;
1269        let mut bundle = Self::new(status);
1270        bundle.content.clone_from(&event.content);
1271        for tag in &event.tags {
1272            let values = tag.values();
1273            let args = args(values);
1274            match tag.name() {
1275                "e" => {
1276                    let Some(id_hex) = args.first() else {
1277                        continue;
1278                    };
1279                    let event_id = EventId::parse(id_hex)?;
1280                    let marker = args.get(2).cloned().filter(|s| !s.is_empty());
1281                    bundle.references.push(StatusReference { event_id, marker });
1282                }
1283                "p" => {
1284                    if let Some(value) = args.first() {
1285                        bundle.mentions.push(PublicKey::parse(value)?);
1286                    }
1287                }
1288                "a" => {
1289                    if let Some(value) = args.first() {
1290                        bundle.repo = Some(Coordinate::parse(value)?);
1291                    }
1292                }
1293                "r" => {
1294                    if let Some(value) = args.first() {
1295                        bundle.repo_euc.get_or_insert_with(|| value.clone());
1296                    }
1297                }
1298                "q" => {
1299                    if let Some(value) = args.first() {
1300                        bundle.quoted_patches.push(EventId::parse(value)?);
1301                    }
1302                }
1303                tag_names::MERGE_COMMIT => bundle.merge_commit = args.first().cloned(),
1304                tag_names::APPLIED_AS_COMMITS => {
1305                    for v in args {
1306                        bundle.applied_as_commits.push(v.clone());
1307                    }
1308                }
1309                _ => {}
1310            }
1311        }
1312        Ok(bundle)
1313    }
1314}
1315
1316/// Typed bundle for the `kind: 10317` user grasp-server list.
1317#[derive(Debug, Clone, PartialEq, Eq, Default)]
1318pub struct GraspServerList {
1319    /// Grasp service WebSocket URLs in preference order.
1320    pub servers: Vec<RelayUrl>,
1321}
1322
1323impl GraspServerList {
1324    /// Construct a grasp-server list.
1325    #[must_use]
1326    pub fn new<I>(servers: I) -> Self
1327    where
1328        I: IntoIterator<Item = RelayUrl>,
1329    {
1330        Self {
1331            servers: servers.into_iter().collect(),
1332        }
1333    }
1334
1335    /// Render the typed bundle to the public tag list.
1336    #[must_use]
1337    pub fn to_tags(&self) -> Vec<Tag> {
1338        self.servers
1339            .iter()
1340            .map(|server| {
1341                Tag::with(
1342                    &TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::G)),
1343                    [server.as_str().to_owned()],
1344                )
1345            })
1346            .collect()
1347    }
1348
1349    /// Parse a signed kind-10317 event into a typed bundle.
1350    ///
1351    /// # Errors
1352    ///
1353    /// Returns [`Nip34Error::WrongKind`] for the wrong kind.
1354    pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
1355        if event.kind != KIND_GRASP_LIST {
1356            return Err(Nip34Error::WrongKind {
1357                expected: KIND_GRASP_LIST,
1358                got: event.kind,
1359            });
1360        }
1361        let mut servers: Vec<RelayUrl> = Vec::new();
1362        for tag in &event.tags {
1363            if tag.name() != "g" {
1364                continue;
1365            }
1366            if let Some(value) = tag.values().get(1) {
1367                servers.push(RelayUrl::parse(value)?);
1368            }
1369        }
1370        Ok(Self { servers })
1371    }
1372}
1373
1374impl EventBuilder {
1375    /// Author a NIP-34 repository announcement (`kind: 30617`).
1376    #[must_use]
1377    pub fn git_repository(repo: &Repository) -> Self {
1378        let mut builder = Self::new(KIND_REPO, "");
1379        for tag in repo.to_tags() {
1380            builder = builder.tag(tag);
1381        }
1382        builder
1383    }
1384
1385    /// Author a NIP-34 repository state event (`kind: 30618`).
1386    #[must_use]
1387    pub fn git_repository_state(state: &RepositoryState) -> Self {
1388        let mut builder = Self::new(KIND_REPO_STATE, "");
1389        for tag in state.to_tags() {
1390            builder = builder.tag(tag);
1391        }
1392        builder
1393    }
1394
1395    /// Author a NIP-34 patch event (`kind: 1617`).
1396    #[must_use]
1397    pub fn git_patch(patch: &Patch) -> Self {
1398        let mut builder = Self::new(KIND_PATCH, patch.content.clone());
1399        for tag in patch.to_tags() {
1400            builder = builder.tag(tag);
1401        }
1402        builder
1403    }
1404
1405    /// Author a NIP-34 pull request event (`kind: 1618`).
1406    #[must_use]
1407    pub fn git_pull_request(pr: &PullRequest) -> Self {
1408        let mut builder = Self::new(KIND_PULL_REQUEST, pr.content.clone());
1409        for tag in pr.to_tags() {
1410            builder = builder.tag(tag);
1411        }
1412        builder
1413    }
1414
1415    /// Author a NIP-34 pull request update event (`kind: 1619`).
1416    #[must_use]
1417    pub fn git_pull_request_update(update: &PullRequestUpdate) -> Self {
1418        let mut builder = Self::new(KIND_PULL_REQUEST_UPDATE, update.content.clone());
1419        for tag in update.to_tags() {
1420            builder = builder.tag(tag);
1421        }
1422        builder
1423    }
1424
1425    /// Author a NIP-34 issue event (`kind: 1621`).
1426    #[must_use]
1427    pub fn git_issue(issue: &Issue) -> Self {
1428        let mut builder = Self::new(KIND_ISSUE, issue.content.clone());
1429        for tag in issue.to_tags() {
1430            builder = builder.tag(tag);
1431        }
1432        builder
1433    }
1434
1435    /// Author a NIP-34 status event (`kind: 1630..=1633`).
1436    #[must_use]
1437    pub fn git_status(event: &StatusEvent) -> Self {
1438        let mut builder = Self::new(event.status.to_kind(), event.content.clone());
1439        for tag in event.to_tags() {
1440            builder = builder.tag(tag);
1441        }
1442        builder
1443    }
1444
1445    /// Author a NIP-34 grasp-server list event (`kind: 10317`).
1446    #[must_use]
1447    pub fn git_grasp_servers(list: &GraspServerList) -> Self {
1448        let mut builder = Self::new(KIND_GRASP_LIST, "");
1449        for tag in list.to_tags() {
1450            builder = builder.tag(tag);
1451        }
1452        builder
1453    }
1454}
1455
1456#[cfg(test)]
1457mod tests {
1458    use super::*;
1459    use crate::Keys;
1460
1461    fn keys() -> Keys {
1462        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
1463    }
1464
1465    fn other_keys() -> Keys {
1466        Keys::parse("0000000000000000000000000000000000000000000000000000000000000005").unwrap()
1467    }
1468
1469    fn repo_coord() -> Coordinate {
1470        Coordinate::new(KIND_REPO, *keys().public_key(), "ngit".to_owned())
1471    }
1472
1473    #[test]
1474    fn repository_round_trips_through_event() {
1475        let repo = Repository {
1476            identifier: "ngit".to_owned(),
1477            name: Some("ngit".to_owned()),
1478            description: Some("Nostr git-helper".to_owned()),
1479            web: vec!["https://ngit.dev".to_owned()],
1480            clone: vec!["https://github.com/x/ngit.git".to_owned()],
1481            relays: vec![RelayUrl::parse("wss://relay.ngit.dev").unwrap()],
1482            earliest_unique_commit: Some("deadbeef".to_owned()),
1483            maintainers: vec![*other_keys().public_key()],
1484            hashtags: vec!["git".to_owned(), "tooling".to_owned()],
1485            personal_fork: false,
1486        };
1487        let event = EventBuilder::git_repository(&repo)
1488            .sign_with_keys(&keys())
1489            .unwrap();
1490        assert_eq!(event.kind, KIND_REPO);
1491        let recovered = Repository::from_event(&event).unwrap();
1492        assert_eq!(recovered, repo);
1493    }
1494
1495    #[test]
1496    fn repository_personal_fork_flag_round_trips() {
1497        let repo = Repository {
1498            personal_fork: true,
1499            ..Repository::new("fork".to_owned())
1500        };
1501        let event = EventBuilder::git_repository(&repo)
1502            .sign_with_keys(&keys())
1503            .unwrap();
1504        let recovered = Repository::from_event(&event).unwrap();
1505        assert!(recovered.personal_fork);
1506    }
1507
1508    #[test]
1509    fn repository_from_event_requires_identifier() {
1510        let event = EventBuilder::new(KIND_REPO, "")
1511            .sign_with_keys(&keys())
1512            .unwrap();
1513        assert!(matches!(
1514            Repository::from_event(&event),
1515            Err(Nip34Error::MissingIdentifier { .. }),
1516        ));
1517    }
1518
1519    #[test]
1520    fn repository_state_round_trips_through_event() {
1521        let state = RepositoryState {
1522            identifier: "ngit".to_owned(),
1523            refs: vec![
1524                GitRef::new("refs/heads/main", "aabb"),
1525                GitRef::new("refs/tags/v1.0.0", "ccdd"),
1526            ],
1527            head: Some("ref: refs/heads/main".to_owned()),
1528        };
1529        let event = EventBuilder::git_repository_state(&state)
1530            .sign_with_keys(&keys())
1531            .unwrap();
1532        assert_eq!(event.kind, KIND_REPO_STATE);
1533        let recovered = RepositoryState::from_event(&event).unwrap();
1534        assert_eq!(recovered, state);
1535    }
1536
1537    #[test]
1538    fn patch_round_trips_through_event() {
1539        let patch = Patch {
1540            content: "From <git format-patch output>\n".to_owned(),
1541            repo: repo_coord(),
1542            repo_euc: Some("deadbeef".to_owned()),
1543            mentions: vec![*other_keys().public_key()],
1544            root: true,
1545            root_revision: false,
1546            commit: Some("abc123".to_owned()),
1547            parent_commit: Some("def456".to_owned()),
1548            commit_pgp_sig: Some("-----BEGIN PGP SIGNATURE-----\n...".to_owned()),
1549            committer: Some(Committer {
1550                name: "Satoshi".to_owned(),
1551                email: "satoshi@example".to_owned(),
1552                timestamp: "1700000000".to_owned(),
1553                offset: "+0000".to_owned(),
1554            }),
1555        };
1556        let event = EventBuilder::git_patch(&patch)
1557            .sign_with_keys(&keys())
1558            .unwrap();
1559        assert_eq!(event.kind, KIND_PATCH);
1560        let recovered = Patch::from_event(&event).unwrap();
1561        assert_eq!(recovered, patch);
1562    }
1563
1564    #[test]
1565    fn patch_from_event_requires_repository() {
1566        let event = EventBuilder::new(KIND_PATCH, "")
1567            .sign_with_keys(&keys())
1568            .unwrap();
1569        assert!(matches!(
1570            Patch::from_event(&event),
1571            Err(Nip34Error::MissingRepository { .. }),
1572        ));
1573    }
1574
1575    #[test]
1576    fn pull_request_round_trips_through_event() {
1577        let pr = PullRequest {
1578            content: "Adds awesome feature".to_owned(),
1579            repo: repo_coord(),
1580            repo_euc: Some("deadbeef".to_owned()),
1581            mentions: vec![*other_keys().public_key()],
1582            subject: Some("feat: awesome".to_owned()),
1583            hashtags: vec!["feature".to_owned()],
1584            tip_commit: Some("abc123".to_owned()),
1585            clone: vec!["https://github.com/x/ngit.git".to_owned()],
1586            branch_name: Some("feat/awesome".to_owned()),
1587            revises_event: Some(EventId::from_byte_array([0xaa; 32])),
1588            merge_base: Some("base999".to_owned()),
1589        };
1590        let event = EventBuilder::git_pull_request(&pr)
1591            .sign_with_keys(&keys())
1592            .unwrap();
1593        assert_eq!(event.kind, KIND_PULL_REQUEST);
1594        let recovered = PullRequest::from_event(&event).unwrap();
1595        assert_eq!(recovered, pr);
1596    }
1597
1598    #[test]
1599    fn pull_request_update_round_trips_through_event() {
1600        let update = PullRequestUpdate {
1601            content: "Updated tip".to_owned(),
1602            repo: repo_coord(),
1603            repo_euc: None,
1604            mentions: vec![],
1605            root_event: EventId::from_byte_array([0xbb; 32]),
1606            root_pubkey: *other_keys().public_key(),
1607            tip_commit: Some("def456".to_owned()),
1608            clone: vec!["https://github.com/x/ngit.git".to_owned()],
1609            merge_base: Some("base000".to_owned()),
1610        };
1611        let event = EventBuilder::git_pull_request_update(&update)
1612            .sign_with_keys(&keys())
1613            .unwrap();
1614        assert_eq!(event.kind, KIND_PULL_REQUEST_UPDATE);
1615        let recovered = PullRequestUpdate::from_event(&event).unwrap();
1616        assert_eq!(recovered, update);
1617    }
1618
1619    #[test]
1620    fn issue_round_trips_through_event() {
1621        let issue = Issue {
1622            content: "Bug body in Markdown".to_owned(),
1623            repo: repo_coord(),
1624            mentions: vec![*other_keys().public_key()],
1625            subject: Some("Crash on startup".to_owned()),
1626            hashtags: vec!["bug".to_owned(), "priority-high".to_owned()],
1627        };
1628        let event = EventBuilder::git_issue(&issue)
1629            .sign_with_keys(&keys())
1630            .unwrap();
1631        assert_eq!(event.kind, KIND_ISSUE);
1632        let recovered = Issue::from_event(&event).unwrap();
1633        assert_eq!(recovered, issue);
1634    }
1635
1636    #[test]
1637    fn status_round_trips_for_each_kind() {
1638        for status in [
1639            GitStatus::Open,
1640            GitStatus::Applied,
1641            GitStatus::Closed,
1642            GitStatus::Draft,
1643        ] {
1644            let event = StatusEvent {
1645                content: format!("status: {status:?}"),
1646                status,
1647                references: vec![StatusReference {
1648                    event_id: EventId::from_byte_array([0xcc; 32]),
1649                    marker: Some("root".to_owned()),
1650                }],
1651                mentions: vec![*other_keys().public_key()],
1652                repo: Some(repo_coord()),
1653                repo_euc: Some("deadbeef".to_owned()),
1654                quoted_patches: vec![],
1655                merge_commit: None,
1656                applied_as_commits: vec![],
1657            };
1658            let signed = EventBuilder::git_status(&event)
1659                .sign_with_keys(&keys())
1660                .unwrap();
1661            assert_eq!(signed.kind, status.to_kind());
1662            let recovered = StatusEvent::from_event(&signed).unwrap();
1663            assert_eq!(recovered, event);
1664        }
1665    }
1666
1667    #[test]
1668    fn status_applied_carries_merge_metadata() {
1669        let event = StatusEvent {
1670            content: String::new(),
1671            status: GitStatus::Applied,
1672            references: vec![],
1673            mentions: vec![],
1674            repo: None,
1675            repo_euc: None,
1676            quoted_patches: vec![EventId::from_byte_array([0xdd; 32])],
1677            merge_commit: Some("mergecommithex".to_owned()),
1678            applied_as_commits: vec!["a1".to_owned(), "a2".to_owned()],
1679        };
1680        let signed = EventBuilder::git_status(&event)
1681            .sign_with_keys(&keys())
1682            .unwrap();
1683        let recovered = StatusEvent::from_event(&signed).unwrap();
1684        assert_eq!(recovered.merge_commit.as_deref(), Some("mergecommithex"));
1685        assert_eq!(recovered.applied_as_commits, event.applied_as_commits);
1686        assert_eq!(recovered.quoted_patches, event.quoted_patches);
1687    }
1688
1689    #[test]
1690    fn status_from_event_rejects_kind_outside_range() {
1691        let event = EventBuilder::text_note("not a status")
1692            .sign_with_keys(&keys())
1693            .unwrap();
1694        assert!(matches!(
1695            StatusEvent::from_event(&event),
1696            Err(Nip34Error::InvalidStatusKind(_)),
1697        ));
1698    }
1699
1700    #[test]
1701    fn git_status_kind_helpers_round_trip() {
1702        for status in [
1703            GitStatus::Open,
1704            GitStatus::Applied,
1705            GitStatus::Closed,
1706            GitStatus::Draft,
1707        ] {
1708            assert_eq!(GitStatus::from_kind(status.to_kind()).unwrap(), status);
1709        }
1710        assert!(matches!(
1711            GitStatus::from_kind(Kind::TEXT_NOTE),
1712            Err(Nip34Error::InvalidStatusKind(_)),
1713        ));
1714    }
1715
1716    #[test]
1717    fn grasp_server_list_round_trips_through_event() {
1718        let list = GraspServerList::new([
1719            RelayUrl::parse("wss://grasp.one.example").unwrap(),
1720            RelayUrl::parse("wss://grasp.two.example").unwrap(),
1721        ]);
1722        let event = EventBuilder::git_grasp_servers(&list)
1723            .sign_with_keys(&keys())
1724            .unwrap();
1725        assert_eq!(event.kind, KIND_GRASP_LIST);
1726        let recovered = GraspServerList::from_event(&event).unwrap();
1727        assert_eq!(recovered, list);
1728    }
1729
1730    #[test]
1731    fn grasp_server_list_from_event_rejects_wrong_kind() {
1732        let event = EventBuilder::text_note("nope")
1733            .sign_with_keys(&keys())
1734            .unwrap();
1735        assert!(matches!(
1736            GraspServerList::from_event(&event),
1737            Err(Nip34Error::WrongKind { .. }),
1738        ));
1739    }
1740}