#![allow(
clippy::excessive_nesting,
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"
)]
#![allow(
clippy::too_many_lines,
reason = "NIP-34 fields fan out across 8 typed bundles; splitting renders / parsers further would create indirection without clarity"
)]
use thiserror::Error;
use crate::event::{
Alphabet, Coordinate, CoordinateError, Event, EventBuilder, EventId, EventIdError, Kind,
SingleLetterTag, Tag, TagError, TagKind,
};
use crate::key::{PublicKey, PublicKeyError};
use crate::types::{RelayUrl, RelayUrlError};
pub const KIND_REPO: Kind = Kind::GIT_REPOSITORY;
pub const KIND_REPO_STATE: Kind = Kind::GIT_REPOSITORY_STATE;
pub const KIND_PATCH: Kind = Kind::GIT_PATCH;
pub const KIND_PULL_REQUEST: Kind = Kind::GIT_PULL_REQUEST;
pub const KIND_PULL_REQUEST_UPDATE: Kind = Kind::GIT_PULL_REQUEST_UPDATE;
pub const KIND_ISSUE: Kind = Kind::GIT_ISSUE;
pub const KIND_STATUS_OPEN: Kind = Kind::GIT_STATUS_OPEN;
pub const KIND_STATUS_APPLIED: Kind = Kind::GIT_STATUS_APPLIED;
pub const KIND_STATUS_CLOSED: Kind = Kind::GIT_STATUS_CLOSED;
pub const KIND_STATUS_DRAFT: Kind = Kind::GIT_STATUS_DRAFT;
pub const KIND_GRASP_LIST: Kind = Kind::GIT_GRASP_LIST;
pub const PERSONAL_FORK_HASHTAG: &str = "personal-fork";
pub const EUC_MARKER: &str = "euc";
mod tag_names {
pub(super) const D: &str = "d";
pub(super) const NAME: &str = "name";
pub(super) const DESCRIPTION: &str = "description";
pub(super) const WEB: &str = "web";
pub(super) const CLONE: &str = "clone";
pub(super) const RELAYS: &str = "relays";
pub(super) const MAINTAINERS: &str = "maintainers";
pub(super) const COMMIT: &str = "commit";
pub(super) const PARENT_COMMIT: &str = "parent-commit";
pub(super) const COMMIT_PGP_SIG: &str = "commit-pgp-sig";
pub(super) const COMMITTER: &str = "committer";
pub(super) const SUBJECT: &str = "subject";
pub(super) const BRANCH_NAME: &str = "branch-name";
pub(super) const MERGE_BASE: &str = "merge-base";
pub(super) const MERGE_COMMIT: &str = "merge-commit";
pub(super) const APPLIED_AS_COMMITS: &str = "applied-as-commits";
pub(super) const HEAD: &str = "HEAD";
pub(super) const REFS_PREFIX: &str = "refs/";
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum Nip34Error {
#[error("expected kind {expected}, got {got}")]
WrongKind {
expected: Kind,
got: Kind,
},
#[error("expected a status kind (1630..=1633), got {0}")]
InvalidStatusKind(Kind),
#[error("NIP-34 {kind} event missing required `d` tag")]
MissingIdentifier {
kind: Kind,
},
#[error("NIP-34 {kind} event missing required `a` repository tag")]
MissingRepository {
kind: Kind,
},
#[error(transparent)]
Coordinate(#[from] CoordinateError),
#[error(transparent)]
EventId(#[from] EventIdError),
#[error(transparent)]
RelayUrl(#[from] RelayUrlError),
#[error(transparent)]
PublicKey(#[from] PublicKeyError),
#[error(transparent)]
Tag(#[from] TagError),
}
fn second(values: &[String]) -> Option<&str> {
values.get(1).map(String::as_str)
}
fn args(values: &[String]) -> &[String] {
values.get(1..).unwrap_or(&[])
}
fn p_tag(pubkey: PublicKey) -> Tag {
Tag::with(
&TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P)),
[pubkey.to_hex()],
)
}
fn e_tag(event_id: EventId, marker: Option<&str>) -> Tag {
let mut row = vec![event_id.to_hex()];
row.push(String::new());
if let Some(marker) = marker {
row.push(marker.to_owned());
}
Tag::with(
&TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E)),
row,
)
}
fn r_tag(value: impl Into<String>) -> Tag {
Tag::with(
&TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::R)),
[value.into()],
)
}
fn a_tag(coordinate: &Coordinate) -> Tag {
Tag::with(
&TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::A)),
[coordinate.to_wire()],
)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Repository {
pub identifier: String,
pub name: Option<String>,
pub description: Option<String>,
pub web: Vec<String>,
pub clone: Vec<String>,
pub relays: Vec<RelayUrl>,
pub earliest_unique_commit: Option<String>,
pub maintainers: Vec<PublicKey>,
pub hashtags: Vec<String>,
pub personal_fork: bool,
}
impl Repository {
#[must_use]
pub fn new(identifier: impl Into<String>) -> Self {
Self {
identifier: identifier.into(),
name: None,
description: None,
web: Vec::new(),
clone: Vec::new(),
relays: Vec::new(),
earliest_unique_commit: None,
maintainers: Vec::new(),
hashtags: Vec::new(),
personal_fork: false,
}
}
#[must_use]
pub fn coordinate(&self, author: PublicKey) -> Coordinate {
Coordinate::new(KIND_REPO, author, self.identifier.clone())
}
#[must_use]
pub fn to_tags(&self) -> Vec<Tag> {
let mut tags: Vec<Tag> = Vec::new();
tags.push(Tag::with(
&TagKind::custom(tag_names::D),
[self.identifier.clone()],
));
if let Some(name) = &self.name {
tags.push(Tag::with(&TagKind::custom(tag_names::NAME), [name.clone()]));
}
if let Some(description) = &self.description {
tags.push(Tag::with(
&TagKind::custom(tag_names::DESCRIPTION),
[description.clone()],
));
}
if !self.web.is_empty() {
tags.push(Tag::with(
&TagKind::custom(tag_names::WEB),
self.web.clone(),
));
}
if !self.clone.is_empty() {
tags.push(Tag::with(
&TagKind::custom(tag_names::CLONE),
self.clone.clone(),
));
}
if !self.relays.is_empty() {
let row: Vec<String> = self.relays.iter().map(|r| r.as_str().to_owned()).collect();
tags.push(Tag::with(&TagKind::custom(tag_names::RELAYS), row));
}
if let Some(euc) = &self.earliest_unique_commit {
tags.push(Tag::with(
&TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::R)),
[euc.clone(), EUC_MARKER.to_owned()],
));
}
if !self.maintainers.is_empty() {
let row: Vec<String> = self.maintainers.iter().map(|pk| pk.to_hex()).collect();
tags.push(Tag::with(&TagKind::custom(tag_names::MAINTAINERS), row));
}
if self.personal_fork {
tags.push(Tag::with(
&TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::T)),
[PERSONAL_FORK_HASHTAG.to_owned()],
));
}
for hashtag in &self.hashtags {
tags.push(Tag::with(
&TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::T)),
[hashtag.clone()],
));
}
tags
}
pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
if event.kind != KIND_REPO {
return Err(Nip34Error::WrongKind {
expected: KIND_REPO,
got: event.kind,
});
}
let mut identifier: Option<String> = None;
let mut repo = Self::new(String::new());
for tag in &event.tags {
let values = tag.values();
let args = args(values);
match tag.name() {
tag_names::D => identifier = second(values).map(String::from),
tag_names::NAME => repo.name = second(values).map(String::from),
tag_names::DESCRIPTION => repo.description = second(values).map(String::from),
tag_names::WEB => {
for v in args {
repo.web.push(v.clone());
}
}
tag_names::CLONE => {
for v in args {
repo.clone.push(v.clone());
}
}
tag_names::RELAYS => {
for v in args {
repo.relays.push(RelayUrl::parse(v)?);
}
}
tag_names::MAINTAINERS => {
for v in args {
repo.maintainers.push(PublicKey::parse(v)?);
}
}
"r" => {
let marker = args.get(1).map(String::as_str);
if marker == Some(EUC_MARKER) {
repo.earliest_unique_commit = args.first().cloned();
}
}
"t" => {
if let Some(value) = args.first() {
if value == PERSONAL_FORK_HASHTAG {
repo.personal_fork = true;
} else {
repo.hashtags.push(value.clone());
}
}
}
_ => {}
}
}
repo.identifier = identifier.ok_or(Nip34Error::MissingIdentifier { kind: KIND_REPO })?;
Ok(repo)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GitRef {
pub ref_path: String,
pub oid: String,
}
impl GitRef {
#[must_use]
pub fn new(ref_path: impl Into<String>, oid: impl Into<String>) -> Self {
Self {
ref_path: ref_path.into(),
oid: oid.into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RepositoryState {
pub identifier: String,
pub refs: Vec<GitRef>,
pub head: Option<String>,
}
impl RepositoryState {
#[must_use]
pub fn new(identifier: impl Into<String>) -> Self {
Self {
identifier: identifier.into(),
refs: Vec::new(),
head: None,
}
}
#[must_use]
pub fn to_tags(&self) -> Vec<Tag> {
let mut tags: Vec<Tag> = Vec::new();
tags.push(Tag::with(
&TagKind::custom(tag_names::D),
[self.identifier.clone()],
));
for git_ref in &self.refs {
tags.push(Tag::with(
&TagKind::custom(&git_ref.ref_path),
[git_ref.oid.clone()],
));
}
if let Some(head) = &self.head {
tags.push(Tag::with(&TagKind::custom(tag_names::HEAD), [head.clone()]));
}
tags
}
pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
if event.kind != KIND_REPO_STATE {
return Err(Nip34Error::WrongKind {
expected: KIND_REPO_STATE,
got: event.kind,
});
}
let mut identifier: Option<String> = None;
let mut state = Self::new(String::new());
for tag in &event.tags {
let values = tag.values();
let name = tag.name();
if name == tag_names::D {
identifier = second(values).map(String::from);
continue;
}
if name == tag_names::HEAD {
state.head = second(values).map(String::from);
continue;
}
if name.starts_with(tag_names::REFS_PREFIX)
&& let Some(oid) = second(values)
{
state.refs.push(GitRef::new(name, oid));
}
}
state.identifier = identifier.ok_or(Nip34Error::MissingIdentifier {
kind: KIND_REPO_STATE,
})?;
Ok(state)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Patch {
pub content: String,
pub repo: Coordinate,
pub repo_euc: Option<String>,
pub mentions: Vec<PublicKey>,
pub root: bool,
pub root_revision: bool,
pub commit: Option<String>,
pub parent_commit: Option<String>,
pub commit_pgp_sig: Option<String>,
pub committer: Option<Committer>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Committer {
pub name: String,
pub email: String,
pub timestamp: String,
pub offset: String,
}
impl Patch {
#[must_use]
pub fn new(repo: Coordinate, content: impl Into<String>) -> Self {
Self {
content: content.into(),
repo,
repo_euc: None,
mentions: Vec::new(),
root: false,
root_revision: false,
commit: None,
parent_commit: None,
commit_pgp_sig: None,
committer: None,
}
}
#[must_use]
pub fn to_tags(&self) -> Vec<Tag> {
let mut tags: Vec<Tag> = Vec::new();
tags.push(a_tag(&self.repo));
if let Some(euc) = &self.repo_euc {
tags.push(r_tag(euc.clone()));
}
for mention in &self.mentions {
tags.push(p_tag(*mention));
}
if self.root {
tags.push(Tag::with(
&TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::T)),
["root".to_owned()],
));
}
if self.root_revision {
tags.push(Tag::with(
&TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::T)),
["root-revision".to_owned()],
));
}
if let Some(commit) = &self.commit {
tags.push(Tag::with(
&TagKind::custom(tag_names::COMMIT),
[commit.clone()],
));
tags.push(r_tag(commit.clone()));
}
if let Some(parent_commit) = &self.parent_commit {
tags.push(Tag::with(
&TagKind::custom(tag_names::PARENT_COMMIT),
[parent_commit.clone()],
));
}
if let Some(sig) = &self.commit_pgp_sig {
tags.push(Tag::with(
&TagKind::custom(tag_names::COMMIT_PGP_SIG),
[sig.clone()],
));
}
if let Some(committer) = &self.committer {
tags.push(Tag::with(
&TagKind::custom(tag_names::COMMITTER),
[
committer.name.clone(),
committer.email.clone(),
committer.timestamp.clone(),
committer.offset.clone(),
],
));
}
tags
}
pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
if event.kind != KIND_PATCH {
return Err(Nip34Error::WrongKind {
expected: KIND_PATCH,
got: event.kind,
});
}
let mut repo: Option<Coordinate> = None;
let mut mentions: Vec<PublicKey> = Vec::new();
let mut root = false;
let mut root_revision = false;
let mut commit: Option<String> = None;
let mut parent_commit: Option<String> = None;
let mut commit_pgp_sig: Option<String> = None;
let mut committer: Option<Committer> = None;
let mut repo_euc: Option<String> = None;
for tag in &event.tags {
let values = tag.values();
let args = args(values);
match tag.name() {
"a" => {
if let Some(value) = args.first() {
repo = Some(Coordinate::parse(value)?);
}
}
"r" => {
if let Some(value) = args.first() {
repo_euc.get_or_insert_with(|| value.clone());
}
}
"p" => {
if let Some(value) = args.first() {
mentions.push(PublicKey::parse(value)?);
}
}
"t" => match args.first().map(String::as_str) {
Some("root") => root = true,
Some("root-revision") => root_revision = true,
_ => {}
},
tag_names::COMMIT => commit = args.first().cloned(),
tag_names::PARENT_COMMIT => parent_commit = args.first().cloned(),
tag_names::COMMIT_PGP_SIG => commit_pgp_sig = args.first().cloned(),
tag_names::COMMITTER => {
if let [name, email, timestamp, offset, ..] = args {
committer = Some(Committer {
name: name.clone(),
email: email.clone(),
timestamp: timestamp.clone(),
offset: offset.clone(),
});
}
}
_ => {}
}
}
let repo = repo.ok_or(Nip34Error::MissingRepository { kind: KIND_PATCH })?;
Ok(Self {
content: event.content.clone(),
repo,
repo_euc,
mentions,
root,
root_revision,
commit,
parent_commit,
commit_pgp_sig,
committer,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PullRequest {
pub content: String,
pub repo: Coordinate,
pub repo_euc: Option<String>,
pub mentions: Vec<PublicKey>,
pub subject: Option<String>,
pub hashtags: Vec<String>,
pub tip_commit: Option<String>,
pub clone: Vec<String>,
pub branch_name: Option<String>,
pub revises_event: Option<EventId>,
pub merge_base: Option<String>,
}
impl PullRequest {
#[must_use]
pub fn new(repo: Coordinate, content: impl Into<String>) -> Self {
Self {
content: content.into(),
repo,
repo_euc: None,
mentions: Vec::new(),
subject: None,
hashtags: Vec::new(),
tip_commit: None,
clone: Vec::new(),
branch_name: None,
revises_event: None,
merge_base: None,
}
}
#[must_use]
pub fn to_tags(&self) -> Vec<Tag> {
let mut tags: Vec<Tag> = Vec::new();
tags.push(a_tag(&self.repo));
if let Some(euc) = &self.repo_euc {
tags.push(r_tag(euc.clone()));
}
for mention in &self.mentions {
tags.push(p_tag(*mention));
}
if let Some(subject) = &self.subject {
tags.push(Tag::with(
&TagKind::custom(tag_names::SUBJECT),
[subject.clone()],
));
}
for hashtag in &self.hashtags {
tags.push(Tag::with(
&TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::T)),
[hashtag.clone()],
));
}
if let Some(c) = &self.tip_commit {
tags.push(Tag::with(
&TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::C)),
[c.clone()],
));
}
if !self.clone.is_empty() {
tags.push(Tag::with(
&TagKind::custom(tag_names::CLONE),
self.clone.clone(),
));
}
if let Some(branch) = &self.branch_name {
tags.push(Tag::with(
&TagKind::custom(tag_names::BRANCH_NAME),
[branch.clone()],
));
}
if let Some(event_id) = self.revises_event {
tags.push(e_tag(event_id, None));
}
if let Some(merge_base) = &self.merge_base {
tags.push(Tag::with(
&TagKind::custom(tag_names::MERGE_BASE),
[merge_base.clone()],
));
}
tags
}
pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
if event.kind != KIND_PULL_REQUEST {
return Err(Nip34Error::WrongKind {
expected: KIND_PULL_REQUEST,
got: event.kind,
});
}
let mut repo: Option<Coordinate> = None;
let mut repo_euc: Option<String> = None;
let mut mentions: Vec<PublicKey> = Vec::new();
let mut subject: Option<String> = None;
let mut hashtags: Vec<String> = Vec::new();
let mut tip_commit: Option<String> = None;
let mut clone: Vec<String> = Vec::new();
let mut branch_name: Option<String> = None;
let mut revises_event: Option<EventId> = None;
let mut merge_base: Option<String> = None;
for tag in &event.tags {
let values = tag.values();
let args = args(values);
match tag.name() {
"a" => {
if let Some(value) = args.first() {
repo = Some(Coordinate::parse(value)?);
}
}
"r" => {
if let Some(value) = args.first() {
repo_euc.get_or_insert_with(|| value.clone());
}
}
"p" => {
if let Some(value) = args.first() {
mentions.push(PublicKey::parse(value)?);
}
}
tag_names::SUBJECT => subject = args.first().cloned(),
"t" => {
if let Some(value) = args.first() {
hashtags.push(value.clone());
}
}
"c" => tip_commit = args.first().cloned(),
tag_names::CLONE => {
for v in args {
clone.push(v.clone());
}
}
tag_names::BRANCH_NAME => branch_name = args.first().cloned(),
"e" => {
if let Some(value) = args.first() {
revises_event = Some(EventId::parse(value)?);
}
}
tag_names::MERGE_BASE => merge_base = args.first().cloned(),
_ => {}
}
}
let repo = repo.ok_or(Nip34Error::MissingRepository {
kind: KIND_PULL_REQUEST,
})?;
Ok(Self {
content: event.content.clone(),
repo,
repo_euc,
mentions,
subject,
hashtags,
tip_commit,
clone,
branch_name,
revises_event,
merge_base,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PullRequestUpdate {
pub content: String,
pub repo: Coordinate,
pub repo_euc: Option<String>,
pub mentions: Vec<PublicKey>,
pub root_event: EventId,
pub root_pubkey: PublicKey,
pub tip_commit: Option<String>,
pub clone: Vec<String>,
pub merge_base: Option<String>,
}
impl PullRequestUpdate {
#[must_use]
pub fn new(
repo: Coordinate,
root_event: EventId,
root_pubkey: PublicKey,
content: impl Into<String>,
) -> Self {
Self {
content: content.into(),
repo,
repo_euc: None,
mentions: Vec::new(),
root_event,
root_pubkey,
tip_commit: None,
clone: Vec::new(),
merge_base: None,
}
}
#[must_use]
pub fn to_tags(&self) -> Vec<Tag> {
let mut tags: Vec<Tag> = Vec::new();
tags.push(a_tag(&self.repo));
if let Some(euc) = &self.repo_euc {
tags.push(r_tag(euc.clone()));
}
for mention in &self.mentions {
tags.push(p_tag(*mention));
}
tags.push(Tag::with(
&TagKind::single_letter(SingleLetterTag::uppercase(Alphabet::E)),
[self.root_event.to_hex()],
));
tags.push(Tag::with(
&TagKind::single_letter(SingleLetterTag::uppercase(Alphabet::P)),
[self.root_pubkey.to_hex()],
));
if let Some(c) = &self.tip_commit {
tags.push(Tag::with(
&TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::C)),
[c.clone()],
));
}
if !self.clone.is_empty() {
tags.push(Tag::with(
&TagKind::custom(tag_names::CLONE),
self.clone.clone(),
));
}
if let Some(merge_base) = &self.merge_base {
tags.push(Tag::with(
&TagKind::custom(tag_names::MERGE_BASE),
[merge_base.clone()],
));
}
tags
}
pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
if event.kind != KIND_PULL_REQUEST_UPDATE {
return Err(Nip34Error::WrongKind {
expected: KIND_PULL_REQUEST_UPDATE,
got: event.kind,
});
}
let mut repo: Option<Coordinate> = None;
let mut repo_euc: Option<String> = None;
let mut mentions: Vec<PublicKey> = Vec::new();
let mut root_event: Option<EventId> = None;
let mut root_pubkey: Option<PublicKey> = None;
let mut tip_commit: Option<String> = None;
let mut clone: Vec<String> = Vec::new();
let mut merge_base: Option<String> = None;
for tag in &event.tags {
let values = tag.values();
let args = args(values);
match tag.name() {
"a" => {
if let Some(value) = args.first() {
repo = Some(Coordinate::parse(value)?);
}
}
"r" => {
if let Some(value) = args.first() {
repo_euc.get_or_insert_with(|| value.clone());
}
}
"p" => {
if let Some(value) = args.first() {
mentions.push(PublicKey::parse(value)?);
}
}
"E" => {
if let Some(value) = args.first() {
root_event = Some(EventId::parse(value)?);
}
}
"P" => {
if let Some(value) = args.first() {
root_pubkey = Some(PublicKey::parse(value)?);
}
}
"c" => tip_commit = args.first().cloned(),
tag_names::CLONE => {
for v in args {
clone.push(v.clone());
}
}
tag_names::MERGE_BASE => merge_base = args.first().cloned(),
_ => {}
}
}
let repo = repo.ok_or(Nip34Error::MissingRepository {
kind: KIND_PULL_REQUEST_UPDATE,
})?;
let root_event = root_event.ok_or(Nip34Error::MissingRepository {
kind: KIND_PULL_REQUEST_UPDATE,
})?;
let root_pubkey = root_pubkey.ok_or(Nip34Error::MissingRepository {
kind: KIND_PULL_REQUEST_UPDATE,
})?;
Ok(Self {
content: event.content.clone(),
repo,
repo_euc,
mentions,
root_event,
root_pubkey,
tip_commit,
clone,
merge_base,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Issue {
pub content: String,
pub repo: Coordinate,
pub mentions: Vec<PublicKey>,
pub subject: Option<String>,
pub hashtags: Vec<String>,
}
impl Issue {
#[must_use]
pub fn new(repo: Coordinate, content: impl Into<String>) -> Self {
Self {
content: content.into(),
repo,
mentions: Vec::new(),
subject: None,
hashtags: Vec::new(),
}
}
#[must_use]
pub fn to_tags(&self) -> Vec<Tag> {
let mut tags: Vec<Tag> = Vec::new();
tags.push(a_tag(&self.repo));
for mention in &self.mentions {
tags.push(p_tag(*mention));
}
if let Some(subject) = &self.subject {
tags.push(Tag::with(
&TagKind::custom(tag_names::SUBJECT),
[subject.clone()],
));
}
for hashtag in &self.hashtags {
tags.push(Tag::with(
&TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::T)),
[hashtag.clone()],
));
}
tags
}
pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
if event.kind != KIND_ISSUE {
return Err(Nip34Error::WrongKind {
expected: KIND_ISSUE,
got: event.kind,
});
}
let mut repo: Option<Coordinate> = None;
let mut mentions: Vec<PublicKey> = Vec::new();
let mut subject: Option<String> = None;
let mut hashtags: Vec<String> = Vec::new();
for tag in &event.tags {
let values = tag.values();
let args = args(values);
match tag.name() {
"a" => {
if let Some(value) = args.first() {
repo = Some(Coordinate::parse(value)?);
}
}
"p" => {
if let Some(value) = args.first() {
mentions.push(PublicKey::parse(value)?);
}
}
tag_names::SUBJECT => subject = args.first().cloned(),
"t" => {
if let Some(value) = args.first() {
hashtags.push(value.clone());
}
}
_ => {}
}
}
let repo = repo.ok_or(Nip34Error::MissingRepository { kind: KIND_ISSUE })?;
Ok(Self {
content: event.content.clone(),
repo,
mentions,
subject,
hashtags,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[non_exhaustive]
pub enum GitStatus {
Open,
Applied,
Closed,
Draft,
}
impl GitStatus {
#[must_use]
pub const fn to_kind(self) -> Kind {
match self {
Self::Open => KIND_STATUS_OPEN,
Self::Applied => KIND_STATUS_APPLIED,
Self::Closed => KIND_STATUS_CLOSED,
Self::Draft => KIND_STATUS_DRAFT,
}
}
pub const fn from_kind(kind: Kind) -> Result<Self, Nip34Error> {
match kind.as_u16() {
1_630 => Ok(Self::Open),
1_631 => Ok(Self::Applied),
1_632 => Ok(Self::Closed),
1_633 => Ok(Self::Draft),
_ => Err(Nip34Error::InvalidStatusKind(kind)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusReference {
pub event_id: EventId,
pub marker: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct StatusEvent {
pub content: String,
pub status: GitStatus,
pub references: Vec<StatusReference>,
pub mentions: Vec<PublicKey>,
pub repo: Option<Coordinate>,
pub repo_euc: Option<String>,
pub quoted_patches: Vec<EventId>,
pub merge_commit: Option<String>,
pub applied_as_commits: Vec<String>,
}
impl StatusEvent {
#[must_use]
pub const fn new(status: GitStatus) -> Self {
Self {
content: String::new(),
status,
references: Vec::new(),
mentions: Vec::new(),
repo: None,
repo_euc: None,
quoted_patches: Vec::new(),
merge_commit: None,
applied_as_commits: Vec::new(),
}
}
#[must_use]
pub fn to_tags(&self) -> Vec<Tag> {
let mut tags: Vec<Tag> = Vec::new();
for reference in &self.references {
tags.push(e_tag(reference.event_id, reference.marker.as_deref()));
}
for mention in &self.mentions {
tags.push(p_tag(*mention));
}
if let Some(repo) = &self.repo {
tags.push(a_tag(repo));
}
if let Some(euc) = &self.repo_euc {
tags.push(r_tag(euc.clone()));
}
for q in &self.quoted_patches {
tags.push(Tag::with(
&TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::Q)),
[q.to_hex(), String::new(), String::new()],
));
}
if let Some(merge_commit) = &self.merge_commit {
tags.push(Tag::with(
&TagKind::custom(tag_names::MERGE_COMMIT),
[merge_commit.clone()],
));
tags.push(r_tag(merge_commit.clone()));
}
if !self.applied_as_commits.is_empty() {
tags.push(Tag::with(
&TagKind::custom(tag_names::APPLIED_AS_COMMITS),
self.applied_as_commits.clone(),
));
for commit in &self.applied_as_commits {
tags.push(r_tag(commit.clone()));
}
}
tags
}
pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
let status = GitStatus::from_kind(event.kind)?;
let mut bundle = Self::new(status);
bundle.content.clone_from(&event.content);
for tag in &event.tags {
let values = tag.values();
let args = args(values);
match tag.name() {
"e" => {
let Some(id_hex) = args.first() else {
continue;
};
let event_id = EventId::parse(id_hex)?;
let marker = args.get(2).cloned().filter(|s| !s.is_empty());
bundle.references.push(StatusReference { event_id, marker });
}
"p" => {
if let Some(value) = args.first() {
bundle.mentions.push(PublicKey::parse(value)?);
}
}
"a" => {
if let Some(value) = args.first() {
bundle.repo = Some(Coordinate::parse(value)?);
}
}
"r" => {
if let Some(value) = args.first() {
bundle.repo_euc.get_or_insert_with(|| value.clone());
}
}
"q" => {
if let Some(value) = args.first() {
bundle.quoted_patches.push(EventId::parse(value)?);
}
}
tag_names::MERGE_COMMIT => bundle.merge_commit = args.first().cloned(),
tag_names::APPLIED_AS_COMMITS => {
for v in args {
bundle.applied_as_commits.push(v.clone());
}
}
_ => {}
}
}
Ok(bundle)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct GraspServerList {
pub servers: Vec<RelayUrl>,
}
impl GraspServerList {
#[must_use]
pub fn new<I>(servers: I) -> Self
where
I: IntoIterator<Item = RelayUrl>,
{
Self {
servers: servers.into_iter().collect(),
}
}
#[must_use]
pub fn to_tags(&self) -> Vec<Tag> {
self.servers
.iter()
.map(|server| {
Tag::with(
&TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::G)),
[server.as_str().to_owned()],
)
})
.collect()
}
pub fn from_event(event: &Event) -> Result<Self, Nip34Error> {
if event.kind != KIND_GRASP_LIST {
return Err(Nip34Error::WrongKind {
expected: KIND_GRASP_LIST,
got: event.kind,
});
}
let mut servers: Vec<RelayUrl> = Vec::new();
for tag in &event.tags {
if tag.name() != "g" {
continue;
}
if let Some(value) = tag.values().get(1) {
servers.push(RelayUrl::parse(value)?);
}
}
Ok(Self { servers })
}
}
impl EventBuilder {
#[must_use]
pub fn git_repository(repo: &Repository) -> Self {
let mut builder = Self::new(KIND_REPO, "");
for tag in repo.to_tags() {
builder = builder.tag(tag);
}
builder
}
#[must_use]
pub fn git_repository_state(state: &RepositoryState) -> Self {
let mut builder = Self::new(KIND_REPO_STATE, "");
for tag in state.to_tags() {
builder = builder.tag(tag);
}
builder
}
#[must_use]
pub fn git_patch(patch: &Patch) -> Self {
let mut builder = Self::new(KIND_PATCH, patch.content.clone());
for tag in patch.to_tags() {
builder = builder.tag(tag);
}
builder
}
#[must_use]
pub fn git_pull_request(pr: &PullRequest) -> Self {
let mut builder = Self::new(KIND_PULL_REQUEST, pr.content.clone());
for tag in pr.to_tags() {
builder = builder.tag(tag);
}
builder
}
#[must_use]
pub fn git_pull_request_update(update: &PullRequestUpdate) -> Self {
let mut builder = Self::new(KIND_PULL_REQUEST_UPDATE, update.content.clone());
for tag in update.to_tags() {
builder = builder.tag(tag);
}
builder
}
#[must_use]
pub fn git_issue(issue: &Issue) -> Self {
let mut builder = Self::new(KIND_ISSUE, issue.content.clone());
for tag in issue.to_tags() {
builder = builder.tag(tag);
}
builder
}
#[must_use]
pub fn git_status(event: &StatusEvent) -> Self {
let mut builder = Self::new(event.status.to_kind(), event.content.clone());
for tag in event.to_tags() {
builder = builder.tag(tag);
}
builder
}
#[must_use]
pub fn git_grasp_servers(list: &GraspServerList) -> Self {
let mut builder = Self::new(KIND_GRASP_LIST, "");
for tag in list.to_tags() {
builder = builder.tag(tag);
}
builder
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Keys;
fn keys() -> Keys {
Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
}
fn other_keys() -> Keys {
Keys::parse("0000000000000000000000000000000000000000000000000000000000000005").unwrap()
}
fn repo_coord() -> Coordinate {
Coordinate::new(KIND_REPO, *keys().public_key(), "ngit".to_owned())
}
#[test]
fn repository_round_trips_through_event() {
let repo = Repository {
identifier: "ngit".to_owned(),
name: Some("ngit".to_owned()),
description: Some("Nostr git-helper".to_owned()),
web: vec!["https://ngit.dev".to_owned()],
clone: vec!["https://github.com/x/ngit.git".to_owned()],
relays: vec![RelayUrl::parse("wss://relay.ngit.dev").unwrap()],
earliest_unique_commit: Some("deadbeef".to_owned()),
maintainers: vec![*other_keys().public_key()],
hashtags: vec!["git".to_owned(), "tooling".to_owned()],
personal_fork: false,
};
let event = EventBuilder::git_repository(&repo)
.sign_with_keys(&keys())
.unwrap();
assert_eq!(event.kind, KIND_REPO);
let recovered = Repository::from_event(&event).unwrap();
assert_eq!(recovered, repo);
}
#[test]
fn repository_personal_fork_flag_round_trips() {
let repo = Repository {
personal_fork: true,
..Repository::new("fork".to_owned())
};
let event = EventBuilder::git_repository(&repo)
.sign_with_keys(&keys())
.unwrap();
let recovered = Repository::from_event(&event).unwrap();
assert!(recovered.personal_fork);
}
#[test]
fn repository_from_event_requires_identifier() {
let event = EventBuilder::new(KIND_REPO, "")
.sign_with_keys(&keys())
.unwrap();
assert!(matches!(
Repository::from_event(&event),
Err(Nip34Error::MissingIdentifier { .. }),
));
}
#[test]
fn repository_state_round_trips_through_event() {
let state = RepositoryState {
identifier: "ngit".to_owned(),
refs: vec![
GitRef::new("refs/heads/main", "aabb"),
GitRef::new("refs/tags/v1.0.0", "ccdd"),
],
head: Some("ref: refs/heads/main".to_owned()),
};
let event = EventBuilder::git_repository_state(&state)
.sign_with_keys(&keys())
.unwrap();
assert_eq!(event.kind, KIND_REPO_STATE);
let recovered = RepositoryState::from_event(&event).unwrap();
assert_eq!(recovered, state);
}
#[test]
fn patch_round_trips_through_event() {
let patch = Patch {
content: "From <git format-patch output>\n".to_owned(),
repo: repo_coord(),
repo_euc: Some("deadbeef".to_owned()),
mentions: vec![*other_keys().public_key()],
root: true,
root_revision: false,
commit: Some("abc123".to_owned()),
parent_commit: Some("def456".to_owned()),
commit_pgp_sig: Some("-----BEGIN PGP SIGNATURE-----\n...".to_owned()),
committer: Some(Committer {
name: "Satoshi".to_owned(),
email: "satoshi@example".to_owned(),
timestamp: "1700000000".to_owned(),
offset: "+0000".to_owned(),
}),
};
let event = EventBuilder::git_patch(&patch)
.sign_with_keys(&keys())
.unwrap();
assert_eq!(event.kind, KIND_PATCH);
let recovered = Patch::from_event(&event).unwrap();
assert_eq!(recovered, patch);
}
#[test]
fn patch_from_event_requires_repository() {
let event = EventBuilder::new(KIND_PATCH, "")
.sign_with_keys(&keys())
.unwrap();
assert!(matches!(
Patch::from_event(&event),
Err(Nip34Error::MissingRepository { .. }),
));
}
#[test]
fn pull_request_round_trips_through_event() {
let pr = PullRequest {
content: "Adds awesome feature".to_owned(),
repo: repo_coord(),
repo_euc: Some("deadbeef".to_owned()),
mentions: vec![*other_keys().public_key()],
subject: Some("feat: awesome".to_owned()),
hashtags: vec!["feature".to_owned()],
tip_commit: Some("abc123".to_owned()),
clone: vec!["https://github.com/x/ngit.git".to_owned()],
branch_name: Some("feat/awesome".to_owned()),
revises_event: Some(EventId::from_byte_array([0xaa; 32])),
merge_base: Some("base999".to_owned()),
};
let event = EventBuilder::git_pull_request(&pr)
.sign_with_keys(&keys())
.unwrap();
assert_eq!(event.kind, KIND_PULL_REQUEST);
let recovered = PullRequest::from_event(&event).unwrap();
assert_eq!(recovered, pr);
}
#[test]
fn pull_request_update_round_trips_through_event() {
let update = PullRequestUpdate {
content: "Updated tip".to_owned(),
repo: repo_coord(),
repo_euc: None,
mentions: vec![],
root_event: EventId::from_byte_array([0xbb; 32]),
root_pubkey: *other_keys().public_key(),
tip_commit: Some("def456".to_owned()),
clone: vec!["https://github.com/x/ngit.git".to_owned()],
merge_base: Some("base000".to_owned()),
};
let event = EventBuilder::git_pull_request_update(&update)
.sign_with_keys(&keys())
.unwrap();
assert_eq!(event.kind, KIND_PULL_REQUEST_UPDATE);
let recovered = PullRequestUpdate::from_event(&event).unwrap();
assert_eq!(recovered, update);
}
#[test]
fn issue_round_trips_through_event() {
let issue = Issue {
content: "Bug body in Markdown".to_owned(),
repo: repo_coord(),
mentions: vec![*other_keys().public_key()],
subject: Some("Crash on startup".to_owned()),
hashtags: vec!["bug".to_owned(), "priority-high".to_owned()],
};
let event = EventBuilder::git_issue(&issue)
.sign_with_keys(&keys())
.unwrap();
assert_eq!(event.kind, KIND_ISSUE);
let recovered = Issue::from_event(&event).unwrap();
assert_eq!(recovered, issue);
}
#[test]
fn status_round_trips_for_each_kind() {
for status in [
GitStatus::Open,
GitStatus::Applied,
GitStatus::Closed,
GitStatus::Draft,
] {
let event = StatusEvent {
content: format!("status: {status:?}"),
status,
references: vec![StatusReference {
event_id: EventId::from_byte_array([0xcc; 32]),
marker: Some("root".to_owned()),
}],
mentions: vec![*other_keys().public_key()],
repo: Some(repo_coord()),
repo_euc: Some("deadbeef".to_owned()),
quoted_patches: vec![],
merge_commit: None,
applied_as_commits: vec![],
};
let signed = EventBuilder::git_status(&event)
.sign_with_keys(&keys())
.unwrap();
assert_eq!(signed.kind, status.to_kind());
let recovered = StatusEvent::from_event(&signed).unwrap();
assert_eq!(recovered, event);
}
}
#[test]
fn status_applied_carries_merge_metadata() {
let event = StatusEvent {
content: String::new(),
status: GitStatus::Applied,
references: vec![],
mentions: vec![],
repo: None,
repo_euc: None,
quoted_patches: vec![EventId::from_byte_array([0xdd; 32])],
merge_commit: Some("mergecommithex".to_owned()),
applied_as_commits: vec!["a1".to_owned(), "a2".to_owned()],
};
let signed = EventBuilder::git_status(&event)
.sign_with_keys(&keys())
.unwrap();
let recovered = StatusEvent::from_event(&signed).unwrap();
assert_eq!(recovered.merge_commit.as_deref(), Some("mergecommithex"));
assert_eq!(recovered.applied_as_commits, event.applied_as_commits);
assert_eq!(recovered.quoted_patches, event.quoted_patches);
}
#[test]
fn status_from_event_rejects_kind_outside_range() {
let event = EventBuilder::text_note("not a status")
.sign_with_keys(&keys())
.unwrap();
assert!(matches!(
StatusEvent::from_event(&event),
Err(Nip34Error::InvalidStatusKind(_)),
));
}
#[test]
fn git_status_kind_helpers_round_trip() {
for status in [
GitStatus::Open,
GitStatus::Applied,
GitStatus::Closed,
GitStatus::Draft,
] {
assert_eq!(GitStatus::from_kind(status.to_kind()).unwrap(), status);
}
assert!(matches!(
GitStatus::from_kind(Kind::TEXT_NOTE),
Err(Nip34Error::InvalidStatusKind(_)),
));
}
#[test]
fn grasp_server_list_round_trips_through_event() {
let list = GraspServerList::new([
RelayUrl::parse("wss://grasp.one.example").unwrap(),
RelayUrl::parse("wss://grasp.two.example").unwrap(),
]);
let event = EventBuilder::git_grasp_servers(&list)
.sign_with_keys(&keys())
.unwrap();
assert_eq!(event.kind, KIND_GRASP_LIST);
let recovered = GraspServerList::from_event(&event).unwrap();
assert_eq!(recovered, list);
}
#[test]
fn grasp_server_list_from_event_rejects_wrong_kind() {
let event = EventBuilder::text_note("nope")
.sign_with_keys(&keys())
.unwrap();
assert!(matches!(
GraspServerList::from_event(&event),
Err(Nip34Error::WrongKind { .. }),
));
}
}