From 2f188e203ca36a20b078ede06916df7f6f1252d8 Mon Sep 17 00:00:00 2001 From: naliyi <154817482+naliyi@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:05:47 +0000 Subject: [PATCH 1/2] feat(nip34,nip22): add git-over-nostr collaboration (NIP-34) and NIP-22 comments Adds nip34: repository announcements (30617) and state (30618), patches (1617), pull requests (1618) and updates (1619), issues (1621), status events (1630-1633) with ResolveStatus/ResolveRevisionStatus helpers, user grasp lists (10317), and nostr:// clone URL parsing/building. Replies follow NIP-22's kind:1111 comment shape, added here as its own nip22 package since other NIPs may want it too. Both packages follow this SDK's existing split: a dependency-free protocol library (New*/Parse*/Validate* per event kind, sentinel errors) plus a relayreg subpackage that registers relay-side support and validators. There's no nip34/client subpackage -- unlike Blossom/ecash, NIP-34 has no second transport to dial out to, so publishing/subscribing goes through the existing generic nip01/relay-client plumbing. Also adds utils.FormatATag, the build-side counterpart to the existing ParseATag, needed by both new packages to construct "a" tag addresses. --- CHANGELOG.md | 12 ++ README.md | 74 +++++++- nip22/nip22.go | 357 ++++++++++++++++++++++++++++++++++++ nip22/nip22_test.go | 201 +++++++++++++++++++++ nip22/relayreg/relayreg.go | 26 +++ nip34/clone_url.go | 90 +++++++++ nip34/clone_url_test.go | 99 ++++++++++ nip34/graspset.go | 53 ++++++ nip34/graspset_test.go | 45 +++++ nip34/issue.go | 111 ++++++++++++ nip34/issue_test.go | 60 ++++++ nip34/nip34.go | 64 +++++++ nip34/nip34_test.go | 44 +++++ nip34/patch.go | 245 +++++++++++++++++++++++++ nip34/patch_test.go | 136 ++++++++++++++ nip34/pullrequest.go | 361 +++++++++++++++++++++++++++++++++++++ nip34/pullrequest_test.go | 110 +++++++++++ nip34/relayreg/relayreg.go | 59 ++++++ nip34/reply.go | 92 ++++++++++ nip34/reply_test.go | 106 +++++++++++ nip34/repository.go | 310 +++++++++++++++++++++++++++++++ nip34/repository_test.go | 140 ++++++++++++++ nip34/status.go | 291 ++++++++++++++++++++++++++++++ nip34/status_test.go | 135 ++++++++++++++ utils/event.go | 17 ++ 25 files changed, 3237 insertions(+), 1 deletion(-) create mode 100644 nip22/nip22.go create mode 100644 nip22/nip22_test.go create mode 100644 nip22/relayreg/relayreg.go create mode 100644 nip34/clone_url.go create mode 100644 nip34/clone_url_test.go create mode 100644 nip34/graspset.go create mode 100644 nip34/graspset_test.go create mode 100644 nip34/issue.go create mode 100644 nip34/issue_test.go create mode 100644 nip34/nip34.go create mode 100644 nip34/nip34_test.go create mode 100644 nip34/patch.go create mode 100644 nip34/patch_test.go create mode 100644 nip34/pullrequest.go create mode 100644 nip34/pullrequest_test.go create mode 100644 nip34/relayreg/relayreg.go create mode 100644 nip34/reply.go create mode 100644 nip34/reply_test.go create mode 100644 nip34/repository.go create mode 100644 nip34/repository_test.go create mode 100644 nip34/status.go create mode 100644 nip34/status_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 667f31e..4715126 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,18 @@ ### Added +- `nip34`: NIP-34 (git stuff) — repository announcements (`kind:30617`) and + state (`kind:30618`), patches (`kind:1617`), pull requests (`kind:1618`) + and PR updates (`kind:1619`), issues (`kind:1621`), replies (built on the + new `nip22` package), status events (`kind:1630`-`1633`) with + `ResolveStatus`/`ResolveRevisionStatus` helpers implementing the spec's + status-resolution rules, user grasp lists (`kind:10317`), and `nostr://` + clone URL parsing/building. `nip34/relayreg` declares relay-side support. +- `nip22`: NIP-22 (Comment) — the generic `kind:1111` threading note that + NIP-34 replies build on, scoped to a root event, addressable event, or + NIP-73 external identifier. `nip22/relayreg` declares relay-side support. +- `utils.FormatATag`: renders a `kind:pubkey:d-value` "a" tag string, the + build-side counterpart to the existing `utils.ParseATag`. - `nipcash.ResolvedConnectionKey`: builds a `connection_key`-mode Recipient/ Target from a `nipIC.ConnectionKey` the caller already has (e.g. decoded from an `nconnection1...` string via `nipIC.DecodeNConnection`), without diff --git a/README.md b/README.md index 47a2990..f0e8976 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,11 @@ go get github.com/ohstr/nmilat - **[`nip16`](https://github.com/nostr-protocol/nips/blob/master/16.md)** — Event treatment (regular/replaceable/ephemeral kinds) - **[`nip17`](https://github.com/nostr-protocol/nips/blob/master/17.md), [`nip59`](https://github.com/nostr-protocol/nips/blob/master/59.md)** — Private direct messages, gift wraps - **[`nip19`](https://github.com/nostr-protocol/nips/blob/master/19.md)** — Bech32-encoded entities: npub, nsec, note, plus the TLV-based nprofile, nevent, and naddr +- **[`nip22`](https://github.com/nostr-protocol/nips/blob/master/22.md)** — Comment: generic kind:1111 threading note scoped to a root event, address, or NIP-73 external identifier - **[`nip23`](https://github.com/nostr-protocol/nips/blob/master/23.md)** — Long-form content - **[`nip26`](https://github.com/nostr-protocol/nips/blob/master/26.md)** — Event delegation - **[`nip33`](https://github.com/nostr-protocol/nips/blob/master/33.md)** — Parameterized replaceable events (now called addressable events) +- **[`nip34`](https://github.com/nostr-protocol/nips/blob/master/34.md)** — git stuff: repository announcements/state, patches, pull requests, issues, replies, and status over Nostr - **[`nip40`](https://github.com/nostr-protocol/nips/blob/master/40.md)** — Event expiration - **[`nip42`](https://github.com/nostr-protocol/nips/blob/master/42.md), [`nip98`](https://github.com/nostr-protocol/nips/blob/master/98.md)** — Relay/HTTP authentication - **[`nip43`](https://github.com/nostr-protocol/nips/blob/master/43.md)** — Relay access metadata and requests @@ -73,7 +75,7 @@ go get github.com/ohstr/nmilat - **`wire`** — Relay wire-protocol packet types - **`utils`** — Shared event/key/logging helpers -NIP packages with relay-side concerns (NIP-47/48/57/65/88/90/B0/B7) stay +NIP packages with relay-side concerns (NIP-22/34/47/48/57/65/88/90/B0/B7) stay dependency-free on their own; blank-import their `relayreg` subpackage to declare relay support, e.g. `import _ "github.com/ohstr/nmilat/nip57/relayreg"`. See "Run a relay" below. @@ -714,6 +716,76 @@ naddr, err := nip19.EncodeAddr(nip19.EntityPointer{ addr, err := nip19.DecodeAddr(naddr) // *nip19.EntityPointer ``` +### Announce a git repository and open an issue (NIP-34) + +Publish a repository announcement, then a patch and an issue that reference +it by its address; replies to either follow NIP-22's `kind:1111` comment +shape via the `nip34.NewReply`/`nip34.ParseReply` convenience layer: + +```go +package main + +import ( + "fmt" + + "github.com/ohstr/nmilat/nip34" + "github.com/ohstr/nmilat/utils" +) + +func main() { + repoEv, err := nip34.NewRepositoryAnnouncement(nip34.RepositoryAnnouncementParams{ + Pubkey: pubkeyHex, + Identifier: "ngit", + Name: "ngit", + Description: "git over nostr", + Clone: []string{"https://github.com/example/ngit.git"}, + Relays: []string{"wss://relay.ngit.dev"}, + Maintainers: []string{pubkeyHex}, + }) + if err != nil { + panic(err) + } + if err := repoEv.Sign(privateKeyHex); err != nil { + panic(err) + } + + repoAddr, _ := utils.FormatATag(nip34.KindRepositoryAnnouncement, pubkeyHex, "ngit") + + issueEv, err := nip34.NewIssue(nip34.IssueParams{ + Pubkey: otherPubkeyHex, + Content: "The build is broken on main.", + RepoAddress: repoAddr, + RepositoryOwner: pubkeyHex, + Subject: "Build broken", + Labels: []string{"bug"}, + }) + if err != nil { + panic(err) + } + if err := issueEv.Sign(otherPrivateKeyHex); err != nil { + panic(err) + } + + replyEv, err := nip34.NewReply(nip34.ReplyParams{ + Pubkey: pubkeyHex, + Content: "thanks for reporting, looking into it", + RootEvent: issueEv, + }) + if err != nil { + panic(err) + } + if err := replyEv.Sign(privateKeyHex); err != nil { + panic(err) + } + fmt.Println("reply kind:", replyEv.Kind) // 1111 +} +``` + +Status events (`nip34.NewStatus`, kinds `1630`-`1633`) close the loop, and +`nip34.ResolveStatus`/`nip34.ResolveRevisionStatus` implement the spec's +"latest status from the root author or a maintainer wins" resolution rule +for a client that has fetched every status event for a thread. + ## Development Uses [`just`](https://github.com/casey/just) for build automation: diff --git a/nip22/nip22.go b/nip22/nip22.go new file mode 100644 index 0000000..df06a36 --- /dev/null +++ b/nip22/nip22.go @@ -0,0 +1,357 @@ +// Package nip22 implements NIP-22: Comment, a generic threading note +// (kind:1111) scoped to a root event, addressable event, or NIP-73 +// external identifier, with a separate pointer to the specific parent item +// being replied to. +// +// This package is a pure protocol library: event/type construction, +// parsing, and validation, with no relay or network dependency of its own. +// For declaring NIP-22 support to nmilat's relay engine, see the +// nip22/relayreg subpackage. See the nip34 package for a git-specific +// convenience layer built on top of this one (replies to issues, patches, +// and pull requests). +package nip22 + +import ( + "errors" + "fmt" + + "github.com/ohstr/nmilat/nip01" + "github.com/ohstr/nmilat/utils" +) + +const KindComment = 1111 + +// Failure modes for the Parse*/Validate*/New* functions below, for callers +// that need to distinguish them (e.g. via errors.Is) rather than match on +// message text. +var ( + ErrWrongKind = errors.New("nip22: wrong kind") + ErrInvalidSignature = errors.New("nip22: invalid signature") + ErrMissingRootScope = errors.New("nip22: missing root scope (A/E/I) tag") + ErrMissingRootKind = errors.New("nip22: missing K tag") + ErrMissingParentScope = errors.New("nip22: missing parent scope (a/e/i) tag") + ErrMissingParentKind = errors.New("nip22: missing k tag") + ErrInvalidPointerTag = errors.New("nip22: invalid scope pointer tag") + ErrInvalidPubkeyTag = errors.New("nip22: invalid pubkey tag") + ErrMultipleRootScopes = errors.New("nip22: more than one root scope (A/E/I) tag") + ErrMultipleParentScopes = errors.New("nip22: more than one parent scope (a/e/i) tag") +) + +// PointerType identifies which kind of value a Pointer holds: a +// parameterized-replaceable event address (nip33-style "a"/"A" tags), a +// regular event id ("e"/"E" tags), or a NIP-73 external identifier +// ("i"/"I" tags). +type PointerType int + +const ( + PointerEvent PointerType = iota + PointerAddress + PointerExternal +) + +func (t PointerType) lowerTag() string { + switch t { + case PointerAddress: + return "a" + case PointerExternal: + return "i" + default: + return "e" + } +} + +func (t PointerType) upperTag() string { + switch t { + case PointerAddress: + return "A" + case PointerExternal: + return "I" + default: + return "E" + } +} + +// Pointer is a NIP-22 scope reference: an event id, an addressable-event +// address, or a NIP-73 external identifier, plus an optional relay/webpage +// hint. AuthorPubkey is only meaningful for PointerEvent ("e"/"E") tags, +// which the spec permits to carry the referenced event's author as a 4th +// element -- PointerAddress values already embed a pubkey, and +// PointerExternal values have no author. +type Pointer struct { + Type PointerType + Value string + RelayHint string + AuthorPubkey string +} + +// Scope is one side (root or parent) of a Comment: what item it targets, +// that item's kind, and (when known) that item's author. Kind is a plain +// string rather than an int because NIP-73 external items use non-numeric +// kinds (e.g. "web", "podcast:item:guid"). +type Scope struct { + Pointer Pointer + Kind string + AuthorPubkey string + AuthorRelay string +} + +// Comment is a parsed kind:1111 comment event. +type Comment struct { + *nip01.Event + Root Scope + Parent Scope +} + +// ParseComment parses and structurally validates a kind:1111 comment +// event. +func ParseComment(event *nip01.Event) (*Comment, error) { + if event.Kind != KindComment { + return nil, fmt.Errorf("%w: got %d, want %d", ErrWrongKind, event.Kind, KindComment) + } + + c := &Comment{Event: event} + var haveRootPointer, haveParentPointer bool + + for _, tag := range event.Tags { + if len(tag) < 2 { + continue + } + switch tag[0] { + case "A", "E", "I": + if haveRootPointer { + return nil, ErrMultipleRootScopes + } + p, err := parsePointerTag(tag) + if err != nil { + return nil, err + } + c.Root.Pointer = p + haveRootPointer = true + case "a", "e", "i": + if haveParentPointer { + return nil, ErrMultipleParentScopes + } + p, err := parsePointerTag(tag) + if err != nil { + return nil, err + } + c.Parent.Pointer = p + haveParentPointer = true + case "K": + c.Root.Kind = tag[1] + case "k": + c.Parent.Kind = tag[1] + case "P": + c.Root.AuthorPubkey = tag[1] + if len(tag) > 2 { + c.Root.AuthorRelay = tag[2] + } + case "p": + c.Parent.AuthorPubkey = tag[1] + if len(tag) > 2 { + c.Parent.AuthorRelay = tag[2] + } + } + } + + if !haveRootPointer { + return nil, ErrMissingRootScope + } + if !haveParentPointer { + return nil, ErrMissingParentScope + } + if c.Root.Kind == "" { + return nil, ErrMissingRootKind + } + if c.Parent.Kind == "" { + return nil, ErrMissingParentKind + } + if c.Root.AuthorPubkey != "" { + if err := utils.Validate32Key(c.Root.AuthorPubkey); err != nil { + return nil, fmt.Errorf("%w: root author %q: %w", ErrInvalidPubkeyTag, c.Root.AuthorPubkey, err) + } + } + if c.Parent.AuthorPubkey != "" { + if err := utils.Validate32Key(c.Parent.AuthorPubkey); err != nil { + return nil, fmt.Errorf("%w: parent author %q: %w", ErrInvalidPubkeyTag, c.Parent.AuthorPubkey, err) + } + } + + return c, nil +} + +func parsePointerTag(tag []string) (Pointer, error) { + var t PointerType + switch tag[0] { + case "A", "a": + t = PointerAddress + case "I", "i": + t = PointerExternal + default: // "E", "e" + t = PointerEvent + } + + p := Pointer{Type: t, Value: tag[1]} + if len(tag) > 2 { + p.RelayHint = tag[2] + } + + switch t { + case PointerEvent: + if err := utils.Validate32Key(p.Value); err != nil { + return Pointer{}, fmt.Errorf("%w: %q %q: %w", ErrInvalidPointerTag, tag[0], p.Value, err) + } + if len(tag) > 3 { + p.AuthorPubkey = tag[3] + if err := utils.Validate32Key(p.AuthorPubkey); err != nil { + return Pointer{}, fmt.Errorf("%w: %q author %q: %w", ErrInvalidPointerTag, tag[0], p.AuthorPubkey, err) + } + } + case PointerAddress: + if _, _, _, err := utils.ParseATag(p.Value); err != nil { + return Pointer{}, fmt.Errorf("%w: %q %q: %w", ErrInvalidPointerTag, tag[0], p.Value, err) + } + } + + return p, nil +} + +// ValidateComment checks the signature and structure of a comment event. +func ValidateComment(event *nip01.Event) error { + if err := event.Verify(); err != nil { + return fmt.Errorf("%w: %w", ErrInvalidSignature, err) + } + _, err := ParseComment(event) + return err +} + +// PointerParams describes a Pointer to build. Type and Value are required; +// RelayHint and AuthorPubkey (meaningful only for PointerEvent) are +// optional. +type PointerParams struct { + Type PointerType + Value string + RelayHint string + AuthorPubkey string +} + +// ScopeParams describes a Scope to build. Kind is required; Pointer.Value +// is required (root or parent, per which ScopeParams this is). +// AuthorPubkey/AuthorRelay are optional. +type ScopeParams struct { + Pointer PointerParams + Kind string + AuthorPubkey string + AuthorRelay string +} + +// CommentParams describes a comment event to build. Pubkey, Root, and +// Parent are required (pass Parent equal to Root for a top-level comment); +// Content is optional. +type CommentParams struct { + Pubkey string + Content string + Root ScopeParams + Parent ScopeParams +} + +// NewComment builds an unsigned kind:1111 comment event. Caller must sign +// it. +func NewComment(p CommentParams) (*nip01.Event, error) { + if p.Root.Kind == "" { + return nil, ErrMissingRootKind + } + if p.Parent.Kind == "" { + return nil, ErrMissingParentKind + } + + rootTag, err := buildPointerTag(p.Root.Pointer, true) + if err != nil { + return nil, err + } + parentTag, err := buildPointerTag(p.Parent.Pointer, false) + if err != nil { + return nil, err + } + + tags := [][]string{rootTag, {"K", p.Root.Kind}} + if p.Root.AuthorPubkey != "" { + if err := utils.Validate32Key(p.Root.AuthorPubkey); err != nil { + return nil, fmt.Errorf("%w: root author %q: %w", ErrInvalidPubkeyTag, p.Root.AuthorPubkey, err) + } + pt := []string{"P", p.Root.AuthorPubkey} + if p.Root.AuthorRelay != "" { + pt = append(pt, p.Root.AuthorRelay) + } + tags = append(tags, pt) + } + + tags = append(tags, parentTag, []string{"k", p.Parent.Kind}) + if p.Parent.AuthorPubkey != "" { + if err := utils.Validate32Key(p.Parent.AuthorPubkey); err != nil { + return nil, fmt.Errorf("%w: parent author %q: %w", ErrInvalidPubkeyTag, p.Parent.AuthorPubkey, err) + } + pt := []string{"p", p.Parent.AuthorPubkey} + if p.Parent.AuthorRelay != "" { + pt = append(pt, p.Parent.AuthorRelay) + } + tags = append(tags, pt) + } + + return nip01.NewUnsignedEvent(KindComment, p.Pubkey, p.Content, tags...), nil +} + +func buildPointerTag(p PointerParams, root bool) ([]string, error) { + if p.Value == "" { + if root { + return nil, ErrMissingRootScope + } + return nil, ErrMissingParentScope + } + + switch p.Type { + case PointerAddress: + if _, _, _, err := utils.ParseATag(p.Value); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidPointerTag, p.Value, err) + } + case PointerEvent: + if err := utils.Validate32Key(p.Value); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidPointerTag, p.Value, err) + } + } + + var name string + if root { + name = p.Type.upperTag() + } else { + name = p.Type.lowerTag() + } + tag := []string{name, p.Value} + + hasAuthor := p.Type == PointerEvent && p.AuthorPubkey != "" + if p.RelayHint != "" || hasAuthor { + tag = append(tag, p.RelayHint) + } + if hasAuthor { + if err := utils.Validate32Key(p.AuthorPubkey); err != nil { + return nil, fmt.Errorf("%w: author %q: %w", ErrInvalidPointerTag, p.AuthorPubkey, err) + } + tag = append(tag, p.AuthorPubkey) + } + return tag, nil +} + +// QuoteTag builds a ["q", ...] tag for citing an event or address in +// Content via NIP-21, per NIP-22's "q tags MAY be used when citing events" +// note. pubkey is only meaningful when idOrAddress is a regular event id; +// pass "" for an addressable-event address. +func QuoteTag(idOrAddress, relayURL, pubkey string) []string { + tag := []string{"q", idOrAddress} + if relayURL != "" || pubkey != "" { + tag = append(tag, relayURL) + } + if pubkey != "" { + tag = append(tag, pubkey) + } + return tag +} diff --git a/nip22/nip22_test.go b/nip22/nip22_test.go new file mode 100644 index 0000000..93e8011 --- /dev/null +++ b/nip22/nip22_test.go @@ -0,0 +1,201 @@ +package nip22 + +import ( + "errors" + "strings" + "testing" + + "github.com/ohstr/nmilat/nip01" +) + +const testPrivKey = "48939ec93986b59b58d7206887b42ff74d99dd3258782e2fdfd720eb74d547a5" + +func signed(t *testing.T, ev *nip01.Event) *nip01.Event { + t.Helper() + if err := ev.Sign(testPrivKey); err != nil { + t.Fatalf("Sign() error = %v", err) + } + return ev +} + +var ( + rootPubkey = strings.Repeat("a", 64) + parentPubkey = strings.Repeat("b", 64) + rootEventID = strings.Repeat("c", 64) +) + +func TestNewCommentAndParse_TopLevelOnAddress(t *testing.T) { + addr := "30023:" + rootPubkey + ":f9347ca7" + + ev, err := NewComment(CommentParams{ + Content: "Great blog post!", + Root: ScopeParams{ + Pointer: PointerParams{Type: PointerAddress, Value: addr, RelayHint: "wss://example.relay"}, + Kind: "30023", + AuthorPubkey: rootPubkey, + }, + Parent: ScopeParams{ + Pointer: PointerParams{Type: PointerAddress, Value: addr, RelayHint: "wss://example.relay"}, + Kind: "30023", + }, + }) + if err != nil { + t.Fatalf("NewComment() error = %v", err) + } + ev = signed(t, ev) + + c, err := ParseComment(ev) + if err != nil { + t.Fatalf("ParseComment() error = %v", err) + } + if c.Root.Pointer.Type != PointerAddress || c.Root.Pointer.Value != addr { + t.Errorf("Root.Pointer = %+v", c.Root.Pointer) + } + if c.Root.Kind != "30023" { + t.Errorf("Root.Kind = %q", c.Root.Kind) + } + if c.Root.AuthorPubkey != rootPubkey { + t.Errorf("Root.AuthorPubkey = %q", c.Root.AuthorPubkey) + } + if c.Parent.Pointer.Value != addr { + t.Errorf("Parent.Pointer.Value = %q", c.Parent.Pointer.Value) + } + + if err := ValidateComment(ev); err != nil { + t.Errorf("ValidateComment() error = %v", err) + } +} + +func TestNewCommentAndParse_ReplyOnEvent(t *testing.T) { + ev, err := NewComment(CommentParams{ + Content: "This is a reply", + Root: ScopeParams{ + Pointer: PointerParams{Type: PointerEvent, Value: rootEventID, AuthorPubkey: rootPubkey}, + Kind: "1063", + }, + Parent: ScopeParams{ + Pointer: PointerParams{Type: PointerEvent, Value: rootEventID, AuthorPubkey: parentPubkey}, + Kind: "1111", + }, + }) + if err != nil { + t.Fatalf("NewComment() error = %v", err) + } + ev = signed(t, ev) + + c, err := ParseComment(ev) + if err != nil { + t.Fatalf("ParseComment() error = %v", err) + } + if c.Root.Pointer.Type != PointerEvent || c.Root.Pointer.AuthorPubkey != rootPubkey { + t.Errorf("Root.Pointer = %+v", c.Root.Pointer) + } + if c.Parent.Kind != "1111" || c.Parent.Pointer.AuthorPubkey != parentPubkey { + t.Errorf("Parent = %+v", c.Parent) + } +} + +func TestNewCommentAndParse_ExternalIdentifier(t *testing.T) { + ev, err := NewComment(CommentParams{ + Content: "Nice article!", + Root: ScopeParams{ + Pointer: PointerParams{Type: PointerExternal, Value: "https://abc.com/articles/1"}, + Kind: "web", + }, + Parent: ScopeParams{ + Pointer: PointerParams{Type: PointerExternal, Value: "https://abc.com/articles/1"}, + Kind: "web", + }, + }) + if err != nil { + t.Fatalf("NewComment() error = %v", err) + } + ev = signed(t, ev) + + c, err := ParseComment(ev) + if err != nil { + t.Fatalf("ParseComment() error = %v", err) + } + if c.Root.Pointer.Type != PointerExternal || c.Root.Pointer.Value != "https://abc.com/articles/1" { + t.Errorf("Root.Pointer = %+v", c.Root.Pointer) + } +} + +func TestParseCommentErrors(t *testing.T) { + tests := []struct { + name string + kind int + tags [][]string + wantErr error + }{ + {name: "wrong kind", kind: 1, tags: nil, wantErr: ErrWrongKind}, + { + name: "missing root kind", + kind: KindComment, + tags: [][]string{ + {"E", rootEventID}, + {"e", rootEventID}, + {"k", "1111"}, + }, + wantErr: ErrMissingRootKind, + }, + { + name: "missing parent scope", + kind: KindComment, + tags: [][]string{ + {"E", rootEventID}, + {"K", "1"}, + {"k", "1111"}, + }, + wantErr: ErrMissingParentScope, + }, + { + name: "two root scopes", + kind: KindComment, + tags: [][]string{ + {"E", rootEventID}, + {"I", "https://abc.com"}, + {"K", "1"}, + {"e", rootEventID}, + {"k", "1"}, + }, + wantErr: ErrMultipleRootScopes, + }, + { + name: "bad event id", + kind: KindComment, + tags: [][]string{ + {"E", "not-an-id"}, + {"K", "1"}, + {"e", "not-an-id"}, + {"k", "1"}, + }, + wantErr: ErrInvalidPointerTag, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ev := &nip01.Event{Kind: tt.kind, Tags: tt.tags} + _, err := ParseComment(ev) + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, tt.wantErr) { + t.Errorf("error = %v, want wrapping %v", err, tt.wantErr) + } + }) + } +} + +func TestQuoteTag(t *testing.T) { + tag := QuoteTag(rootEventID, "wss://example.relay", rootPubkey) + want := []string{"q", rootEventID, "wss://example.relay", rootPubkey} + if len(tag) != len(want) { + t.Fatalf("QuoteTag() = %v", tag) + } + for i := range want { + if tag[i] != want[i] { + t.Errorf("QuoteTag()[%d] = %q, want %q", i, tag[i], want[i]) + } + } +} diff --git a/nip22/relayreg/relayreg.go b/nip22/relayreg/relayreg.go new file mode 100644 index 0000000..6d313ab --- /dev/null +++ b/nip22/relayreg/relayreg.go @@ -0,0 +1,26 @@ +// Package relayreg declares NIP-22 support to a relay engine. Blank-import +// it from a relay-embedding binary that wants NIP-22 auto-declared in its +// NIP-11 document and kind:1111 comment events auto-validated: +// +// import _ "github.com/ohstr/nmilat/nip22/relayreg" +// +// nip22 itself has no dependency on relay, so pure clients that only +// build/parse comment events don't pay for relay's bbolt/websocket +// dependency. +package relayreg + +import ( + "context" + + "github.com/ohstr/nmilat/nip01" + "github.com/ohstr/nmilat/nip22" + "github.com/ohstr/nmilat/relay" +) + +func init() { + relay.RegisterNIP(22) + + relay.RegisterEventValidator(nip22.KindComment, func(_ context.Context, event *nip01.Event) error { + return nip22.ValidateComment(event) + }) +} diff --git a/nip34/clone_url.go b/nip34/clone_url.go new file mode 100644 index 0000000..372edf2 --- /dev/null +++ b/nip34/clone_url.go @@ -0,0 +1,90 @@ +package nip34 + +import ( + "errors" + "fmt" + "net/url" + "strings" + + "github.com/ohstr/nmilat/nip19" +) + +// ErrInvalidCloneURL is returned by ParseCloneURL for a malformed +// "nostr://" clone URL. +var ErrInvalidCloneURL = errors.New("nip34: invalid nostr:// clone url") + +const cloneURLScheme = "nostr://" + +// CloneURL is a parsed "nostr://" repository clone URL (see NIP-34's +// "Nostr Clone URL format"), understood by a git-remote-nostr helper. +type CloneURL struct { + // NAddr holds the raw naddr when the URL used the "nostr://" + // form. Empty for the "nostr:///..." forms, which + // identify the repository by owner + identifier instead. + NAddr string + // Owner is the npub or NIP-05 identifier from the + // "nostr:///..." forms. Empty when NAddr is set. + Owner string + // RelayHint is the optional relay URL segment. Its "wss://" scheme, + // if the URL omitted it for brevity, is not restored here. + RelayHint string + // Identifier is the repository announcement's "d" tag value. Empty + // when NAddr is set (the naddr already embeds it). + Identifier string +} + +// ParseCloneURL parses a "nostr://" clone URL into its components. +func ParseCloneURL(raw string) (*CloneURL, error) { + if !strings.HasPrefix(raw, cloneURLScheme) { + return nil, fmt.Errorf("%w: %q: missing %q scheme", ErrInvalidCloneURL, raw, cloneURLScheme) + } + + rest := strings.TrimPrefix(raw, cloneURLScheme) + parts := strings.Split(rest, "/") + for i, part := range parts { + decoded, err := url.PathUnescape(part) + if err != nil { + return nil, fmt.Errorf("%w: %q: bad percent-encoding in segment %q: %w", ErrInvalidCloneURL, raw, part, err) + } + parts[i] = decoded + } + + switch len(parts) { + case 1: + return &CloneURL{NAddr: parts[0]}, nil + case 2: + return &CloneURL{Owner: parts[0], Identifier: parts[1]}, nil + case 3: + return &CloneURL{Owner: parts[0], RelayHint: parts[1], Identifier: parts[2]}, nil + default: + return nil, fmt.Errorf("%w: %q: expected 1-3 path segments, got %d", ErrInvalidCloneURL, raw, len(parts)) + } +} + +// ResolveAddr decodes c.NAddr (set when the URL used the +// "nostr://" form) into its nip19.EntityPointer. Returns an error +// if this CloneURL instead used one of the "nostr:///..." +// forms (NAddr is empty). +func (c *CloneURL) ResolveAddr() (*nip19.EntityPointer, error) { + if c.NAddr == "" { + return nil, fmt.Errorf("%w: not an naddr-form clone url", ErrInvalidCloneURL) + } + return nip19.DecodeAddr(c.NAddr) +} + +// BuildCloneURLFromAddr builds the "nostr://" clone URL form from +// an already-encoded naddr string (see nip19.EncodeAddr). +func BuildCloneURLFromAddr(naddr string) string { + return cloneURLScheme + naddr +} + +// BuildCloneURL builds the "nostr:///[/]" +// clone URL form, percent-encoding relayHint and identifier per the spec. +// owner is an npub or NIP-05 identifier, used as-is; relayHint may be +// empty. +func BuildCloneURL(owner, relayHint, identifier string) string { + if relayHint == "" { + return fmt.Sprintf("%s%s/%s", cloneURLScheme, owner, url.PathEscape(identifier)) + } + return fmt.Sprintf("%s%s/%s/%s", cloneURLScheme, owner, url.PathEscape(relayHint), url.PathEscape(identifier)) +} diff --git a/nip34/clone_url_test.go b/nip34/clone_url_test.go new file mode 100644 index 0000000..895bf05 --- /dev/null +++ b/nip34/clone_url_test.go @@ -0,0 +1,99 @@ +package nip34 + +import ( + "errors" + "testing" + + "github.com/ohstr/nmilat/nip19" +) + +func TestParseCloneURL_NAddrForm(t *testing.T) { + c, err := ParseCloneURL("nostr://naddr1qexamplevalue") + if err != nil { + t.Fatalf("ParseCloneURL() error = %v", err) + } + if c.NAddr != "naddr1qexamplevalue" { + t.Errorf("NAddr = %q", c.NAddr) + } + if c.Owner != "" || c.Identifier != "" { + t.Errorf("expected empty Owner/Identifier, got %+v", c) + } +} + +func TestParseCloneURL_OwnerIdentifierForm(t *testing.T) { + c, err := ParseCloneURL("nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/my%20%F0%9F%9A%80%20repo") + if err != nil { + t.Fatalf("ParseCloneURL() error = %v", err) + } + if c.Owner != "npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr" { + t.Errorf("Owner = %q", c.Owner) + } + if c.Identifier != "my \U0001F680 repo" { + t.Errorf("Identifier = %q", c.Identifier) + } + if c.RelayHint != "" { + t.Errorf("RelayHint = %q, want empty", c.RelayHint) + } +} + +func TestParseCloneURL_OwnerRelayIdentifierForm(t *testing.T) { + c, err := ParseCloneURL("nostr://npub15qydau2hjma6ngxkl2cyar74wzyjshvl65za5k5rl69264ar2exs5cyejr/relay.ngit.dev/ngit") + if err != nil { + t.Fatalf("ParseCloneURL() error = %v", err) + } + if c.RelayHint != "relay.ngit.dev" || c.Identifier != "ngit" { + t.Errorf("RelayHint/Identifier = %q/%q", c.RelayHint, c.Identifier) + } +} + +func TestParseCloneURL_Errors(t *testing.T) { + if _, err := ParseCloneURL("https://example.com"); !errors.Is(err, ErrInvalidCloneURL) { + t.Errorf("missing scheme: error = %v, want ErrInvalidCloneURL", err) + } + if _, err := ParseCloneURL("nostr://a/b/c/d"); !errors.Is(err, ErrInvalidCloneURL) { + t.Errorf("too many segments: error = %v, want ErrInvalidCloneURL", err) + } +} + +func TestBuildCloneURL_RoundTrip(t *testing.T) { + raw := BuildCloneURL("danconwaydev.com", "ws://localhost:7334", "my-local-only-repo") + c, err := ParseCloneURL(raw) + if err != nil { + t.Fatalf("ParseCloneURL() error = %v", err) + } + if c.Owner != "danconwaydev.com" || c.RelayHint != "ws://localhost:7334" || c.Identifier != "my-local-only-repo" { + t.Errorf("round trip = %+v", c) + } +} + +func TestCloneURL_ResolveAddr(t *testing.T) { + naddr, err := nip19.EncodeAddr(nip19.EntityPointer{ + Identifier: "ngit", + PublicKey: ownerPubkey, + Kind: KindRepositoryAnnouncement, + Relays: []string{"wss://relay.ngit.dev"}, + }) + if err != nil { + t.Fatalf("nip19.EncodeAddr() error = %v", err) + } + + c, err := ParseCloneURL(BuildCloneURLFromAddr(naddr)) + if err != nil { + t.Fatalf("ParseCloneURL() error = %v", err) + } + + p, err := c.ResolveAddr() + if err != nil { + t.Fatalf("ResolveAddr() error = %v", err) + } + if p.Identifier != "ngit" || p.PublicKey != ownerPubkey || p.Kind != KindRepositoryAnnouncement { + t.Errorf("ResolveAddr() = %+v", p) + } +} + +func TestCloneURL_ResolveAddr_NotNAddrForm(t *testing.T) { + c := &CloneURL{Owner: "npub1...", Identifier: "ngit"} + if _, err := c.ResolveAddr(); !errors.Is(err, ErrInvalidCloneURL) { + t.Errorf("error = %v, want ErrInvalidCloneURL", err) + } +} diff --git a/nip34/graspset.go b/nip34/graspset.go new file mode 100644 index 0000000..25a97ff --- /dev/null +++ b/nip34/graspset.go @@ -0,0 +1,53 @@ +package nip34 + +import ( + "fmt" + + "github.com/ohstr/nmilat/nip01" +) + +// GraspServerList is a parsed kind:10317 user grasp-server list event: the +// grasp (git-relay) servers a user prefers for NIP-34-related activity, in +// order of preference. It's the git-hosting analogue of a NIP-65 relay +// list or NIP-B7 Blossom server list. +type GraspServerList struct { + *nip01.Event + Servers []string // "g" tag values, in preference order; may be empty +} + +// ParseGraspServerList parses and structurally validates a kind:10317 +// event. +func ParseGraspServerList(event *nip01.Event) (*GraspServerList, error) { + if event.Kind != KindGraspServerList { + return nil, fmt.Errorf("%w: got %d, want %d", ErrWrongKind, event.Kind, KindGraspServerList) + } + + gl := &GraspServerList{Event: event} + for _, tag := range event.Tags { + if len(tag) > 1 && tag[0] == "g" { + gl.Servers = append(gl.Servers, tag[1]) + } + } + return gl, nil +} + +// ValidateGraspServerList checks the signature and structure of a grasp +// server list event. +func ValidateGraspServerList(event *nip01.Event) error { + if err := event.Verify(); err != nil { + return fmt.Errorf("%w: %w", ErrInvalidSignature, err) + } + _, err := ParseGraspServerList(event) + return err +} + +// NewGraspServerList builds an unsigned kind:10317 event. servers may be +// empty or nil, per the spec's "zero or more grasp server urls". Caller +// must sign it. +func NewGraspServerList(pubkey string, servers []string) *nip01.Event { + var tags [][]string + for _, s := range servers { + tags = append(tags, []string{"g", s}) + } + return nip01.NewUnsignedEvent(KindGraspServerList, pubkey, "", tags...) +} diff --git a/nip34/graspset_test.go b/nip34/graspset_test.go new file mode 100644 index 0000000..e2708aa --- /dev/null +++ b/nip34/graspset_test.go @@ -0,0 +1,45 @@ +package nip34 + +import ( + "errors" + "testing" + + "github.com/ohstr/nmilat/nip01" +) + +func TestNewGraspServerListAndParse(t *testing.T) { + ev := NewGraspServerList(ownerPubkey, []string{"wss://grasp1.example", "wss://grasp2.example"}) + ev = signed(t, ev) + + gl, err := ParseGraspServerList(ev) + if err != nil { + t.Fatalf("ParseGraspServerList() error = %v", err) + } + if len(gl.Servers) != 2 || gl.Servers[0] != "wss://grasp1.example" { + t.Errorf("Servers = %v", gl.Servers) + } + + if err := ValidateGraspServerList(ev); err != nil { + t.Errorf("ValidateGraspServerList() error = %v", err) + } +} + +func TestNewGraspServerList_Empty(t *testing.T) { + ev := NewGraspServerList(ownerPubkey, nil) + ev = signed(t, ev) + + gl, err := ParseGraspServerList(ev) + if err != nil { + t.Fatalf("ParseGraspServerList() error = %v", err) + } + if len(gl.Servers) != 0 { + t.Errorf("Servers = %v, want empty", gl.Servers) + } +} + +func TestParseGraspServerList_WrongKind(t *testing.T) { + ev := &nip01.Event{Kind: 1} + if _, err := ParseGraspServerList(ev); !errors.Is(err, ErrWrongKind) { + t.Errorf("error = %v, want ErrWrongKind", err) + } +} diff --git a/nip34/issue.go b/nip34/issue.go new file mode 100644 index 0000000..d9e6316 --- /dev/null +++ b/nip34/issue.go @@ -0,0 +1,111 @@ +package nip34 + +import ( + "fmt" + + "github.com/ohstr/nmilat/nip01" + "github.com/ohstr/nmilat/utils" +) + +// Issue is a parsed kind:1621 issue event. Content holds markdown text: a +// bug report, feature request, question, or comment of any kind related to +// the repository. +type Issue struct { + *nip01.Event + RepoAddress string // "a" tag; SHOULD be set, not required + RepositoryOwner string // "p" tag + Subject string // "subject" tag, for a header + Labels []string // "t" tags +} + +// ParseIssue parses and structurally validates a kind:1621 event. +func ParseIssue(event *nip01.Event) (*Issue, error) { + if event.Kind != KindIssue { + return nil, fmt.Errorf("%w: got %d, want %d", ErrWrongKind, event.Kind, KindIssue) + } + + iss := &Issue{Event: event} + for _, tag := range event.Tags { + if len(tag) < 1 { + continue + } + switch tag[0] { + case "a": + if len(tag) < 2 { + continue + } + if _, _, _, err := utils.ParseATag(tag[1]); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidRepoAddress, tag[1], err) + } + iss.RepoAddress = tag[1] + case "p": + if len(tag) < 2 { + continue + } + if err := utils.Validate32Key(tag[1]); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidPubkeyTag, tag[1], err) + } + iss.RepositoryOwner = tag[1] + case "subject": + if len(tag) > 1 { + iss.Subject = tag[1] + } + case "t": + if len(tag) > 1 { + iss.Labels = append(iss.Labels, tag[1]) + } + } + } + + return iss, nil +} + +// ValidateIssue checks the signature and structure of an issue event. +func ValidateIssue(event *nip01.Event) error { + if err := event.Verify(); err != nil { + return fmt.Errorf("%w: %w", ErrInvalidSignature, err) + } + _, err := ParseIssue(event) + return err +} + +// IssueParams describes an issue event to build. Pubkey and Content are +// required; everything else is optional. +type IssueParams struct { + Pubkey string + Content string + RepoAddress string + RepositoryOwner string + Subject string + Labels []string +} + +// NewIssue builds an unsigned kind:1621 event. Caller must sign it. +func NewIssue(p IssueParams) (*nip01.Event, error) { + if p.RepoAddress != "" { + if _, _, _, err := utils.ParseATag(p.RepoAddress); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidRepoAddress, p.RepoAddress, err) + } + } + if p.RepositoryOwner != "" { + if err := utils.Validate32Key(p.RepositoryOwner); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidPubkeyTag, p.RepositoryOwner, err) + } + } + + var tags [][]string + if p.RepoAddress != "" { + tags = append(tags, []string{"a", p.RepoAddress}) + } + if p.RepositoryOwner != "" { + tags = append(tags, []string{"p", p.RepositoryOwner}) + } + if p.Subject != "" { + tags = append(tags, []string{"subject", p.Subject}) + } + for _, label := range p.Labels { + tags = append(tags, []string{"t", label}) + } + + return nip01.NewUnsignedEvent(KindIssue, p.Pubkey, p.Content, tags...), nil +} diff --git a/nip34/issue_test.go b/nip34/issue_test.go new file mode 100644 index 0000000..2da5f41 --- /dev/null +++ b/nip34/issue_test.go @@ -0,0 +1,60 @@ +package nip34 + +import ( + "errors" + "testing" + + "github.com/ohstr/nmilat/nip01" +) + +func TestNewIssueAndParse(t *testing.T) { + ev, err := NewIssue(IssueParams{ + Pubkey: otherUserPubkey, + Content: "The build is broken on main.", + RepoAddress: testRepoAddress(t), + RepositoryOwner: ownerPubkey, + Subject: "Build broken", + Labels: []string{"bug", "ci"}, + }) + if err != nil { + t.Fatalf("NewIssue() error = %v", err) + } + ev = signed(t, ev) + + iss, err := ParseIssue(ev) + if err != nil { + t.Fatalf("ParseIssue() error = %v", err) + } + if iss.RepoAddress != testRepoAddress(t) || iss.RepositoryOwner != ownerPubkey { + t.Errorf("RepoAddress/owner = %q/%q", iss.RepoAddress, iss.RepositoryOwner) + } + if iss.Subject != "Build broken" || len(iss.Labels) != 2 { + t.Errorf("Subject/Labels = %q/%v", iss.Subject, iss.Labels) + } + + if err := ValidateIssue(ev); err != nil { + t.Errorf("ValidateIssue() error = %v", err) + } +} + +func TestParseIssueErrors(t *testing.T) { + tests := []struct { + name string + kind int + tags [][]string + wantErr error + }{ + {name: "wrong kind", kind: 1, tags: nil, wantErr: ErrWrongKind}, + {name: "bad repo address", kind: KindIssue, tags: [][]string{{"a", "bogus"}}, wantErr: ErrInvalidRepoAddress}, + {name: "bad owner pubkey", kind: KindIssue, tags: [][]string{{"p", "not-a-pubkey"}}, wantErr: ErrInvalidPubkeyTag}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ev := &nip01.Event{Kind: tt.kind, Tags: tt.tags} + _, err := ParseIssue(ev) + if !errors.Is(err, tt.wantErr) { + t.Errorf("error = %v, want wrapping %v", err, tt.wantErr) + } + }) + } +} diff --git a/nip34/nip34.go b/nip34/nip34.go new file mode 100644 index 0000000..3ded70a --- /dev/null +++ b/nip34/nip34.go @@ -0,0 +1,64 @@ +// Package nip34 implements NIP-34: git stuff — code collaboration over +// Nostr, covering: +// +// - Repository announcements (kind 30617) and repository state (kind +// 30618), both addressable events (repository.go) +// - Patches (kind 1617) (patch.go) +// - Pull requests (kind 1618) and PR updates (kind 1619) (pullrequest.go) +// - Issues (kind 1621) (issue.go) +// - Replies to issues/patches/PRs, which follow NIP-22's kind:1111 +// comment shape (reply.go, built on the nip22 package) +// - Status events for patches/PRs/issues (kind 1630-1633), including +// ResolveStatus and ResolveRevisionStatus, which implement the spec's +// status-resolution rules (status.go) +// - User grasp lists (kind 10317) (graspset.go) +// - The "nostr://" git clone URL scheme (clone_url.go) +// +// This package is a pure protocol library: event/type construction, +// parsing, and validation, with no relay or network dependency of its own. +// Publishing/subscribing is left to the generic nip01/relay-client +// plumbing already in this SDK — there is no nip34/client subpackage, +// since (unlike e.g. nipB7's Blossom HTTP servers) NIP-34 has no second +// transport of its own to dial out to. For declaring NIP-34 support to +// nmilat's relay engine, see the nip34/relayreg subpackage. +package nip34 + +import "errors" + +const ( + KindRepositoryAnnouncement = 30617 + KindRepositoryState = 30618 + KindPatch = 1617 + KindPullRequest = 1618 + KindPullRequestUpdate = 1619 + KindIssue = 1621 + KindStatusOpen = 1630 // default status for a root patch/PR/issue + KindStatusApplied = 1631 // Applied/Merged for patches/PRs; Resolved for issues + KindStatusClosed = 1632 + KindStatusDraft = 1633 + KindGraspServerList = 10317 +) + +// IsStatusKind reports whether kind is one of the four NIP-34 status kinds +// (1630-1633). +func IsStatusKind(kind int) bool { + switch kind { + case KindStatusOpen, KindStatusApplied, KindStatusClosed, KindStatusDraft: + return true + default: + return false + } +} + +// Failure modes shared by more than one file in this package, for callers +// that need to distinguish them (e.g. via errors.Is) rather than match on +// message text. Failure modes specific to a single event type are declared +// alongside that type instead. +var ( + ErrWrongKind = errors.New("nip34: wrong kind") + ErrInvalidSignature = errors.New("nip34: invalid signature") + ErrMissingIdentifier = errors.New("nip34: missing d tag identifier") + ErrInvalidRepoAddress = errors.New("nip34: invalid a tag repository address") + ErrInvalidPubkeyTag = errors.New("nip34: invalid pubkey tag") + ErrInvalidEventTag = errors.New("nip34: invalid event id tag") +) diff --git a/nip34/nip34_test.go b/nip34/nip34_test.go new file mode 100644 index 0000000..9c573de --- /dev/null +++ b/nip34/nip34_test.go @@ -0,0 +1,44 @@ +package nip34 + +import ( + "strings" + "testing" + + "github.com/ohstr/nmilat/nip01" +) + +const testPrivKey = "48939ec93986b59b58d7206887b42ff74d99dd3258782e2fdfd720eb74d547a5" + +func signed(t *testing.T, ev *nip01.Event) *nip01.Event { + t.Helper() + if err := ev.Sign(testPrivKey); err != nil { + t.Fatalf("Sign() error = %v", err) + } + return ev +} + +var ( + ownerPubkey = strings.Repeat("a", 64) + maintainer = strings.Repeat("b", 64) + otherUserPubkey = strings.Repeat("c", 64) + someEventID = strings.Repeat("d", 64) + someEventID2 = strings.Repeat("e", 64) +) + +func testRepoAddress(t *testing.T) string { + t.Helper() + return "30617:" + ownerPubkey + ":ngit" +} + +func TestIsStatusKind(t *testing.T) { + for _, k := range []int{KindStatusOpen, KindStatusApplied, KindStatusClosed, KindStatusDraft} { + if !IsStatusKind(k) { + t.Errorf("IsStatusKind(%d) = false, want true", k) + } + } + for _, k := range []int{0, 1617, 1620, 1634, 30617} { + if IsStatusKind(k) { + t.Errorf("IsStatusKind(%d) = true, want false", k) + } + } +} diff --git a/nip34/patch.go b/nip34/patch.go new file mode 100644 index 0000000..22ef3d3 --- /dev/null +++ b/nip34/patch.go @@ -0,0 +1,245 @@ +package nip34 + +import ( + "errors" + "fmt" + "strconv" + + "github.com/ohstr/nmilat/nip01" + "github.com/ohstr/nmilat/utils" +) + +// ErrInvalidCommitterTag is returned when a Patch's "committer" tag +// doesn't have all four required elements or has an unparsable +// timestamp/timezone offset. +var ErrInvalidCommitterTag = errors.New("nip34: invalid committer tag") + +// Committer is the ["committer", name, email, timestamp, tz-offset] tag on +// a Patch, mirroring `git format-patch`'s committer identity. +type Committer struct { + Name string + Email string + Timestamp int64 + TZOffsetMinutes int +} + +// Patch is a parsed kind:1617 patch event. Content holds the raw +// `git format-patch` output. +type Patch struct { + *nip01.Event + // RepoAddress is the "a" tag pointing at the target repository's + // announcement ("30617::"). The spec marks it + // SHOULD, not MUST, so it may be empty. + RepoAddress string + // CommitRefs holds every "r" tag value on the patch. NIP-34 overloads + // this tag: one instance conventionally mirrors the target repo's + // EarliestUniqueCommit (so clients can subscribe to all patches for a + // local repo), and, when Commit is set, another mirrors Commit itself + // (so clients can find existing patches for a specific commit). + // Nothing on the wire distinguishes which is which; cross-reference + // against Commit and the repository announcement's own + // EarliestUniqueCommit to tell them apart. + CommitRefs []string + // RepositoryOwner is the first "p" tag (conventionally the repo + // owner, per the spec's tag ordering). + RepositoryOwner string + // Recipients holds any further "p" tags, e.g. other users brought in + // for attention. + Recipients []string + IsRoot bool // "t" "root" tag: first patch in a series + IsRootRevision bool // "t" "root-revision" tag: first patch of a revision + // ReplyTo is the NIP-10 "e" tag (marked "reply", or unmarked per the + // legacy positional convention) pointing at the previous patch in the + // series/revision, or at the root patch for a revision's first patch. + ReplyTo string + Commit string // "commit" tag: this patch's resulting commit id + ParentCommit string // "parent-commit" tag + HasCommitPGPSig bool // whether a "commit-pgp-sig" tag is present at all + CommitPGPSig string // its value; empty means an unsigned commit + Committer *Committer +} + +// ParsePatch parses and structurally validates a kind:1617 event. +func ParsePatch(event *nip01.Event) (*Patch, error) { + if event.Kind != KindPatch { + return nil, fmt.Errorf("%w: got %d, want %d", ErrWrongKind, event.Kind, KindPatch) + } + + p := &Patch{Event: event} + for _, tag := range event.Tags { + if len(tag) < 1 { + continue + } + switch tag[0] { + case "a": + if len(tag) < 2 { + continue + } + if _, _, _, err := utils.ParseATag(tag[1]); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidRepoAddress, tag[1], err) + } + p.RepoAddress = tag[1] + case "r": + if len(tag) > 1 { + p.CommitRefs = append(p.CommitRefs, tag[1]) + } + case "p": + if len(tag) < 2 { + continue + } + if err := utils.Validate32Key(tag[1]); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidPubkeyTag, tag[1], err) + } + if p.RepositoryOwner == "" { + p.RepositoryOwner = tag[1] + } else { + p.Recipients = append(p.Recipients, tag[1]) + } + case "t": + if len(tag) < 2 { + continue + } + switch tag[1] { + case "root": + p.IsRoot = true + case "root-revision": + p.IsRootRevision = true + } + case "e": + if len(tag) < 2 { + continue + } + if err := utils.Validate32Key(tag[1]); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidEventTag, tag[1], err) + } + if p.ReplyTo == "" { + p.ReplyTo = tag[1] + } + case "commit": + if len(tag) > 1 { + p.Commit = tag[1] + } + case "parent-commit": + if len(tag) > 1 { + p.ParentCommit = tag[1] + } + case "commit-pgp-sig": + p.HasCommitPGPSig = true + if len(tag) > 1 { + p.CommitPGPSig = tag[1] + } + case "committer": + if len(tag) < 5 { + return nil, fmt.Errorf("%w: expected [\"committer\", name, email, timestamp, tz-offset], got %v", ErrInvalidCommitterTag, tag) + } + ts, err := strconv.ParseInt(tag[3], 10, 64) + if err != nil { + return nil, fmt.Errorf("%w: bad timestamp %q: %w", ErrInvalidCommitterTag, tag[3], err) + } + tz, err := strconv.Atoi(tag[4]) + if err != nil { + return nil, fmt.Errorf("%w: bad tz offset %q: %w", ErrInvalidCommitterTag, tag[4], err) + } + p.Committer = &Committer{Name: tag[1], Email: tag[2], Timestamp: ts, TZOffsetMinutes: tz} + } + } + + return p, nil +} + +// ValidatePatch checks the signature and structure of a patch event. +func ValidatePatch(event *nip01.Event) error { + if err := event.Verify(); err != nil { + return fmt.Errorf("%w: %w", ErrInvalidSignature, err) + } + _, err := ParsePatch(event) + return err +} + +// PatchParams describes a patch event to build. Pubkey and Content are +// required; everything else is optional (the spec marks nothing besides +// the repo address as conventional, and even that is a SHOULD). +type PatchParams struct { + Pubkey string + Content string // `git format-patch` output + RepoAddress string + CommitRefs []string + RepositoryOwner string + Recipients []string + IsRoot bool + IsRootRevision bool + ReplyTo string + ReplyRelayHint string + Commit string + ParentCommit string + // CommitPGPSig, when non-nil, adds a "commit-pgp-sig" tag; pass a + // pointer to "" for an unsigned commit (present-but-empty), or nil to + // omit the tag entirely. + CommitPGPSig *string + Committer *Committer +} + +// NewPatch builds an unsigned kind:1617 event. Caller must sign it. +func NewPatch(p PatchParams) (*nip01.Event, error) { + if p.RepoAddress != "" { + if _, _, _, err := utils.ParseATag(p.RepoAddress); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidRepoAddress, p.RepoAddress, err) + } + } + if p.RepositoryOwner != "" { + if err := utils.Validate32Key(p.RepositoryOwner); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidPubkeyTag, p.RepositoryOwner, err) + } + } + for _, r := range p.Recipients { + if err := utils.Validate32Key(r); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidPubkeyTag, r, err) + } + } + if p.ReplyTo != "" { + if err := utils.Validate32Key(p.ReplyTo); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidEventTag, p.ReplyTo, err) + } + } + + var tags [][]string + if p.RepoAddress != "" { + tags = append(tags, []string{"a", p.RepoAddress}) + } + for _, r := range p.CommitRefs { + tags = append(tags, []string{"r", r}) + } + if p.RepositoryOwner != "" { + tags = append(tags, []string{"p", p.RepositoryOwner}) + } + for _, r := range p.Recipients { + tags = append(tags, []string{"p", r}) + } + if p.IsRoot { + tags = append(tags, []string{"t", "root"}) + } + if p.IsRootRevision { + tags = append(tags, []string{"t", "root-revision"}) + } + if p.ReplyTo != "" { + tags = append(tags, []string{"e", p.ReplyTo, p.ReplyRelayHint, "reply"}) + } + if p.Commit != "" { + tags = append(tags, []string{"commit", p.Commit}) + } + if p.ParentCommit != "" { + tags = append(tags, []string{"parent-commit", p.ParentCommit}) + } + if p.CommitPGPSig != nil { + tags = append(tags, []string{"commit-pgp-sig", *p.CommitPGPSig}) + } + if p.Committer != nil { + tags = append(tags, []string{ + "committer", p.Committer.Name, p.Committer.Email, + strconv.FormatInt(p.Committer.Timestamp, 10), + strconv.Itoa(p.Committer.TZOffsetMinutes), + }) + } + + return nip01.NewUnsignedEvent(KindPatch, p.Pubkey, p.Content, tags...), nil +} diff --git a/nip34/patch_test.go b/nip34/patch_test.go new file mode 100644 index 0000000..30a75be --- /dev/null +++ b/nip34/patch_test.go @@ -0,0 +1,136 @@ +package nip34 + +import ( + "errors" + "testing" + + "github.com/ohstr/nmilat/nip01" +) + +func TestNewPatchAndParse(t *testing.T) { + sig := "-----BEGIN PGP SIGNATURE-----..." + ev, err := NewPatch(PatchParams{ + Pubkey: ownerPubkey, + Content: "diff --git a/foo b/foo\n...", + RepoAddress: testRepoAddress(t), + CommitRefs: []string{someEventID}, + RepositoryOwner: ownerPubkey, + Recipients: []string{otherUserPubkey}, + IsRoot: true, + Commit: "abc123", + ParentCommit: "def456", + CommitPGPSig: &sig, + Committer: &Committer{Name: "Alice", Email: "alice@example.com", Timestamp: 1700000000, TZOffsetMinutes: 60}, + }) + if err != nil { + t.Fatalf("NewPatch() error = %v", err) + } + ev = signed(t, ev) + + p, err := ParsePatch(ev) + if err != nil { + t.Fatalf("ParsePatch() error = %v", err) + } + if p.RepoAddress != testRepoAddress(t) { + t.Errorf("RepoAddress = %q", p.RepoAddress) + } + if len(p.CommitRefs) != 1 || p.CommitRefs[0] != someEventID { + t.Errorf("CommitRefs = %v", p.CommitRefs) + } + if p.RepositoryOwner != ownerPubkey { + t.Errorf("RepositoryOwner = %q", p.RepositoryOwner) + } + if len(p.Recipients) != 1 || p.Recipients[0] != otherUserPubkey { + t.Errorf("Recipients = %v", p.Recipients) + } + if !p.IsRoot || p.IsRootRevision { + t.Errorf("IsRoot=%v IsRootRevision=%v", p.IsRoot, p.IsRootRevision) + } + if p.Commit != "abc123" || p.ParentCommit != "def456" { + t.Errorf("Commit=%q ParentCommit=%q", p.Commit, p.ParentCommit) + } + if !p.HasCommitPGPSig || p.CommitPGPSig != sig { + t.Errorf("CommitPGPSig = %q (has=%v)", p.CommitPGPSig, p.HasCommitPGPSig) + } + if p.Committer == nil || p.Committer.Name != "Alice" || p.Committer.TZOffsetMinutes != 60 { + t.Errorf("Committer = %+v", p.Committer) + } + + if err := ValidatePatch(ev); err != nil { + t.Errorf("ValidatePatch() error = %v", err) + } +} + +func TestNewPatch_NoRepoAddressAllowed(t *testing.T) { + ev, err := NewPatch(PatchParams{Pubkey: ownerPubkey, Content: "patch body"}) + if err != nil { + t.Fatalf("NewPatch() error = %v", err) + } + ev = signed(t, ev) + + p, err := ParsePatch(ev) + if err != nil { + t.Fatalf("ParsePatch() error = %v", err) + } + if p.RepoAddress != "" { + t.Errorf("RepoAddress = %q, want empty", p.RepoAddress) + } +} + +func TestPatchReplyTo(t *testing.T) { + ev, err := NewPatch(PatchParams{ + Pubkey: ownerPubkey, + Content: "revision 2", + ReplyTo: someEventID, + }) + if err != nil { + t.Fatalf("NewPatch() error = %v", err) + } + ev = signed(t, ev) + + p, err := ParsePatch(ev) + if err != nil { + t.Fatalf("ParsePatch() error = %v", err) + } + if p.ReplyTo != someEventID { + t.Errorf("ReplyTo = %q, want %q", p.ReplyTo, someEventID) + } +} + +func TestParsePatchErrors(t *testing.T) { + tests := []struct { + name string + kind int + tags [][]string + wantErr error + }{ + {name: "wrong kind", kind: 1, tags: nil, wantErr: ErrWrongKind}, + { + name: "bad repo address", + kind: KindPatch, + tags: [][]string{{"a", "not-an-address"}}, + wantErr: ErrInvalidRepoAddress, + }, + { + name: "short committer tag", + kind: KindPatch, + tags: [][]string{{"committer", "Alice"}}, + wantErr: ErrInvalidCommitterTag, + }, + { + name: "bad committer timestamp", + kind: KindPatch, + tags: [][]string{{"committer", "Alice", "alice@example.com", "not-a-number", "0"}}, + wantErr: ErrInvalidCommitterTag, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ev := &nip01.Event{Kind: tt.kind, Tags: tt.tags} + _, err := ParsePatch(ev) + if !errors.Is(err, tt.wantErr) { + t.Errorf("error = %v, want wrapping %v", err, tt.wantErr) + } + }) + } +} diff --git a/nip34/pullrequest.go b/nip34/pullrequest.go new file mode 100644 index 0000000..21777db --- /dev/null +++ b/nip34/pullrequest.go @@ -0,0 +1,361 @@ +package nip34 + +import ( + "fmt" + + "github.com/ohstr/nmilat/nip01" + "github.com/ohstr/nmilat/utils" +) + +// PullRequest is a parsed kind:1618 pull request event. Content holds +// markdown text describing the change. +type PullRequest struct { + *nip01.Event + RepoAddress string // "a" tag; SHOULD be set, not required + EarliestUniqueCommit string // "r" tag + RepositoryOwner string // first "p" tag + Recipients []string // remaining "p" tags + Subject string // "subject" tag + Labels []string // "t" tags + Commit string // "c" tag: tip of the PR branch + CloneURLs []string // "clone" tag + BranchName string // "branch-name" tag + // RevisesPatch is the "e" tag's event id, set when this PR is a + // revision of an existing patch (which should then be closed). + RevisesPatch string + MergeBase string // "merge-base" tag +} + +// ParsePullRequest parses and structurally validates a kind:1618 event. +func ParsePullRequest(event *nip01.Event) (*PullRequest, error) { + if event.Kind != KindPullRequest { + return nil, fmt.Errorf("%w: got %d, want %d", ErrWrongKind, event.Kind, KindPullRequest) + } + + pr := &PullRequest{Event: event} + for _, tag := range event.Tags { + if len(tag) < 1 { + continue + } + switch tag[0] { + case "a": + if len(tag) < 2 { + continue + } + if _, _, _, err := utils.ParseATag(tag[1]); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidRepoAddress, tag[1], err) + } + pr.RepoAddress = tag[1] + case "r": + if len(tag) > 1 { + pr.EarliestUniqueCommit = tag[1] + } + case "p": + if len(tag) < 2 { + continue + } + if err := utils.Validate32Key(tag[1]); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidPubkeyTag, tag[1], err) + } + if pr.RepositoryOwner == "" { + pr.RepositoryOwner = tag[1] + } else { + pr.Recipients = append(pr.Recipients, tag[1]) + } + case "subject": + if len(tag) > 1 { + pr.Subject = tag[1] + } + case "t": + if len(tag) > 1 { + pr.Labels = append(pr.Labels, tag[1]) + } + case "c": + if len(tag) > 1 { + pr.Commit = tag[1] + } + case "clone": + pr.CloneURLs = append(pr.CloneURLs, tag[1:]...) + case "branch-name": + if len(tag) > 1 { + pr.BranchName = tag[1] + } + case "e": + if len(tag) < 2 { + continue + } + if err := utils.Validate32Key(tag[1]); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidEventTag, tag[1], err) + } + pr.RevisesPatch = tag[1] + case "merge-base": + if len(tag) > 1 { + pr.MergeBase = tag[1] + } + } + } + + return pr, nil +} + +// ValidatePullRequest checks the signature and structure of a pull +// request event. +func ValidatePullRequest(event *nip01.Event) error { + if err := event.Verify(); err != nil { + return fmt.Errorf("%w: %w", ErrInvalidSignature, err) + } + _, err := ParsePullRequest(event) + return err +} + +// PullRequestParams describes a pull request event to build. Pubkey and +// Content are required; everything else is optional. +type PullRequestParams struct { + Pubkey string + Content string + RepoAddress string + EarliestUniqueCommit string + RepositoryOwner string + Recipients []string + Subject string + Labels []string + Commit string + CloneURLs []string + BranchName string + RevisesPatch string + MergeBase string +} + +// NewPullRequest builds an unsigned kind:1618 event. Caller must sign it. +func NewPullRequest(p PullRequestParams) (*nip01.Event, error) { + if p.RepoAddress != "" { + if _, _, _, err := utils.ParseATag(p.RepoAddress); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidRepoAddress, p.RepoAddress, err) + } + } + if p.RepositoryOwner != "" { + if err := utils.Validate32Key(p.RepositoryOwner); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidPubkeyTag, p.RepositoryOwner, err) + } + } + for _, r := range p.Recipients { + if err := utils.Validate32Key(r); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidPubkeyTag, r, err) + } + } + if p.RevisesPatch != "" { + if err := utils.Validate32Key(p.RevisesPatch); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidEventTag, p.RevisesPatch, err) + } + } + + var tags [][]string + if p.RepoAddress != "" { + tags = append(tags, []string{"a", p.RepoAddress}) + } + if p.EarliestUniqueCommit != "" { + tags = append(tags, []string{"r", p.EarliestUniqueCommit}) + } + if p.RepositoryOwner != "" { + tags = append(tags, []string{"p", p.RepositoryOwner}) + } + for _, r := range p.Recipients { + tags = append(tags, []string{"p", r}) + } + if p.Subject != "" { + tags = append(tags, []string{"subject", p.Subject}) + } + for _, label := range p.Labels { + tags = append(tags, []string{"t", label}) + } + if p.Commit != "" { + tags = append(tags, []string{"c", p.Commit}) + } + if len(p.CloneURLs) > 0 { + tags = append(tags, append([]string{"clone"}, p.CloneURLs...)) + } + if p.BranchName != "" { + tags = append(tags, []string{"branch-name", p.BranchName}) + } + if p.RevisesPatch != "" { + tags = append(tags, []string{"e", p.RevisesPatch}) + } + if p.MergeBase != "" { + tags = append(tags, []string{"merge-base", p.MergeBase}) + } + + return nip01.NewUnsignedEvent(KindPullRequest, p.Pubkey, p.Content, tags...), nil +} + +// PullRequestUpdate is a parsed kind:1619 pull request update event, +// changing the tip of a referenced PR. +type PullRequestUpdate struct { + *nip01.Event + RepoAddress string + EarliestUniqueCommit string + RepositoryOwner string + Recipients []string + PullRequestEventID string // "E" tag (NIP-22-style root pointer) + PullRequestAuthor string // "P" tag + Commit string // "c" tag: updated tip of the PR + CloneURLs []string + MergeBase string +} + +// ParsePullRequestUpdate parses and structurally validates a kind:1619 +// event. +func ParsePullRequestUpdate(event *nip01.Event) (*PullRequestUpdate, error) { + if event.Kind != KindPullRequestUpdate { + return nil, fmt.Errorf("%w: got %d, want %d", ErrWrongKind, event.Kind, KindPullRequestUpdate) + } + + u := &PullRequestUpdate{Event: event} + for _, tag := range event.Tags { + if len(tag) < 1 { + continue + } + switch tag[0] { + case "a": + if len(tag) < 2 { + continue + } + if _, _, _, err := utils.ParseATag(tag[1]); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidRepoAddress, tag[1], err) + } + u.RepoAddress = tag[1] + case "r": + if len(tag) > 1 { + u.EarliestUniqueCommit = tag[1] + } + case "p": + if len(tag) < 2 { + continue + } + if err := utils.Validate32Key(tag[1]); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidPubkeyTag, tag[1], err) + } + if u.RepositoryOwner == "" { + u.RepositoryOwner = tag[1] + } else { + u.Recipients = append(u.Recipients, tag[1]) + } + case "E": + if len(tag) < 2 { + continue + } + if err := utils.Validate32Key(tag[1]); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidEventTag, tag[1], err) + } + u.PullRequestEventID = tag[1] + case "P": + if len(tag) < 2 { + continue + } + if err := utils.Validate32Key(tag[1]); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidPubkeyTag, tag[1], err) + } + u.PullRequestAuthor = tag[1] + case "c": + if len(tag) > 1 { + u.Commit = tag[1] + } + case "clone": + u.CloneURLs = append(u.CloneURLs, tag[1:]...) + case "merge-base": + if len(tag) > 1 { + u.MergeBase = tag[1] + } + } + } + + if u.PullRequestEventID == "" { + return nil, ErrInvalidEventTag + } + return u, nil +} + +// ValidatePullRequestUpdate checks the signature and structure of a pull +// request update event. +func ValidatePullRequestUpdate(event *nip01.Event) error { + if err := event.Verify(); err != nil { + return fmt.Errorf("%w: %w", ErrInvalidSignature, err) + } + _, err := ParsePullRequestUpdate(event) + return err +} + +// PullRequestUpdateParams describes a pull request update event to build. +// Pubkey and PullRequestEventID are required; everything else is +// optional. +type PullRequestUpdateParams struct { + Pubkey string + RepoAddress string + EarliestUniqueCommit string + RepositoryOwner string + Recipients []string + PullRequestEventID string + PullRequestAuthor string + Commit string + CloneURLs []string + MergeBase string +} + +// NewPullRequestUpdate builds an unsigned kind:1619 event. Caller must +// sign it. +func NewPullRequestUpdate(p PullRequestUpdateParams) (*nip01.Event, error) { + if p.PullRequestEventID == "" { + return nil, ErrInvalidEventTag + } + if err := utils.Validate32Key(p.PullRequestEventID); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidEventTag, p.PullRequestEventID, err) + } + if p.RepoAddress != "" { + if _, _, _, err := utils.ParseATag(p.RepoAddress); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidRepoAddress, p.RepoAddress, err) + } + } + if p.RepositoryOwner != "" { + if err := utils.Validate32Key(p.RepositoryOwner); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidPubkeyTag, p.RepositoryOwner, err) + } + } + for _, r := range p.Recipients { + if err := utils.Validate32Key(r); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidPubkeyTag, r, err) + } + } + if p.PullRequestAuthor != "" { + if err := utils.Validate32Key(p.PullRequestAuthor); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidPubkeyTag, p.PullRequestAuthor, err) + } + } + + var tags [][]string + if p.RepoAddress != "" { + tags = append(tags, []string{"a", p.RepoAddress}) + } + if p.EarliestUniqueCommit != "" { + tags = append(tags, []string{"r", p.EarliestUniqueCommit}) + } + if p.RepositoryOwner != "" { + tags = append(tags, []string{"p", p.RepositoryOwner}) + } + for _, r := range p.Recipients { + tags = append(tags, []string{"p", r}) + } + tags = append(tags, []string{"E", p.PullRequestEventID}) + if p.PullRequestAuthor != "" { + tags = append(tags, []string{"P", p.PullRequestAuthor}) + } + if p.Commit != "" { + tags = append(tags, []string{"c", p.Commit}) + } + if len(p.CloneURLs) > 0 { + tags = append(tags, append([]string{"clone"}, p.CloneURLs...)) + } + if p.MergeBase != "" { + tags = append(tags, []string{"merge-base", p.MergeBase}) + } + + return nip01.NewUnsignedEvent(KindPullRequestUpdate, p.Pubkey, "", tags...), nil +} diff --git a/nip34/pullrequest_test.go b/nip34/pullrequest_test.go new file mode 100644 index 0000000..ef48556 --- /dev/null +++ b/nip34/pullrequest_test.go @@ -0,0 +1,110 @@ +package nip34 + +import ( + "errors" + "testing" + + "github.com/ohstr/nmilat/nip01" +) + +func TestNewPullRequestAndParse(t *testing.T) { + ev, err := NewPullRequest(PullRequestParams{ + Pubkey: ownerPubkey, + Content: "adds a cool feature", + RepoAddress: testRepoAddress(t), + EarliestUniqueCommit: someEventID, + RepositoryOwner: ownerPubkey, + Recipients: []string{otherUserPubkey}, + Subject: "Add cool feature", + Labels: []string{"enhancement"}, + Commit: "abc123", + CloneURLs: []string{"https://github.com/example/fork.git"}, + BranchName: "cool-feature", + RevisesPatch: someEventID2, + MergeBase: "def456", + }) + if err != nil { + t.Fatalf("NewPullRequest() error = %v", err) + } + ev = signed(t, ev) + + pr, err := ParsePullRequest(ev) + if err != nil { + t.Fatalf("ParsePullRequest() error = %v", err) + } + if pr.RepoAddress != testRepoAddress(t) || pr.EarliestUniqueCommit != someEventID { + t.Errorf("RepoAddress/EUC = %q/%q", pr.RepoAddress, pr.EarliestUniqueCommit) + } + if pr.RepositoryOwner != ownerPubkey || len(pr.Recipients) != 1 || pr.Recipients[0] != otherUserPubkey { + t.Errorf("owner/recipients = %q/%v", pr.RepositoryOwner, pr.Recipients) + } + if pr.Subject != "Add cool feature" || len(pr.Labels) != 1 || pr.Labels[0] != "enhancement" { + t.Errorf("subject/labels = %q/%v", pr.Subject, pr.Labels) + } + if pr.Commit != "abc123" || pr.BranchName != "cool-feature" || pr.MergeBase != "def456" { + t.Errorf("commit/branch/mergebase = %q/%q/%q", pr.Commit, pr.BranchName, pr.MergeBase) + } + if len(pr.CloneURLs) != 1 || pr.CloneURLs[0] != "https://github.com/example/fork.git" { + t.Errorf("CloneURLs = %v", pr.CloneURLs) + } + if pr.RevisesPatch != someEventID2 { + t.Errorf("RevisesPatch = %q", pr.RevisesPatch) + } + + if err := ValidatePullRequest(ev); err != nil { + t.Errorf("ValidatePullRequest() error = %v", err) + } +} + +func TestParsePullRequestErrors(t *testing.T) { + ev := &nip01.Event{Kind: KindPullRequest, Tags: [][]string{{"a", "bogus"}}} + if _, err := ParsePullRequest(ev); !errors.Is(err, ErrInvalidRepoAddress) { + t.Errorf("error = %v, want ErrInvalidRepoAddress", err) + } +} + +func TestNewPullRequestUpdateAndParse(t *testing.T) { + ev, err := NewPullRequestUpdate(PullRequestUpdateParams{ + Pubkey: ownerPubkey, + RepoAddress: testRepoAddress(t), + EarliestUniqueCommit: someEventID, + RepositoryOwner: ownerPubkey, + PullRequestEventID: someEventID2, + PullRequestAuthor: otherUserPubkey, + Commit: "newcommit", + CloneURLs: []string{"https://github.com/example/fork.git"}, + MergeBase: "base123", + }) + if err != nil { + t.Fatalf("NewPullRequestUpdate() error = %v", err) + } + ev = signed(t, ev) + + u, err := ParsePullRequestUpdate(ev) + if err != nil { + t.Fatalf("ParsePullRequestUpdate() error = %v", err) + } + if u.PullRequestEventID != someEventID2 || u.PullRequestAuthor != otherUserPubkey { + t.Errorf("PR id/author = %q/%q", u.PullRequestEventID, u.PullRequestAuthor) + } + if u.Commit != "newcommit" || u.MergeBase != "base123" { + t.Errorf("commit/mergebase = %q/%q", u.Commit, u.MergeBase) + } + + if err := ValidatePullRequestUpdate(ev); err != nil { + t.Errorf("ValidatePullRequestUpdate() error = %v", err) + } +} + +func TestNewPullRequestUpdate_MissingPullRequestID(t *testing.T) { + if _, err := NewPullRequestUpdate(PullRequestUpdateParams{Pubkey: ownerPubkey}); !errors.Is(err, ErrInvalidEventTag) { + t.Errorf("error = %v, want ErrInvalidEventTag", err) + } +} + +func TestParsePullRequestUpdate_MissingE(t *testing.T) { + ev := &nip01.Event{Kind: KindPullRequestUpdate, Tags: nil} + if _, err := ParsePullRequestUpdate(ev); !errors.Is(err, ErrInvalidEventTag) { + t.Errorf("error = %v, want ErrInvalidEventTag", err) + } +} diff --git a/nip34/relayreg/relayreg.go b/nip34/relayreg/relayreg.go new file mode 100644 index 0000000..ceed33f --- /dev/null +++ b/nip34/relayreg/relayreg.go @@ -0,0 +1,59 @@ +// Package relayreg declares NIP-34 support to a relay engine. Blank-import +// it from a relay-embedding binary that wants NIP-34 auto-declared in its +// NIP-11 document and repository/patch/PR/issue/status/grasp-list events +// auto-validated: +// +// import _ "github.com/ohstr/nmilat/nip34/relayreg" +// +// nip34 itself has no dependency on relay, so pure clients that only +// build/parse git-collaboration events don't pay for relay's bbolt/ +// websocket dependency. Replies use NIP-22's kind:1111 comment shape, +// which isn't NIP-34-specific -- blank-import nip22/relayreg alongside +// this package too if the relay should also validate those. +package relayreg + +import ( + "context" + + "github.com/ohstr/nmilat/nip01" + "github.com/ohstr/nmilat/nip34" + "github.com/ohstr/nmilat/relay" +) + +func init() { + relay.RegisterNIP(34) + + relay.RegisterEventValidator(nip34.KindRepositoryAnnouncement, func(_ context.Context, event *nip01.Event) error { + return nip34.ValidateRepositoryAnnouncement(event) + }) + relay.RegisterEventValidator(nip34.KindRepositoryState, func(_ context.Context, event *nip01.Event) error { + return nip34.ValidateRepositoryState(event) + }) + relay.RegisterEventValidator(nip34.KindPatch, func(_ context.Context, event *nip01.Event) error { + return nip34.ValidatePatch(event) + }) + relay.RegisterEventValidator(nip34.KindPullRequest, func(_ context.Context, event *nip01.Event) error { + return nip34.ValidatePullRequest(event) + }) + relay.RegisterEventValidator(nip34.KindPullRequestUpdate, func(_ context.Context, event *nip01.Event) error { + return nip34.ValidatePullRequestUpdate(event) + }) + relay.RegisterEventValidator(nip34.KindIssue, func(_ context.Context, event *nip01.Event) error { + return nip34.ValidateIssue(event) + }) + relay.RegisterEventValidator(nip34.KindStatusOpen, func(_ context.Context, event *nip01.Event) error { + return nip34.ValidateStatus(event) + }) + relay.RegisterEventValidator(nip34.KindStatusApplied, func(_ context.Context, event *nip01.Event) error { + return nip34.ValidateStatus(event) + }) + relay.RegisterEventValidator(nip34.KindStatusClosed, func(_ context.Context, event *nip01.Event) error { + return nip34.ValidateStatus(event) + }) + relay.RegisterEventValidator(nip34.KindStatusDraft, func(_ context.Context, event *nip01.Event) error { + return nip34.ValidateStatus(event) + }) + relay.RegisterEventValidator(nip34.KindGraspServerList, func(_ context.Context, event *nip01.Event) error { + return nip34.ValidateGraspServerList(event) + }) +} diff --git a/nip34/reply.go b/nip34/reply.go new file mode 100644 index 0000000..ab7f9a0 --- /dev/null +++ b/nip34/reply.go @@ -0,0 +1,92 @@ +package nip34 + +import ( + "errors" + "fmt" + "strconv" + + "github.com/ohstr/nmilat/nip01" + "github.com/ohstr/nmilat/nip22" +) + +// ErrNotAGitReplyRoot is returned by ParseReply when a kind:1111 comment's +// root scope kind isn't one of Issue, Patch, or PullRequest. +var ErrNotAGitReplyRoot = errors.New("nip34: root item is not an issue/patch/pull-request") + +// ReplyParams describes a reply to build: a kind:1111 NIP-22 comment (see +// the nip22 package) on a NIP-34 issue, patch, or pull request. Pubkey and +// RootEvent are required; RootEvent must be the thread's root event (an +// Issue/Patch/PullRequest event). ParentEvent is the specific item being +// replied to -- leave it nil (or set it equal to RootEvent) for a +// top-level reply. +type ReplyParams struct { + Pubkey string + Content string + RootEvent *nip01.Event + // ParentEvent defaults to RootEvent when nil, producing a top-level + // reply. + ParentEvent *nip01.Event + RelayHint string +} + +// NewReply builds an unsigned reply event. See ReplyParams. +func NewReply(p ReplyParams) (*nip01.Event, error) { + if p.RootEvent == nil { + return nil, ErrInvalidEventTag + } + parent := p.ParentEvent + if parent == nil { + parent = p.RootEvent + } + + return nip22.NewComment(nip22.CommentParams{ + Pubkey: p.Pubkey, + Content: p.Content, + Root: nip22.ScopeParams{ + Pointer: nip22.PointerParams{ + Type: nip22.PointerEvent, + Value: p.RootEvent.ID, + RelayHint: p.RelayHint, + AuthorPubkey: p.RootEvent.PubKey, + }, + Kind: strconv.Itoa(p.RootEvent.Kind), + AuthorPubkey: p.RootEvent.PubKey, + }, + Parent: nip22.ScopeParams{ + Pointer: nip22.PointerParams{ + Type: nip22.PointerEvent, + Value: parent.ID, + RelayHint: p.RelayHint, + AuthorPubkey: parent.PubKey, + }, + Kind: strconv.Itoa(parent.Kind), + AuthorPubkey: parent.PubKey, + }, + }) +} + +// ParseReply parses a kind:1111 event as a NIP-22 comment (see +// nip22.ParseComment) and checks that its root scope kind is one of +// Issue/Patch/PullRequest, returning ErrNotAGitReplyRoot otherwise. +func ParseReply(event *nip01.Event) (*nip22.Comment, error) { + c, err := nip22.ParseComment(event) + if err != nil { + return nil, err + } + + switch c.Root.Kind { + case strconv.Itoa(KindIssue), strconv.Itoa(KindPatch), strconv.Itoa(KindPullRequest): + return c, nil + default: + return nil, fmt.Errorf("%w: got %q", ErrNotAGitReplyRoot, c.Root.Kind) + } +} + +// ValidateReply checks the signature and structure of a reply event. +func ValidateReply(event *nip01.Event) error { + if err := event.Verify(); err != nil { + return fmt.Errorf("%w: %w", ErrInvalidSignature, err) + } + _, err := ParseReply(event) + return err +} diff --git a/nip34/reply_test.go b/nip34/reply_test.go new file mode 100644 index 0000000..87bb186 --- /dev/null +++ b/nip34/reply_test.go @@ -0,0 +1,106 @@ +package nip34 + +import ( + "errors" + "testing" + + "github.com/ohstr/nmilat/nip22" +) + +func TestNewReplyAndParse_TopLevel(t *testing.T) { + issueEv, err := NewIssue(IssueParams{Pubkey: otherUserPubkey, Content: "bug report", RepoAddress: testRepoAddress(t)}) + if err != nil { + t.Fatalf("NewIssue() error = %v", err) + } + issueEv = signed(t, issueEv) + + replyEv, err := NewReply(ReplyParams{ + Pubkey: ownerPubkey, + Content: "thanks for reporting, looking into it", + RootEvent: issueEv, + }) + if err != nil { + t.Fatalf("NewReply() error = %v", err) + } + replyEv = signed(t, replyEv) + + c, err := ParseReply(replyEv) + if err != nil { + t.Fatalf("ParseReply() error = %v", err) + } + if c.Root.Pointer.Value != issueEv.ID || c.Root.Kind != "1621" { + t.Errorf("Root = %+v", c.Root) + } + if c.Parent.Pointer.Value != issueEv.ID { + t.Errorf("Parent.Pointer.Value = %q, want top-level reply to point at root", c.Parent.Pointer.Value) + } + + if err := ValidateReply(replyEv); err != nil { + t.Errorf("ValidateReply() error = %v", err) + } +} + +func TestNewReplyAndParse_NestedOnPreviousReply(t *testing.T) { + patchEv, err := NewPatch(PatchParams{Pubkey: ownerPubkey, Content: "diff", RepoAddress: testRepoAddress(t)}) + if err != nil { + t.Fatalf("NewPatch() error = %v", err) + } + patchEv = signed(t, patchEv) + + firstReply, err := NewReply(ReplyParams{Pubkey: otherUserPubkey, Content: "comment", RootEvent: patchEv}) + if err != nil { + t.Fatalf("NewReply() error = %v", err) + } + firstReply = signed(t, firstReply) + + nestedReply, err := NewReply(ReplyParams{ + Pubkey: ownerPubkey, + Content: "reply to comment", + RootEvent: patchEv, + ParentEvent: firstReply, + }) + if err != nil { + t.Fatalf("NewReply() (nested) error = %v", err) + } + nestedReply = signed(t, nestedReply) + + c, err := ParseReply(nestedReply) + if err != nil { + t.Fatalf("ParseReply() error = %v", err) + } + if c.Root.Pointer.Value != patchEv.ID { + t.Errorf("Root.Pointer.Value = %q, want patch id", c.Root.Pointer.Value) + } + if c.Parent.Pointer.Value != firstReply.ID || c.Parent.Kind != "1111" { + t.Errorf("Parent = %+v, want pointer at first reply with kind 1111", c.Parent) + } +} + +func TestNewReply_MissingRootEvent(t *testing.T) { + if _, err := NewReply(ReplyParams{Pubkey: ownerPubkey}); !errors.Is(err, ErrInvalidEventTag) { + t.Errorf("error = %v, want ErrInvalidEventTag", err) + } +} + +func TestParseReply_WrongRootKind(t *testing.T) { + ev, err := nip22.NewComment(nip22.CommentParams{ + Pubkey: ownerPubkey, + Content: "not a git thread", + Root: nip22.ScopeParams{ + Pointer: nip22.PointerParams{Type: nip22.PointerEvent, Value: someEventID}, + Kind: "1", + }, + Parent: nip22.ScopeParams{ + Pointer: nip22.PointerParams{Type: nip22.PointerEvent, Value: someEventID}, + Kind: "1", + }, + }) + if err != nil { + t.Fatalf("nip22.NewComment() error = %v", err) + } + ev = signed(t, ev) + + if _, err := ParseReply(ev); !errors.Is(err, ErrNotAGitReplyRoot) { + t.Errorf("error = %v, want ErrNotAGitReplyRoot", err) + } +} diff --git a/nip34/repository.go b/nip34/repository.go new file mode 100644 index 0000000..a5bcf91 --- /dev/null +++ b/nip34/repository.go @@ -0,0 +1,310 @@ +package nip34 + +import ( + "errors" + "fmt" + "net/url" + "strings" + + "github.com/ohstr/nmilat/nip01" + "github.com/ohstr/nmilat/utils" +) + +// Failure modes specific to RepositoryAnnouncement/RepositoryState, for +// callers that need to distinguish them (e.g. via errors.Is) rather than +// match on message text. +var ( + ErrInvalidRelayURL = errors.New("nip34: invalid relay url") + ErrInvalidRelayScheme = errors.New("nip34: relay url must use ws or wss scheme") +) + +// UpstreamFork is the optional "u" tag on a RepositoryAnnouncement, +// indicating this repository is a subordinate fork. +type UpstreamFork struct { + // Pointer is either a nip34 "a" tag address ("30617::") of + // the upstream repository's own announcement, or a plain + // (preferably https) git URL, per the spec's own alternation between + // the two forms. + Pointer string + RelayHint string + AuthorPubkey string +} + +// RepositoryAnnouncement is a parsed kind:30617 repository announcement +// event, an addressable event (see nip33) keyed by Identifier. +type RepositoryAnnouncement struct { + *nip01.Event + Identifier string // "d" tag; the only required tag per the spec + Name string + Description string + // Web is a list of webpage URLs for browsing the repository, if the + // git server used provides one. + Web []string + // Clone is a list of URLs suitable for `git clone`. + Clone []string + // Relays is where patches/issues for this repository should be sent. + Relays []string + // EarliestUniqueCommit is the "r" tag marked "euc": the commit id of + // the repo's earliest unique commit, used to group this repository + // with other copies of essentially the same project hosted elsewhere. + EarliestUniqueCommit string + // Maintainers holds pubkeys of maintainers recognized in addition to + // this event's author. + Maintainers []string + // Upstream is set when this repository is a subordinate fork. + Upstream *UpstreamFork + // Hashtags are "t" tags labelling the repository. + Hashtags []string +} + +// ParseRepositoryAnnouncement parses and structurally validates a +// kind:30617 event. +func ParseRepositoryAnnouncement(event *nip01.Event) (*RepositoryAnnouncement, error) { + if event.Kind != KindRepositoryAnnouncement { + return nil, fmt.Errorf("%w: got %d, want %d", ErrWrongKind, event.Kind, KindRepositoryAnnouncement) + } + + ra := &RepositoryAnnouncement{Event: event} + for _, tag := range event.Tags { + if len(tag) < 1 { + continue + } + switch tag[0] { + case "d": + if len(tag) > 1 { + ra.Identifier = tag[1] + } + case "name": + if len(tag) > 1 { + ra.Name = tag[1] + } + case "description": + if len(tag) > 1 { + ra.Description = tag[1] + } + case "web": + ra.Web = append(ra.Web, tag[1:]...) + case "clone": + ra.Clone = append(ra.Clone, tag[1:]...) + case "relays": + for _, r := range tag[1:] { + if err := validateRelayURL(r); err != nil { + return nil, err + } + ra.Relays = append(ra.Relays, r) + } + case "r": + if len(tag) > 2 && tag[2] == "euc" { + ra.EarliestUniqueCommit = tag[1] + } + case "maintainers": + for _, m := range tag[1:] { + if err := utils.Validate32Key(m); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidPubkeyTag, m, err) + } + ra.Maintainers = append(ra.Maintainers, m) + } + case "u": + if len(tag) < 2 { + continue + } + uf := &UpstreamFork{Pointer: tag[1]} + if len(tag) > 2 { + uf.RelayHint = tag[2] + } + if len(tag) > 3 { + uf.AuthorPubkey = tag[3] + } + ra.Upstream = uf + case "t": + if len(tag) > 1 { + ra.Hashtags = append(ra.Hashtags, tag[1]) + } + } + } + + if ra.Identifier == "" { + return nil, ErrMissingIdentifier + } + return ra, nil +} + +// ValidateRepositoryAnnouncement checks the signature and structure of a +// repository announcement event. +func ValidateRepositoryAnnouncement(event *nip01.Event) error { + if err := event.Verify(); err != nil { + return fmt.Errorf("%w: %w", ErrInvalidSignature, err) + } + _, err := ParseRepositoryAnnouncement(event) + return err +} + +// RepositoryAnnouncementParams describes a repository announcement to +// build. Pubkey and Identifier are required; everything else is optional. +type RepositoryAnnouncementParams struct { + Pubkey string + Identifier string + Name string + Description string + Web []string + Clone []string + Relays []string + EarliestUniqueCommit string + Maintainers []string + Upstream *UpstreamFork + Hashtags []string +} + +// NewRepositoryAnnouncement builds an unsigned kind:30617 event. Caller +// must sign it. +func NewRepositoryAnnouncement(p RepositoryAnnouncementParams) (*nip01.Event, error) { + if p.Identifier == "" { + return nil, ErrMissingIdentifier + } + for _, m := range p.Maintainers { + if err := utils.Validate32Key(m); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidPubkeyTag, m, err) + } + } + for _, r := range p.Relays { + if err := validateRelayURL(r); err != nil { + return nil, err + } + } + + tags := [][]string{{"d", p.Identifier}} + if p.Name != "" { + tags = append(tags, []string{"name", p.Name}) + } + if p.Description != "" { + tags = append(tags, []string{"description", p.Description}) + } + if len(p.Web) > 0 { + tags = append(tags, append([]string{"web"}, p.Web...)) + } + if len(p.Clone) > 0 { + tags = append(tags, append([]string{"clone"}, p.Clone...)) + } + if len(p.Relays) > 0 { + tags = append(tags, append([]string{"relays"}, p.Relays...)) + } + if p.EarliestUniqueCommit != "" { + tags = append(tags, []string{"r", p.EarliestUniqueCommit, "euc"}) + } + if len(p.Maintainers) > 0 { + tags = append(tags, append([]string{"maintainers"}, p.Maintainers...)) + } + if p.Upstream != nil { + u := []string{"u", p.Upstream.Pointer} + if p.Upstream.RelayHint != "" || p.Upstream.AuthorPubkey != "" { + u = append(u, p.Upstream.RelayHint) + } + if p.Upstream.AuthorPubkey != "" { + u = append(u, p.Upstream.AuthorPubkey) + } + tags = append(tags, u) + } + for _, t := range p.Hashtags { + tags = append(tags, []string{"t", t}) + } + + return nip01.NewUnsignedEvent(KindRepositoryAnnouncement, p.Pubkey, "", tags...), nil +} + +func validateRelayURL(raw string) error { + u, err := url.ParseRequestURI(raw) + if err != nil { + return fmt.Errorf("%w %q: %w", ErrInvalidRelayURL, raw, err) + } + if u.Scheme != "wss" && u.Scheme != "ws" { + return fmt.Errorf("%w: %q has scheme %q", ErrInvalidRelayScheme, raw, u.Scheme) + } + return nil +} + +// Ref is one "refs//" entry in a RepositoryState. +type Ref struct { + // Name is the full ref path, e.g. "refs/heads/main" or + // "refs/tags/v1.0.0". + Name string + CommitID string +} + +// RepositoryState is a parsed kind:30618 repository state event, an +// addressable event (see nip33) keyed by Identifier. It's an optional +// source of truth for the state of branches and tags in a repository. +type RepositoryState struct { + *nip01.Event + Identifier string // "d" tag; matches the corresponding announcement's Identifier + Refs []Ref + // Head is the target of the "HEAD" tag (e.g. "refs/heads/main"), with + // its "ref: " prefix stripped. Empty if the event carries no HEAD tag. + Head string +} + +// ParseRepositoryState parses and structurally validates a kind:30618 +// event. +func ParseRepositoryState(event *nip01.Event) (*RepositoryState, error) { + if event.Kind != KindRepositoryState { + return nil, fmt.Errorf("%w: got %d, want %d", ErrWrongKind, event.Kind, KindRepositoryState) + } + + rs := &RepositoryState{Event: event} + for _, tag := range event.Tags { + if len(tag) < 2 { + continue + } + switch { + case tag[0] == "d": + rs.Identifier = tag[1] + case tag[0] == "HEAD": + rs.Head = strings.TrimPrefix(tag[1], "ref: ") + case strings.HasPrefix(tag[0], "refs/"): + rs.Refs = append(rs.Refs, Ref{Name: tag[0], CommitID: tag[1]}) + } + } + + if rs.Identifier == "" { + return nil, ErrMissingIdentifier + } + return rs, nil +} + +// ValidateRepositoryState checks the signature and structure of a +// repository state event. +func ValidateRepositoryState(event *nip01.Event) error { + if err := event.Verify(); err != nil { + return fmt.Errorf("%w: %w", ErrInvalidSignature, err) + } + _, err := ParseRepositoryState(event) + return err +} + +// RepositoryStateParams describes a repository state event to build. +// Pubkey and Identifier are required; Refs and Head are optional. +type RepositoryStateParams struct { + Pubkey string + Identifier string + Refs []Ref + // Head is the target ref (e.g. "refs/heads/main"), without a "ref: " + // prefix -- NewRepositoryState adds it. + Head string +} + +// NewRepositoryState builds an unsigned kind:30618 event. Caller must sign +// it. +func NewRepositoryState(p RepositoryStateParams) (*nip01.Event, error) { + if p.Identifier == "" { + return nil, ErrMissingIdentifier + } + + tags := [][]string{{"d", p.Identifier}} + for _, ref := range p.Refs { + tags = append(tags, []string{ref.Name, ref.CommitID}) + } + if p.Head != "" { + tags = append(tags, []string{"HEAD", "ref: " + p.Head}) + } + + return nip01.NewUnsignedEvent(KindRepositoryState, p.Pubkey, "", tags...), nil +} diff --git a/nip34/repository_test.go b/nip34/repository_test.go new file mode 100644 index 0000000..deb5507 --- /dev/null +++ b/nip34/repository_test.go @@ -0,0 +1,140 @@ +package nip34 + +import ( + "errors" + "testing" + + "github.com/ohstr/nmilat/nip01" +) + +func TestNewRepositoryAnnouncementAndParse(t *testing.T) { + ev, err := NewRepositoryAnnouncement(RepositoryAnnouncementParams{ + Pubkey: ownerPubkey, + Identifier: "ngit", + Name: "ngit", + Description: "git over nostr", + Web: []string{"https://gitworkshop.dev/ngit"}, + Clone: []string{"https://github.com/example/ngit.git"}, + Relays: []string{"wss://relay.ngit.dev"}, + EarliestUniqueCommit: someEventID, + Maintainers: []string{maintainer}, + Upstream: &UpstreamFork{Pointer: "30617:" + maintainer + ":ngit", RelayHint: "wss://relay.ngit.dev", AuthorPubkey: maintainer}, + Hashtags: []string{"git", "nostr"}, + }) + if err != nil { + t.Fatalf("NewRepositoryAnnouncement() error = %v", err) + } + ev = signed(t, ev) + + ra, err := ParseRepositoryAnnouncement(ev) + if err != nil { + t.Fatalf("ParseRepositoryAnnouncement() error = %v", err) + } + if ra.Identifier != "ngit" || ra.Name != "ngit" || ra.Description != "git over nostr" { + t.Errorf("basic fields = %+v", ra) + } + if len(ra.Web) != 1 || ra.Web[0] != "https://gitworkshop.dev/ngit" { + t.Errorf("Web = %v", ra.Web) + } + if len(ra.Clone) != 1 || ra.Clone[0] != "https://github.com/example/ngit.git" { + t.Errorf("Clone = %v", ra.Clone) + } + if len(ra.Relays) != 1 || ra.Relays[0] != "wss://relay.ngit.dev" { + t.Errorf("Relays = %v", ra.Relays) + } + if ra.EarliestUniqueCommit != someEventID { + t.Errorf("EarliestUniqueCommit = %q", ra.EarliestUniqueCommit) + } + if len(ra.Maintainers) != 1 || ra.Maintainers[0] != maintainer { + t.Errorf("Maintainers = %v", ra.Maintainers) + } + if ra.Upstream == nil || ra.Upstream.AuthorPubkey != maintainer { + t.Errorf("Upstream = %+v", ra.Upstream) + } + if len(ra.Hashtags) != 2 { + t.Errorf("Hashtags = %v", ra.Hashtags) + } + + if err := ValidateRepositoryAnnouncement(ev); err != nil { + t.Errorf("ValidateRepositoryAnnouncement() error = %v", err) + } +} + +func TestNewRepositoryAnnouncement_MissingIdentifier(t *testing.T) { + if _, err := NewRepositoryAnnouncement(RepositoryAnnouncementParams{Pubkey: ownerPubkey}); !errors.Is(err, ErrMissingIdentifier) { + t.Errorf("error = %v, want ErrMissingIdentifier", err) + } +} + +func TestParseRepositoryAnnouncementErrors(t *testing.T) { + tests := []struct { + name string + kind int + tags [][]string + wantErr error + }{ + {name: "wrong kind", kind: 1, tags: nil, wantErr: ErrWrongKind}, + {name: "missing d", kind: KindRepositoryAnnouncement, tags: nil, wantErr: ErrMissingIdentifier}, + { + name: "bad relay scheme", + kind: KindRepositoryAnnouncement, + tags: [][]string{{"d", "x"}, {"relays", "https://relay.example"}}, + wantErr: ErrInvalidRelayScheme, + }, + { + name: "bad maintainer pubkey", + kind: KindRepositoryAnnouncement, + tags: [][]string{{"d", "x"}, {"maintainers", "not-a-pubkey"}}, + wantErr: ErrInvalidPubkeyTag, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ev := &nip01.Event{Kind: tt.kind, Tags: tt.tags} + _, err := ParseRepositoryAnnouncement(ev) + if !errors.Is(err, tt.wantErr) { + t.Errorf("error = %v, want wrapping %v", err, tt.wantErr) + } + }) + } +} + +func TestNewRepositoryStateAndParse(t *testing.T) { + ev, err := NewRepositoryState(RepositoryStateParams{ + Pubkey: ownerPubkey, + Identifier: "ngit", + Refs: []Ref{ + {Name: "refs/heads/main", CommitID: someEventID}, + {Name: "refs/tags/v1.0.0", CommitID: someEventID2}, + }, + Head: "refs/heads/main", + }) + if err != nil { + t.Fatalf("NewRepositoryState() error = %v", err) + } + ev = signed(t, ev) + + rs, err := ParseRepositoryState(ev) + if err != nil { + t.Fatalf("ParseRepositoryState() error = %v", err) + } + if rs.Identifier != "ngit" { + t.Errorf("Identifier = %q", rs.Identifier) + } + if len(rs.Refs) != 2 || rs.Refs[0].Name != "refs/heads/main" || rs.Refs[0].CommitID != someEventID { + t.Errorf("Refs = %+v", rs.Refs) + } + if rs.Head != "refs/heads/main" { + t.Errorf("Head = %q", rs.Head) + } + + if err := ValidateRepositoryState(ev); err != nil { + t.Errorf("ValidateRepositoryState() error = %v", err) + } +} + +func TestNewRepositoryState_MissingIdentifier(t *testing.T) { + if _, err := NewRepositoryState(RepositoryStateParams{Pubkey: ownerPubkey}); !errors.Is(err, ErrMissingIdentifier) { + t.Errorf("error = %v, want ErrMissingIdentifier", err) + } +} diff --git a/nip34/status.go b/nip34/status.go new file mode 100644 index 0000000..e217c34 --- /dev/null +++ b/nip34/status.go @@ -0,0 +1,291 @@ +package nip34 + +import ( + "errors" + "fmt" + + "github.com/ohstr/nmilat/nip01" + "github.com/ohstr/nmilat/utils" +) + +// ErrMissingRootTag is returned when a Status event has no "e" tag +// identifying the issue/PR/patch it applies to. +var ErrMissingRootTag = errors.New("nip34: status event missing root e tag") + +// QuotedPatch is a "q" tag on a KindStatusApplied event, citing one of the +// patches applied/merged. +type QuotedPatch struct { + EventID string + RelayHint string + Pubkey string +} + +// Status is a parsed kind:1630-1633 status event, setting the status of a +// root patch, PR, or issue (default status is "Open"). +type Status struct { + *nip01.Event + // Kind mirrors Event.Kind: one of KindStatusOpen/Applied/Closed/Draft. + Kind int + // RootID is the "e" tag (marked "root", or unmarked) identifying the + // issue/PR/original root patch this status applies to. + RootID string + // AcceptedRevisionID is the "e" tag marked "reply", set when a patch + // revision (rather than the original root patch) was the one applied. + AcceptedRevisionID string + // RepositoryOwner, RootAuthor, and RevisionAuthor are the "p" tags, in + // the order the spec lists them. Nothing on the wire marks which "p" + // tag is which beyond that order. + RepositoryOwner string + RootAuthor string + RevisionAuthor string + RepoAddress string // "a" tag; optional, for subscription efficiency + // CommitRefs holds every "r" tag value. The spec overloads this tag + // across an EarliestUniqueCommit hint, a KindStatusApplied + // merge-commit mirror, and per-commit KindStatusApplied + // applied-as-commits mirrors; nothing on the wire distinguishes them. + CommitRefs []string + // AppliedPatches lists the "q"-tagged patches applied/merged + // (KindStatusApplied only). + AppliedPatches []QuotedPatch + // MergeCommit is the "merge-commit" tag (KindStatusApplied, merge + // case). + MergeCommit string + // AppliedAsCommits are the "applied-as-commits" tag's commit ids + // (KindStatusApplied, apply case). + AppliedAsCommits []string +} + +// ParseStatus parses and structurally validates a kind:1630-1633 event. +func ParseStatus(event *nip01.Event) (*Status, error) { + if !IsStatusKind(event.Kind) { + return nil, fmt.Errorf("%w: got %d, want one of %d/%d/%d/%d", ErrWrongKind, event.Kind, + KindStatusOpen, KindStatusApplied, KindStatusClosed, KindStatusDraft) + } + + s := &Status{Event: event, Kind: event.Kind} + var pTagCount int + for _, tag := range event.Tags { + if len(tag) < 1 { + continue + } + switch tag[0] { + case "e": + if len(tag) < 2 { + continue + } + if err := utils.Validate32Key(tag[1]); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidEventTag, tag[1], err) + } + marker := "" + if len(tag) > 3 { + marker = tag[3] + } + if marker == "reply" { + s.AcceptedRevisionID = tag[1] + } else { + s.RootID = tag[1] + } + case "p": + if len(tag) < 2 { + continue + } + if err := utils.Validate32Key(tag[1]); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidPubkeyTag, tag[1], err) + } + pTagCount++ + switch pTagCount { + case 1: + s.RepositoryOwner = tag[1] + case 2: + s.RootAuthor = tag[1] + case 3: + s.RevisionAuthor = tag[1] + } + case "a": + if len(tag) < 2 { + continue + } + if _, _, _, err := utils.ParseATag(tag[1]); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidRepoAddress, tag[1], err) + } + s.RepoAddress = tag[1] + case "r": + if len(tag) > 1 { + s.CommitRefs = append(s.CommitRefs, tag[1]) + } + case "q": + if len(tag) < 2 { + continue + } + qp := QuotedPatch{EventID: tag[1]} + if len(tag) > 2 { + qp.RelayHint = tag[2] + } + if len(tag) > 3 { + qp.Pubkey = tag[3] + } + s.AppliedPatches = append(s.AppliedPatches, qp) + case "merge-commit": + if len(tag) > 1 { + s.MergeCommit = tag[1] + } + case "applied-as-commits": + s.AppliedAsCommits = append(s.AppliedAsCommits, tag[1:]...) + } + } + + if s.RootID == "" { + return nil, ErrMissingRootTag + } + return s, nil +} + +// ValidateStatus checks the signature and structure of a status event. +func ValidateStatus(event *nip01.Event) error { + if err := event.Verify(); err != nil { + return fmt.Errorf("%w: %w", ErrInvalidSignature, err) + } + _, err := ParseStatus(event) + return err +} + +// StatusParams describes a status event to build. Pubkey, Kind, and RootID +// are required; everything else is optional. +type StatusParams struct { + Pubkey string + Kind int // one of KindStatusOpen/Applied/Closed/Draft + RootID string + AcceptedRevisionID string + RepositoryOwner string + RootAuthor string + RevisionAuthor string + RepoAddress string + RepoAddressRelay string + CommitRefs []string + AppliedPatches []QuotedPatch + MergeCommit string + AppliedAsCommits []string + Content string +} + +// NewStatus builds an unsigned status event. Caller must sign it. +func NewStatus(p StatusParams) (*nip01.Event, error) { + if !IsStatusKind(p.Kind) { + return nil, fmt.Errorf("%w: got %d, want one of %d/%d/%d/%d", ErrWrongKind, p.Kind, + KindStatusOpen, KindStatusApplied, KindStatusClosed, KindStatusDraft) + } + if err := utils.Validate32Key(p.RootID); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidEventTag, p.RootID, err) + } + if p.RepoAddress != "" { + if _, _, _, err := utils.ParseATag(p.RepoAddress); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidRepoAddress, p.RepoAddress, err) + } + } + for _, pubkey := range []string{p.RepositoryOwner, p.RootAuthor, p.RevisionAuthor} { + if pubkey == "" { + continue + } + if err := utils.Validate32Key(pubkey); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidPubkeyTag, pubkey, err) + } + } + + tags := [][]string{{"e", p.RootID, "", "root"}} + if p.AcceptedRevisionID != "" { + if err := utils.Validate32Key(p.AcceptedRevisionID); err != nil { + return nil, fmt.Errorf("%w: %q: %w", ErrInvalidEventTag, p.AcceptedRevisionID, err) + } + tags = append(tags, []string{"e", p.AcceptedRevisionID, "", "reply"}) + } + if p.RepositoryOwner != "" { + tags = append(tags, []string{"p", p.RepositoryOwner}) + } + if p.RootAuthor != "" { + tags = append(tags, []string{"p", p.RootAuthor}) + } + if p.RevisionAuthor != "" { + tags = append(tags, []string{"p", p.RevisionAuthor}) + } + if p.RepoAddress != "" { + aTag := []string{"a", p.RepoAddress} + if p.RepoAddressRelay != "" { + aTag = append(aTag, p.RepoAddressRelay) + } + tags = append(tags, aTag) + } + for _, r := range p.CommitRefs { + tags = append(tags, []string{"r", r}) + } + for _, q := range p.AppliedPatches { + qTag := []string{"q", q.EventID} + if q.RelayHint != "" || q.Pubkey != "" { + qTag = append(qTag, q.RelayHint) + } + if q.Pubkey != "" { + qTag = append(qTag, q.Pubkey) + } + tags = append(tags, qTag) + } + if p.MergeCommit != "" { + tags = append(tags, []string{"merge-commit", p.MergeCommit}, []string{"r", p.MergeCommit}) + } + if len(p.AppliedAsCommits) > 0 { + tags = append(tags, append([]string{"applied-as-commits"}, p.AppliedAsCommits...)) + for _, c := range p.AppliedAsCommits { + tags = append(tags, []string{"r", c}) + } + } + + return nip01.NewUnsignedEvent(p.Kind, p.Pubkey, p.Content, tags...), nil +} + +// ResolveStatus picks the currently-valid status for a thread from a set +// of Status events referencing the same root, per the spec's rule: "the +// most recent Status event (by created_at date) from either the issue/ +// patch author or a maintainer is considered valid." Status events not +// authored by rootAuthorPubkey or one of maintainerPubkeys are ignored. +// Returns nil if no authorized Status event is found. +func ResolveStatus(events []*Status, rootAuthorPubkey string, maintainerPubkeys []string) *Status { + isAuthorized := func(pubkey string) bool { + if pubkey == rootAuthorPubkey { + return true + } + for _, m := range maintainerPubkeys { + if pubkey == m { + return true + } + } + return false + } + + var latest *Status + for _, s := range events { + if !isAuthorized(s.PubKey) { + continue + } + if latest == nil || s.CreatedAt > latest.CreatedAt { + latest = s + } + } + return latest +} + +// ResolveRevisionStatus derives the effective status kind for a patch +// revision (a patch series distinct from the root patch it revises), given +// rootStatus, the resolved Status of the root patch (see ResolveStatus). +// Per the spec: a revision inherits the root patch's status, unless the +// root's status is KindStatusApplied and this revision isn't the one +// tagged as accepted in that status event -- in which case the revision is +// implicitly KindStatusClosed. revisionRootEventID is the id of the +// revision's own first ("root-revision"-tagged) patch event. rootStatus +// may be nil, meaning the root patch has no status yet (implicitly Open). +func ResolveRevisionStatus(revisionRootEventID string, rootStatus *Status) int { + if rootStatus == nil { + return KindStatusOpen + } + if rootStatus.Kind == KindStatusApplied && rootStatus.AcceptedRevisionID != revisionRootEventID { + return KindStatusClosed + } + return rootStatus.Kind +} diff --git a/nip34/status_test.go b/nip34/status_test.go new file mode 100644 index 0000000..03a7bb8 --- /dev/null +++ b/nip34/status_test.go @@ -0,0 +1,135 @@ +package nip34 + +import ( + "errors" + "testing" + + "github.com/ohstr/nmilat/nip01" +) + +func TestNewStatusAndParse_Applied(t *testing.T) { + ev, err := NewStatus(StatusParams{ + Pubkey: ownerPubkey, + Kind: KindStatusApplied, + RootID: someEventID, + AcceptedRevisionID: someEventID2, + RepositoryOwner: ownerPubkey, + RootAuthor: otherUserPubkey, + RepoAddress: testRepoAddress(t), + AppliedPatches: []QuotedPatch{{EventID: someEventID, RelayHint: "wss://relay.example", Pubkey: otherUserPubkey}}, + MergeCommit: "merged123", + Content: "applied, thanks!", + }) + if err != nil { + t.Fatalf("NewStatus() error = %v", err) + } + ev = signed(t, ev) + + s, err := ParseStatus(ev) + if err != nil { + t.Fatalf("ParseStatus() error = %v", err) + } + if s.Kind != KindStatusApplied { + t.Errorf("Kind = %d", s.Kind) + } + if s.RootID != someEventID || s.AcceptedRevisionID != someEventID2 { + t.Errorf("RootID/AcceptedRevisionID = %q/%q", s.RootID, s.AcceptedRevisionID) + } + if s.RepositoryOwner != ownerPubkey || s.RootAuthor != otherUserPubkey { + t.Errorf("owner/author = %q/%q", s.RepositoryOwner, s.RootAuthor) + } + if s.RepoAddress != testRepoAddress(t) { + t.Errorf("RepoAddress = %q", s.RepoAddress) + } + if len(s.AppliedPatches) != 1 || s.AppliedPatches[0].EventID != someEventID { + t.Errorf("AppliedPatches = %+v", s.AppliedPatches) + } + if s.MergeCommit != "merged123" { + t.Errorf("MergeCommit = %q", s.MergeCommit) + } + // merge-commit also mirrors into an "r" tag per spec. + found := false + for _, r := range s.CommitRefs { + if r == "merged123" { + found = true + } + } + if !found { + t.Errorf("CommitRefs = %v, want to include merge commit", s.CommitRefs) + } + + if err := ValidateStatus(ev); err != nil { + t.Errorf("ValidateStatus() error = %v", err) + } +} + +func TestNewStatus_MissingRootID(t *testing.T) { + if _, err := NewStatus(StatusParams{Pubkey: ownerPubkey, Kind: KindStatusOpen}); err == nil { + t.Error("expected error for missing RootID") + } +} + +func TestParseStatusErrors(t *testing.T) { + tests := []struct { + name string + kind int + tags [][]string + wantErr error + }{ + {name: "wrong kind", kind: 1617, tags: nil, wantErr: ErrWrongKind}, + {name: "missing root e tag", kind: KindStatusOpen, tags: nil, wantErr: ErrMissingRootTag}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ev := &nip01.Event{Kind: tt.kind, Tags: tt.tags} + _, err := ParseStatus(ev) + if !errors.Is(err, tt.wantErr) { + t.Errorf("error = %v, want wrapping %v", err, tt.wantErr) + } + }) + } +} + +func TestResolveStatus(t *testing.T) { + rootAuthor := otherUserPubkey + strangerPubkey := someEventID // reuse a 64-hex string as a bogus pubkey; only equality matters here + + older := &Status{Event: &nip01.Event{PubKey: rootAuthor, CreatedAt: 100}, Kind: KindStatusOpen} + newer := &Status{Event: &nip01.Event{PubKey: maintainer, CreatedAt: 200}, Kind: KindStatusApplied} + fromStranger := &Status{Event: &nip01.Event{PubKey: strangerPubkey, CreatedAt: 300}, Kind: KindStatusClosed} + + got := ResolveStatus([]*Status{older, newer, fromStranger}, rootAuthor, []string{maintainer}) + if got != newer { + t.Errorf("ResolveStatus() = %+v, want the newer authorized status", got) + } + + if got := ResolveStatus(nil, rootAuthor, nil); got != nil { + t.Errorf("ResolveStatus(nil events) = %+v, want nil", got) + } + + onlyStranger := ResolveStatus([]*Status{fromStranger}, rootAuthor, []string{maintainer}) + if onlyStranger != nil { + t.Errorf("ResolveStatus() = %+v, want nil (no authorized status)", onlyStranger) + } +} + +func TestResolveRevisionStatus(t *testing.T) { + if got := ResolveRevisionStatus(someEventID, nil); got != KindStatusOpen { + t.Errorf("nil rootStatus: got %d, want KindStatusOpen", got) + } + + openRoot := &Status{Kind: KindStatusOpen} + if got := ResolveRevisionStatus(someEventID, openRoot); got != KindStatusOpen { + t.Errorf("open root: got %d, want KindStatusOpen", got) + } + + appliedThisRevision := &Status{Kind: KindStatusApplied, AcceptedRevisionID: someEventID} + if got := ResolveRevisionStatus(someEventID, appliedThisRevision); got != KindStatusApplied { + t.Errorf("accepted revision: got %d, want KindStatusApplied", got) + } + + appliedOtherRevision := &Status{Kind: KindStatusApplied, AcceptedRevisionID: someEventID2} + if got := ResolveRevisionStatus(someEventID, appliedOtherRevision); got != KindStatusClosed { + t.Errorf("non-accepted revision: got %d, want KindStatusClosed", got) + } +} diff --git a/utils/event.go b/utils/event.go index 44492f8..b4088a4 100644 --- a/utils/event.go +++ b/utils/event.go @@ -152,6 +152,23 @@ func ParseATag(tag string) (kind int, pubKey, dValue string, err error) { return kind, pubKey, items[2], nil } +// FormatATag renders kind, pubKey, and dValue as an "a" tag value +// ("::"), the counterpart to ParseATag. kind must be +// a replaceable or parameterized-replaceable kind and pubKey must be a +// valid 32-byte hex key. +func FormatATag(kind int, pubKey, dValue string) (string, error) { + if err := ValidateKind(kind); err != nil { + return "", err + } + if !nip16.IsReplaceableKind(kind) && !nip33.IsParamReplaceableKind(kind) { + return "", fmt.Errorf("%d is not a replaceable kind", kind) + } + if err := Validate32Key(pubKey); err != nil { + return "", err + } + return fmt.Sprintf("%d:%s:%s", kind, pubKey, dValue), nil +} + const hexDigits = "0123456789abcdef" // hexDigit returns the lowercase hex character for nibble (0-15). From dd3088043dd0f5fcd644012cdeffd7a4b5905fc8 Mon Sep 17 00:00:00 2001 From: naliyi <154817482+naliyi@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:35:40 +0000 Subject: [PATCH 2/2] docs(README): expand NIP-34 examples to cover every call scenario Replaces the single announce+issue example with one subsection per nip34 operation: announce a repo and publish its state, submit a patch series/revision/PR/PR-update, open an issue and thread top-level and nested replies, set and resolve all four status kinds, publish a grasp server list, build/parse every nostr:// clone URL form, and subscribe to a repository's activity (and its replies) from a relay. Every example was compile-checked and executed standalone against this branch. That review caught a real bug in the previous example: its otherPrivateKeyHex literal was 63 hex characters (odd length), which type-checks fine but panics at runtime in Sign() -- go build/vet alone would never have caught it. --- README.md | 541 +++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 516 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index f0e8976..b4a52c3 100644 --- a/README.md +++ b/README.md @@ -716,11 +716,15 @@ naddr, err := nip19.EncodeAddr(nip19.EntityPointer{ addr, err := nip19.DecodeAddr(naddr) // *nip19.EntityPointer ``` -### Announce a git repository and open an issue (NIP-34) +### NIP-34: git collaboration over Nostr -Publish a repository announcement, then a patch and an issue that reference -it by its address; replies to either follow NIP-22's `kind:1111` comment -shape via the `nip34.NewReply`/`nip34.ParseReply` convenience layer: +`nip34` covers the whole spec -- repository announcements/state, patches, +pull requests, issues, threaded replies (via `nip22`), status, grasp +lists, and `nostr://` clone URLs. Every event type follows the same +`New*`/`Parse*`/`Validate*` shape; the examples below walk through each +one, in the order a repository's activity actually happens. + +#### Announce a repository and publish its state ```go package main @@ -733,58 +737,545 @@ import ( ) func main() { - repoEv, err := nip34.NewRepositoryAnnouncement(nip34.RepositoryAnnouncementParams{ - Pubkey: pubkeyHex, - Identifier: "ngit", - Name: "ngit", - Description: "git over nostr", - Clone: []string{"https://github.com/example/ngit.git"}, - Relays: []string{"wss://relay.ngit.dev"}, - Maintainers: []string{pubkeyHex}, + announceEv, err := nip34.NewRepositoryAnnouncement(nip34.RepositoryAnnouncementParams{ + Pubkey: ownerPubkeyHex, + Identifier: "ngit", + Name: "ngit", + Description: "git over nostr", + Web: []string{"https://gitworkshop.dev/ngit"}, + Clone: []string{"https://github.com/example/ngit.git"}, + Relays: []string{"wss://relay.ngit.dev"}, + EarliestUniqueCommit: rootCommitHex, + Maintainers: []string{maintainerPubkeyHex}, + Hashtags: []string{"git", "nostr"}, + }) + if err != nil { + panic(err) + } + if err := announceEv.Sign(ownerPrivateKeyHex); err != nil { + panic(err) + } + + stateEv, err := nip34.NewRepositoryState(nip34.RepositoryStateParams{ + Pubkey: ownerPubkeyHex, + Identifier: "ngit", + Refs: []nip34.Ref{ + {Name: "refs/heads/master", CommitID: tipCommitHex}, + {Name: "refs/tags/v1.0.0", CommitID: tagCommitHex}, + }, + Head: "refs/heads/master", + }) + if err != nil { + panic(err) + } + if err := stateEv.Sign(ownerPrivateKeyHex); err != nil { + panic(err) + } + + // What a subscriber does on receipt: verify, then parse. + if err := nip34.ValidateRepositoryAnnouncement(announceEv); err != nil { + panic(err) + } + repo, err := nip34.ParseRepositoryAnnouncement(announceEv) + if err != nil { + panic(err) + } + fmt.Println(repo.Name, repo.Clone, repo.Maintainers) + + // The repo's "a" tag address, for filtering patches/issues/PRs sent to it. + repoAddr, err := utils.FormatATag(nip34.KindRepositoryAnnouncement, ownerPubkeyHex, repo.Identifier) + if err != nil { + panic(err) + } + fmt.Println("repo address:", repoAddr) +} +``` + +`ValidateRepositoryState`/`ParseRepositoryState` are the state event's own +counterparts, shown together with `New` above. + +#### Submit a patch series, a revision, and a pull request + +```go +package main + +import ( + "fmt" + + "github.com/ohstr/nmilat/nip34" +) + +func main() { + repoAddr := "30617:" + ownerPubkeyHex + ":ngit" + + // Root patch: first in the series. + rootPatch, err := nip34.NewPatch(nip34.PatchParams{ + Pubkey: contributorPubkeyHex, + Content: "diff --git a/main.go b/main.go\n...", // `git format-patch` output + RepoAddress: repoAddr, + RepositoryOwner: ownerPubkeyHex, + IsRoot: true, + Commit: newCommitHex, + ParentCommit: parentCommitHex, + Committer: &nip34.Committer{ + Name: "Ada Contributor", Email: "ada@example.com", + Timestamp: 1_700_000_000, TZOffsetMinutes: -60, + }, + }) + if err != nil { + panic(err) + } + if err := rootPatch.Sign(contributorPrivateKeyHex); err != nil { + panic(err) + } + + // Second patch in the same series: replies to the root patch. + secondPatch, err := nip34.NewPatch(nip34.PatchParams{ + Pubkey: contributorPubkeyHex, + Content: "diff --git a/util.go b/util.go\n...", + RepoAddress: repoAddr, + RepositoryOwner: ownerPubkeyHex, + ReplyTo: rootPatch.ID, + Commit: secondCommitHex, + ParentCommit: newCommitHex, + }) + if err != nil { + panic(err) + } + if err := secondPatch.Sign(contributorPrivateKeyHex); err != nil { + panic(err) + } + + // A revised series (e.g. after review feedback): its first patch is + // tagged root-revision and replies to the original root patch. + revisionPatch, err := nip34.NewPatch(nip34.PatchParams{ + Pubkey: contributorPubkeyHex, + Content: "diff --git a/main.go b/main.go\n... (v2)", + RepoAddress: repoAddr, + RepositoryOwner: ownerPubkeyHex, + IsRootRevision: true, + ReplyTo: rootPatch.ID, + }) + if err != nil { + panic(err) + } + if err := revisionPatch.Sign(contributorPrivateKeyHex); err != nil { + panic(err) + } + + // For a change too large for a patch (spec: SHOULD use a PR over 60kb), + // a pull request points at a branch on a regular git host instead. + prEv, err := nip34.NewPullRequest(nip34.PullRequestParams{ + Pubkey: contributorPubkeyHex, + Content: "Adds the cool feature described in issue #12.", + RepoAddress: repoAddr, + RepositoryOwner: ownerPubkeyHex, + Subject: "Add cool feature", + Labels: []string{"enhancement"}, + Commit: tipCommitHex, + CloneURLs: []string{"https://github.com/contributor/ngit.git"}, + BranchName: "cool-feature", }) if err != nil { panic(err) } - if err := repoEv.Sign(privateKeyHex); err != nil { + if err := prEv.Sign(contributorPrivateKeyHex); err != nil { + panic(err) + } + + // Pushed more commits to the same branch: update the PR's tip. + prUpdateEv, err := nip34.NewPullRequestUpdate(nip34.PullRequestUpdateParams{ + Pubkey: contributorPubkeyHex, + RepoAddress: repoAddr, + PullRequestEventID: prEv.ID, + PullRequestAuthor: contributorPubkeyHex, + Commit: newerTipCommitHex, + CloneURLs: []string{"https://github.com/contributor/ngit.git"}, + }) + if err != nil { + panic(err) + } + if err := prUpdateEv.Sign(contributorPrivateKeyHex); err != nil { + panic(err) + } + + // Parsing a patch back out, e.g. after fetching it from a relay. + parsed, err := nip34.ParsePatch(rootPatch) + if err != nil { panic(err) } + fmt.Println(parsed.IsRoot, parsed.Commit, parsed.Committer.Name) +} +``` + +`ParsePullRequest`/`ValidatePullRequest` and +`ParsePullRequestUpdate`/`ValidatePullRequestUpdate` mirror `ParsePatch` +above for the PR events. + +#### Open an issue and thread replies (NIP-22) + +Replies to an issue, patch, or PR follow NIP-22's `kind:1111` comment +shape; `nip34.NewReply`/`nip34.ParseReply` are a thin convenience layer +over the `nip22` package that also checks the thread actually roots at a +NIP-34 item: + +```go +package main + +import ( + "fmt" + + "github.com/ohstr/nmilat/nip34" +) - repoAddr, _ := utils.FormatATag(nip34.KindRepositoryAnnouncement, pubkeyHex, "ngit") +func main() { + repoAddr := "30617:" + ownerPubkeyHex + ":ngit" issueEv, err := nip34.NewIssue(nip34.IssueParams{ - Pubkey: otherPubkeyHex, + Pubkey: contributorPubkeyHex, Content: "The build is broken on main.", RepoAddress: repoAddr, - RepositoryOwner: pubkeyHex, + RepositoryOwner: ownerPubkeyHex, Subject: "Build broken", Labels: []string{"bug"}, }) if err != nil { panic(err) } - if err := issueEv.Sign(otherPrivateKeyHex); err != nil { + if err := issueEv.Sign(contributorPrivateKeyHex); err != nil { panic(err) } - replyEv, err := nip34.NewReply(nip34.ReplyParams{ - Pubkey: pubkeyHex, + // Top-level reply: RootEvent and ParentEvent are the same (issue). + maintainerReply, err := nip34.NewReply(nip34.ReplyParams{ + Pubkey: ownerPubkeyHex, Content: "thanks for reporting, looking into it", RootEvent: issueEv, }) if err != nil { panic(err) } - if err := replyEv.Sign(privateKeyHex); err != nil { + if err := maintainerReply.Sign(ownerPrivateKeyHex); err != nil { + panic(err) + } + + // Nested reply: RootEvent stays the issue, ParentEvent is the previous + // reply -- this is how a threaded discussion is built up. + followUp, err := nip34.NewReply(nip34.ReplyParams{ + Pubkey: contributorPubkeyHex, + Content: "any update?", + RootEvent: issueEv, + ParentEvent: maintainerReply, + }) + if err != nil { + panic(err) + } + if err := followUp.Sign(contributorPrivateKeyHex); err != nil { + panic(err) + } + + // Parsing a reply back out (e.g. after fetching from a relay): + // nip34.ParseReply is nip22.ParseComment plus a check that the thread's + // root is actually an issue/patch/PR. + comment, err := nip34.ParseReply(followUp) + if err != nil { + panic(err) + } + fmt.Println("replying to root", comment.Root.Pointer.Value, "kind", comment.Root.Kind) + fmt.Println("direct parent", comment.Parent.Pointer.Value) +} +``` + +#### Set and resolve status + +```go +package main + +import ( + "fmt" + + "github.com/ohstr/nmilat/nip01" + "github.com/ohstr/nmilat/nip34" +) + +func main() { + // Open is the implicit default; an explicit Open event re-opens a + // previously closed/applied thread. + openEv, err := nip34.NewStatus(nip34.StatusParams{ + Pubkey: contributorPubkeyHex, + Kind: nip34.KindStatusOpen, + RootID: patchEventID, + RepositoryOwner: ownerPubkeyHex, + RootAuthor: contributorPubkeyHex, + }) + if err != nil { + panic(err) + } + if err := openEv.Sign(contributorPrivateKeyHex); err != nil { + panic(err) + } + + // Applied/Merged: a maintainer merges a patch revision, citing it and + // the resulting merge commit. + appliedEv, err := nip34.NewStatus(nip34.StatusParams{ + Pubkey: ownerPubkeyHex, + Kind: nip34.KindStatusApplied, + RootID: patchEventID, + AcceptedRevisionID: revisionEventID, + RepositoryOwner: ownerPubkeyHex, + RootAuthor: contributorPubkeyHex, + RevisionAuthor: contributorPubkeyHex, + AppliedPatches: []nip34.QuotedPatch{{EventID: revisionEventID, Pubkey: contributorPubkeyHex}}, + MergeCommit: mergeCommitHex, + Content: "merged, thanks!", + }) + if err != nil { + panic(err) + } + if err := appliedEv.Sign(ownerPrivateKeyHex); err != nil { + panic(err) + } + + // Closed: rejected without merging. + closedEv, err := nip34.NewStatus(nip34.StatusParams{ + Pubkey: ownerPubkeyHex, + Kind: nip34.KindStatusClosed, + RootID: patchEventID, + RepositoryOwner: ownerPubkeyHex, + Content: "superseded by a different approach", + }) + if err != nil { + panic(err) + } + if err := closedEv.Sign(ownerPrivateKeyHex); err != nil { + panic(err) + } + + // Draft: not ready for review yet (set by the author). + draftEv, err := nip34.NewStatus(nip34.StatusParams{ + Pubkey: contributorPubkeyHex, + Kind: nip34.KindStatusDraft, + RootID: patchEventID, + }) + if err != nil { + panic(err) + } + if err := draftEv.Sign(contributorPrivateKeyHex); err != nil { + panic(err) + } + fmt.Println("closed kind:", closedEv.Kind, "draft kind:", draftEv.Kind) + + // Resolving which status actually counts: a client subscribes to every + // 1630-1633 event for a thread (see "Subscribe to a repository's + // activity" below), parses each, and picks the winner per the spec -- + // latest by created_at, from the root author or a recognized + // maintainer. A status from anyone else is ignored even if it's newer: + fetched := []*nip34.Status{ + {Event: &nip01.Event{PubKey: contributorPubkeyHex, CreatedAt: 1_700_000_000}, Kind: nip34.KindStatusOpen, RootID: patchEventID}, + {Event: &nip01.Event{PubKey: ownerPubkeyHex, CreatedAt: 1_700_000_500}, Kind: nip34.KindStatusApplied, RootID: patchEventID, AcceptedRevisionID: revisionEventID}, + {Event: &nip01.Event{PubKey: strangerPubkeyHex, CreatedAt: 1_700_001_000}, Kind: nip34.KindStatusClosed, RootID: patchEventID}, // newer, but not authorized -- ignored + } + + resolved := nip34.ResolveStatus(fetched, contributorPubkeyHex, []string{maintainerPubkeyHex, ownerPubkeyHex}) + if resolved == nil { + panic("no authorized status found") + } + fmt.Println("resolved status kind:", resolved.Kind) // KindStatusApplied + + // A patch revision inherits its root's resolved status, unless the + // root was merged and this wasn't the accepted revision -- then it's + // implicitly closed. + effective := nip34.ResolveRevisionStatus(revisionEventID, resolved) + fmt.Println("revision's effective status:", effective) // KindStatusApplied: it matches AcceptedRevisionID +} +``` + +#### Publish a grasp server list + +The git-hosting analogue of a NIP-65 relay list or NIP-B7 Blossom server +list -- the [grasp servers](https://github.com/nostr-protocol/nips/blob/master/34.md#user-grasp-list) +a user prefers for NIP-34 activity, in order: + +```go +package main + +import ( + "fmt" + + "github.com/ohstr/nmilat/nip34" +) + +func main() { + ev := nip34.NewGraspServerList(pubkeyHex, []string{ + "wss://relay.ngit.dev", + "wss://grasp.example", + }) + if err := ev.Sign(privateKeyHex); err != nil { + panic(err) + } + + list, err := nip34.ParseGraspServerList(ev) + if err != nil { + panic(err) + } + fmt.Println("preferred grasp servers, in order:", list.Servers) +} +``` + +#### Build and parse `nostr://` clone URLs + +```go +package main + +import ( + "fmt" + + "github.com/ohstr/nmilat/nip19" + "github.com/ohstr/nmilat/nip34" +) + +func main() { + // Form 1: "nostr://" -- wraps the repository announcement's + // naddr (see "Encode & decode entities" above). + naddr, err := nip19.EncodeAddr(nip19.EntityPointer{ + Identifier: "ngit", + PublicKey: pubkeyHex, + Kind: nip34.KindRepositoryAnnouncement, + Relays: []string{"wss://relay.ngit.dev"}, + }) + if err != nil { + panic(err) + } + naddrForm := nip34.BuildCloneURLFromAddr(naddr) + fmt.Println(naddrForm) // nostr://naddr1... + + parsed, err := nip34.ParseCloneURL(naddrForm) + if err != nil { + panic(err) + } + pointer, err := parsed.ResolveAddr() + if err != nil { + panic(err) + } + fmt.Println(pointer.Identifier, pointer.PublicKey, pointer.Kind) + + // Form 2/3: "nostr:///[/]" -- more + // readable, resolved by looking up the owner's relay list instead of + // embedding one. + npub, err := nip19.EncodePublicKey(pubkeyHex) + if err != nil { panic(err) } - fmt.Println("reply kind:", replyEv.Kind) // 1111 + readableForm := nip34.BuildCloneURL(npub, "relay.ngit.dev", "ngit") + fmt.Println(readableForm) // nostr://npub1.../relay.ngit.dev/ngit + + parsed2, err := nip34.ParseCloneURL(readableForm) + if err != nil { + panic(err) + } + fmt.Println(parsed2.Owner, parsed2.RelayHint, parsed2.Identifier) + + // A NIP-05 identifier works as the owner too, and the relay hint is + // optional: + nip05Form := nip34.BuildCloneURL("dev@example.com", "", "ngit") + fmt.Println(nip05Form) // nostr://dev@example.com/ngit } ``` -Status events (`nip34.NewStatus`, kinds `1630`-`1633`) close the loop, and -`nip34.ResolveStatus`/`nip34.ResolveRevisionStatus` implement the spec's -"latest status from the root author or a maintainer wins" resolution rule -for a client that has fetched every status event for a thread. +#### Subscribe to a repository's activity from a relay + +The "server/client" side of NIP-34 is just `nip01`'s generic filter +builder plus `relay/client`, the same as any other NIP here -- there's no +`nip34/client` package, since NIP-34 has no second transport to dial: + +```go +package main + +import ( + "context" + "fmt" + "net/url" + + "github.com/ohstr/nmilat/nip01" + "github.com/ohstr/nmilat/nip34" + relayclient "github.com/ohstr/nmilat/relay/client" +) + +func main() { + relayURL, _ := url.Parse("wss://relay.ngit.dev") + + // Every content kind a repository's activity can arrive as, filtered + // by the repo's own "a" tag address (the same one NewPatch/NewIssue/ + // NewPullRequest/NewStatus were given as RepoAddress). + filter := nip01.NewFilter(). + WithKinds( + nip34.KindPatch, + nip34.KindPullRequest, + nip34.KindPullRequestUpdate, + nip34.KindIssue, + nip34.KindStatusOpen, + nip34.KindStatusApplied, + nip34.KindStatusClosed, + nip34.KindStatusDraft, + ). + WithTag("a", repoAddr) + + events, err := relayclient.ReadEventsFromRelay(context.Background(), relayURL, nip01.NewSubscriptionFilterGroup(filter)) + if err != nil { + panic(err) + } + + for _, ev := range events { + if err := ev.Verify(); err != nil { + continue // bad signature, bad ID, or malformed -- skip it + } + + switch ev.Kind { + case nip34.KindPatch: + patch, err := nip34.ParsePatch(ev) + if err == nil { + fmt.Println("patch:", patch.Commit) + } + case nip34.KindPullRequest: + pr, err := nip34.ParsePullRequest(ev) + if err == nil { + fmt.Println("PR:", pr.Subject) + } + case nip34.KindPullRequestUpdate: + upd, err := nip34.ParsePullRequestUpdate(ev) + if err == nil { + fmt.Println("PR update:", upd.Commit) + } + case nip34.KindIssue: + issue, err := nip34.ParseIssue(ev) + if err == nil { + fmt.Println("issue:", issue.Subject) + } + default: + if nip34.IsStatusKind(ev.Kind) { + status, err := nip34.ParseStatus(ev) + if err == nil { + fmt.Println("status for", status.RootID, "->", status.Kind) + } + } + } + } + + // Replies (kind:1111) aren't repo-scoped by an "a" tag -- they're + // threaded off the issue/patch/PR event directly -- so subscribe to + // them by root instead: + replyFilter := nip01.NewFilter().WithKinds(1111).WithTag("E", issueEventID) + replies, err := relayclient.ReadEventsFromRelay(context.Background(), relayURL, nip01.NewSubscriptionFilterGroup(replyFilter)) + if err != nil { + panic(err) + } + for _, ev := range replies { + if reply, err := nip34.ParseReply(ev); err == nil { + fmt.Println("reply:", reply.Content) + } + } +} +``` ## Development