clone

a self-hosted multitenant nostr relay with sites, file storage, git hosting, and inbox and outbox delivery

add issues and pull requests to repository pages

Dami committed 09a911f
commit
09a911f984e7b58f67f2fc07570259acb3f40d27
parent
f9b06b9bec648c98e985ca3bd7d303d5e4809ebb
author
Dami <dami.etoile@gmail.com>
24 files changed, +1632 -27
Minternal/daemon/client_browse.go20+-
Ainternal/daemon/collaboration_acceptance_test.go292+++++++++++++++++++++
Ainternal/daemon/collaboration_browse.go411++++++++++++++++++++++++++++++
Ainternal/daemon/collaboration_browse_test.go36+++
Ainternal/daemon/git_sync_acceptance_test.go250++++++++++++++++++
Ainternal/gitrelay/collaboration_diff.go118+++++++++
Ainternal/gitrelay/collaboration_diff_test.go112++++++++
Minternal/telemetry/telemetry.go2+-
Ainternal/webui/collaboration.go30++
Ainternal/webui/collaboration.html37+++
Ainternal/webui/collaboration_test.go84++++++
Minternal/webui/components.js62+++++
Minternal/webui/page.html11+-
Minternal/webui/repo.html1+
Minternal/webui/repo_navigation.go6+-
Minternal/webui/style.css18+-
Minternal/webui/templates.go6+-
Minternal/webui/webmcp.go2+-
Minternal/webui/webmcp.js17+-
Minternal/webui/webui.go23++-
Mpackage.json3+-
Ascripts/collaboration-compose.test.mjs80++++++
Mscripts/qa/ux-fixture.mjs15+-
Mscripts/webmcp.test.mjs23++
M internal/daemon/client_browse.go +19 -1
A internal/daemon/collaboration_acceptance_test.go +292 -0
@@ -0,0 +1,292 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "fmt"7+ "net/url"8+ "strings"9+ "testing"10+11+ "github.com/FelineStateMachine/tinyrelay/internal/event"12+ "github.com/FelineStateMachine/tinyrelay/internal/relay"13+ "github.com/FelineStateMachine/tinyrelay/internal/storage"14+)15+16+const collaborationCoordinateRepo = "collaboration"17+18+func collaborationFixtureRepo(t *testing.T, tenant *Tenant) (string, string) {19+ t.Helper()20+ ctx := context.Background()21+ ownerSecret := strings.Repeat("0", 63) + "1"22+ owner := tenant.Policy().Owner23+ serviceURL, err := url.Parse(tenant.RelayURL())24+ if err != nil {25+ t.Fatal(err)26+ }27+ cloneURL := "http://" + serviceURL.Host + "/" + owner + "/" + collaborationCoordinateRepo + ".git"28+ relayURL := "ws://" + serviceURL.Host29+ announcement := event.Event{30+ Kind: event.KIND_REPO,31+ PubKey: owner,32+ CreatedAt: 100,33+ Tags: [][]string{{"d", collaborationCoordinateRepo}, {"clone", cloneURL}, {"relays", relayURL}},34+ }35+ if err := event.Sign(&announcement, ownerSecret); err != nil {36+ t.Fatal(err)37+ }38+ if _, err := tenant.router.Publish(ctx, announcement, relay.Session{PubKeys: []string{owner}}); err != nil {39+ t.Fatal(err)40+ }41+ return owner, "30617:" + owner + ":" + collaborationCoordinateRepo42+}43+44+func signedCollaborationEvent(t *testing.T, secret string, kind int, created int64, tags [][]string, content string) event.Event {45+ t.Helper()46+ e := event.Event{Kind: kind, CreatedAt: created, Tags: tags, Content: content}47+ if err := event.Sign(&e, secret); err != nil {48+ t.Fatal(err)49+ }50+ return e51+}52+53+func saveCollaborationEvents(t *testing.T, tenant *Tenant, rows ...event.Event) {54+ t.Helper()55+ for _, row := range rows {56+ if _, err := tenant.store.Save(context.Background(), row, storage.SaveOptions{Now: row.CreatedAt, SearchMode: tenant.Policy().Features.Search}); err != nil {57+ t.Fatal(err)58+ }59+ }60+}61+62+func browseCollaboration(t *testing.T, tenant *Tenant, actor, method string, args map[string]any) map[string]any {63+ t.Helper()64+ params, err := json.Marshal(args)65+ if err != nil {66+ t.Fatal(err)67+ }68+ value, err := tenant.Execute(context.Background(), actor, method, []json.RawMessage{params})69+ if err != nil {70+ t.Fatal(err)71+ }72+ result, ok := value.(map[string]any)73+ if !ok {74+ t.Fatalf("%s result = %T, want object", method, value)75+ }76+ return result77+}78+79+func browseCollaborationDetail(t *testing.T, tenant *Tenant, actor, method string, args map[string]any) collaborationDetail {80+ t.Helper()81+ params, err := json.Marshal(args)82+ if err != nil {83+ t.Fatal(err)84+ }85+ value, err := tenant.Execute(context.Background(), actor, method, []json.RawMessage{params})86+ if err != nil {87+ t.Fatal(err)88+ }89+ detail, ok := value.(collaborationDetail)90+ if !ok {91+ t.Fatalf("%s result = %T, want collaboration detail", method, value)92+ }93+ return detail94+}95+96+func TestCollaborationListPaginationKeepsTimestampTies(t *testing.T) {97+ _, tenant := testTenant(t)98+ p := tenant.Policy()99+ p.Features.Grasp = true100+ p.Features.Grasp02 = true101+ p.Features.Grasp05 = true102+ p.GuestReplies = true103+ if err := tenant.applyPolicy(context.Background(), p); err != nil {104+ t.Fatal(err)105+ }106+ owner, coordinate := collaborationFixtureRepo(t, tenant)107+ secret := strings.Repeat("1", 63) + "2"108+ var ids []string109+ for i := 0; i < 9; i++ {110+ row := signedCollaborationEvent(t, secret, event.KIND_GIT_ISSUE, 200,111+ [][]string{{"a", coordinate}, {"subject", fmt.Sprintf("Issue %02d", i)}}, "body")112+ saveCollaborationEvents(t, tenant, row)113+ ids = append(ids, row.ID)114+ }115+ seen := map[string]bool{}116+ cursor := ""117+ for page := 0; page < 20; page++ {118+ result := browseCollaboration(t, tenant, owner, "browseissues", map[string]any{"owner": owner, "repo": collaborationCoordinateRepo, "limit": 2, "cursor": cursor})119+ items, ok := result["items"].([]collaborationItem)120+ if !ok {121+ t.Fatalf("items = %T", result["items"])122+ }123+ for _, item := range items {124+ id := item.ID125+ if seen[id] {126+ t.Fatalf("duplicate item %s", id)127+ }128+ seen[id] = true129+ }130+ cursor, _ = result["next_cursor"].(string)131+ if cursor == "" {132+ break133+ }134+ }135+ if len(seen) != len(ids) {136+ t.Fatalf("pagination returned %d issues, want %d", len(seen), len(ids))137+ }138+}139+140+func TestCollaborationSearchAndStatusReachPastFirstPage(t *testing.T) {141+ _, tenant := testTenant(t)142+ p := tenant.Policy()143+ p.Features.Grasp = true144+ p.Features.Grasp02 = true145+ p.Features.Grasp05 = true146+ p.GuestReplies = true147+ if err := tenant.applyPolicy(context.Background(), p); err != nil {148+ t.Fatal(err)149+ }150+ owner, coordinate := collaborationFixtureRepo(t, tenant)151+ secret := strings.Repeat("2", 63) + "3"152+ for i := 0; i < 60; i++ {153+ row := signedCollaborationEvent(t, secret, event.KIND_GIT_ISSUE, int64(300+i),154+ [][]string{{"a", coordinate}, {"subject", fmt.Sprintf("Routine issue %02d", i)}}, "ordinary body")155+ if i == 59 {156+ row.Tags = append(row.Tags, []string{"t", "security"})157+ row.Content = "Security regression"158+ }159+ saveCollaborationEvents(t, tenant, row)160+ if i == 59 {161+ status := signedCollaborationEvent(t, strings.Repeat("0", 63)+"1", 1632, 500,162+ [][]string{{"e", row.ID}}, "closed")163+ saveCollaborationEvents(t, tenant, status)164+ }165+ }166+ result := browseCollaboration(t, tenant, owner, "browseissues", map[string]any{"owner": owner, "repo": collaborationCoordinateRepo, "limit": 2, "q": "security", "label": "security"})167+ items := result["items"].([]collaborationItem)168+ if len(items) != 1 || items[0].Status != "closed" {169+ t.Fatalf("search result = %#v", result)170+ }171+ filtered := browseCollaboration(t, tenant, owner, "browseissues", map[string]any{"owner": owner, "repo": collaborationCoordinateRepo, "limit": 2, "state": "closed"})172+ if len(filtered["items"].([]collaborationItem)) != 1 {173+ t.Fatalf("status filter = %#v", filtered)174+ }175+}176+177+func TestCollaborationReplyPaginationUsesCursorAndFiltersMalformedEvents(t *testing.T) {178+ _, tenant := testTenant(t)179+ p := tenant.Policy()180+ p.Features.Grasp = true181+ p.Features.Grasp02 = true182+ p.Features.Grasp05 = true183+ p.GuestReplies = true184+ if err := tenant.applyPolicy(context.Background(), p); err != nil {185+ t.Fatal(err)186+ }187+ owner, coordinate := collaborationFixtureRepo(t, tenant)188+ authorSecret := strings.Repeat("8", 63) + "9"189+ author, err := event.PublicKey(authorSecret)190+ if err != nil {191+ t.Fatal(err)192+ }193+ issue := signedCollaborationEvent(t, authorSecret, event.KIND_GIT_ISSUE, 800,194+ [][]string{{"a", coordinate}, {"subject", "Crowded thread"}}, "body")195+ rows := []event.Event{issue}196+ for i := 0; i < 205; i++ {197+ rows = append(rows, signedCollaborationEvent(t, authorSecret, 1111, 801,198+ [][]string{{"E", issue.ID}, {"K", "1621"}, {"P", author}}, fmt.Sprintf("reply %03d", i)))199+ }200+ rows = append(rows, signedCollaborationEvent(t, authorSecret, 1111, 801,201+ [][]string{{"E", issue.ID}, {"K", "1618"}, {"P", author}}, "wrong root kind"))202+ rows = append(rows, signedCollaborationEvent(t, authorSecret, 1111, 801,203+ [][]string{{"E", issue.ID}, {"K", "1621"}, {"P", strings.Repeat("a", 64)}}, "wrong root author"))204+ saveCollaborationEvents(t, tenant, rows...)205+ seen := map[string]bool{}206+ cursor := ""207+ for page := 0; page < 20; page++ {208+ result := browseCollaborationDetail(t, tenant, owner, "browseissue", map[string]any{"owner": owner, "repo": collaborationCoordinateRepo, "event": issue.ID, "limit": 37, "cursor": cursor})209+ if len(result.Replies) > 37 {210+ t.Fatalf("reply page has %d entries", len(result.Replies))211+ }212+ for _, reply := range result.Replies {213+ if seen[reply.ID] {214+ t.Fatalf("duplicate reply %s", reply.ID)215+ }216+ seen[reply.ID] = true217+ }218+ cursor = result.ReplyCursor219+ if cursor == "" {220+ break221+ }222+ }223+ if len(seen) != 205 {224+ t.Fatalf("reply pagination returned %d replies, want 205", len(seen))225+ }226+}227+228+func TestCollaborationDetailSeparatesRepliesAndAuthorizesStatus(t *testing.T) {229+ _, tenant := testTenant(t)230+ p := tenant.Policy()231+ p.Features.Grasp = true232+ p.Features.Grasp02 = true233+ p.Features.Grasp05 = true234+ p.GuestReplies = true235+ if err := tenant.applyPolicy(context.Background(), p); err != nil {236+ t.Fatal(err)237+ }238+ owner, coordinate := collaborationFixtureRepo(t, tenant)239+ authorSecret := strings.Repeat("3", 63) + "4"240+ author, err := event.PublicKey(authorSecret)241+ if err != nil {242+ t.Fatal(err)243+ }244+ issue := signedCollaborationEvent(t, authorSecret, event.KIND_GIT_ISSUE, 600,245+ [][]string{{"a", coordinate}, {"subject", "Needs review"}, {"t", "bug"}}, "Please investigate")246+ reply := signedCollaborationEvent(t, strings.Repeat("4", 63)+"5", 1111, 601,247+ [][]string{{"e", issue.ID}, {"E", issue.ID}, {"K", "1621"}, {"P", author}}, "I can reproduce this")248+ status := signedCollaborationEvent(t, authorSecret, 1631, 602, [][]string{{"e", issue.ID}}, "resolved")249+ forged := signedCollaborationEvent(t, strings.Repeat("5", 63)+"6", 1632, 603, [][]string{{"e", issue.ID}}, "closed")250+ saveCollaborationEvents(t, tenant, issue, reply, status, forged)251+ for _, actor := range []string{owner, author} {252+ result := browseCollaborationDetail(t, tenant, actor, "browseissue", map[string]any{"owner": owner, "repo": collaborationCoordinateRepo, "event": issue.ID})253+ if !result.CanStatus {254+ t.Fatalf("actor %s cannot status: %#v", actor, result)255+ }256+ if len(result.Replies) != 1 || len(result.Statuses) != 1 {257+ t.Fatalf("authorized detail thread leaked or duplicated events: %#v", result)258+ }259+ }260+ unauthorized := strings.Repeat("6", 63) + "7"261+ unauthorizedKey, err := event.PublicKey(unauthorized)262+ if err != nil {263+ t.Fatal(err)264+ }265+ result := browseCollaborationDetail(t, tenant, unauthorizedKey, "browseissue", map[string]any{"owner": owner, "repo": collaborationCoordinateRepo, "event": issue.ID})266+ if result.CanStatus {267+ t.Fatalf("unauthorized actor can status: %#v", result)268+ }269+}270+271+func TestPrivateCollaborationRejectsAnonymousAndWrongRepository(t *testing.T) {272+ ctx := context.Background()273+ secret := strings.Repeat("7", 64)274+ owner, err := event.PublicKey(secret)275+ if err != nil {276+ t.Fatal(err)277+ }278+ _, tenant, server := privatePeerTenant(t, "collaboration-private", owner)279+ repo, _, _ := seedPrivateRepository(t, tenant, owner, secret, "private", server.URL)280+ coordinate := "30617:" + owner + ":" + repo.Identifier281+ issue := signedCollaborationEvent(t, secret, event.KIND_GIT_ISSUE, 700,282+ [][]string{{"a", coordinate}, {"subject", "Private issue"}}, "secret")283+ saveCollaborationEvents(t, tenant, issue)284+ params := []json.RawMessage{json.RawMessage(fmt.Sprintf(`{"owner":%q,"repo":%q,"event":%q}`, owner, repo.Identifier, issue.ID))}285+ if _, err := tenant.Execute(ctx, "", "browseissue", params); err == nil {286+ t.Fatal("anonymous private collaboration detail was exposed")287+ }288+ _, _, _ = seedPrivateRepository(t, tenant, owner, secret, "other", server.URL)289+ if _, err := tenant.Execute(ctx, owner, "browseissue", []json.RawMessage{json.RawMessage(fmt.Sprintf(`{"owner":%q,"repo":%q,"event":%q}`, owner, "other", issue.ID))}); err == nil {290+ t.Fatal("collaboration root crossed repository boundary")291+ }292+}
A internal/daemon/collaboration_browse.go +411 -0
@@ -0,0 +1,411 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "errors"7+ "sort"8+ "strconv"9+ "strings"10+ "time"11+12+ "github.com/FelineStateMachine/tinyrelay/internal/event"13+ "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"14+ "github.com/FelineStateMachine/tinyrelay/internal/relay"15+ "github.com/FelineStateMachine/tinyrelay/internal/storage"16+)17+18+type collaborationItem struct {19+ ID string `json:"id"`20+ Kind int `json:"kind"`21+ Author string `json:"author"`22+ CreatedAt int64 `json:"created_at"`23+ Title string `json:"title"`24+ Content string `json:"content"`25+ Labels []string `json:"labels"`26+ Status string `json:"status"`27+}28+29+type collaborationDetail struct {30+ Repository map[string]any `json:"repository"`31+ Root event.Event `json:"root"`32+ Item collaborationItem `json:"item"`33+ Replies []event.Event `json:"replies"`34+ Updates []event.Event `json:"updates"`35+ Statuses []event.Event `json:"statuses"`36+ Diff string `json:"diff,omitempty"`37+ CanStatus bool `json:"can_status"`38+ DiffUnavailable string `json:"diff_unavailable,omitempty"`39+ DiffTruncated bool `json:"diff_truncated,omitempty"`40+ ReplyCursor string `json:"reply_cursor,omitempty"`41+}42+43+func collaborationRepository(repo gitrelay.Repository) map[string]any {44+ return map[string]any{"owner": repo.Owner, "private": repo.Private, "clone": repo.Clone, "refs": repo.Refs, "head": repo.Head}45+}46+47+func (t *Tenant) collaborationRepo(ctx context.Context, actor string, q clientBrowseRequest) (gitrelay.Repository, error) {48+ if t.git == nil || !t.Policy().Features.Grasp {49+ return gitrelay.Repository{}, errors.New("not found: Git hosting is disabled")50+ }51+ r, err := t.git.BrowseRepository(q.Owner, q.Repo)52+ if err != nil {53+ return gitrelay.Repository{}, err54+ }55+ if err := t.browseRepoAccess(ctx, actor, r); err != nil {56+ return gitrelay.Repository{}, err57+ }58+ r.Private = r.Private || t.PrivateServiceEnabled()59+ return r, nil60+}61+62+func (t *Tenant) browseCollaboration(ctx context.Context, actor string, q clientBrowseRequest, kind int) (any, error) {63+ r, err := t.collaborationRepo(ctx, actor, q)64+ if err != nil {65+ return nil, err66+ }67+ if q.Limit <= 0 || q.Limit > 100 {68+ q.Limit = 5069+ }70+ cursor, err := parseCollaborationCursor(q.Cursor)71+ if err != nil {72+ return nil, err73+ }74+ if q.State != "" && !contains([]string{"open", "resolved", "merged", "closed", "draft"}, q.State) {75+ return nil, errors.New("invalid: issue or pull request status")76+ }77+ coordinate := "30617:" + r.Owner + ":" + r.Identifier78+ filters := []event.Filter{{Kinds: []int{kind}, Tags: map[string][]string{"a": {coordinate}}}, {Kinds: []int{kind}, Tags: map[string][]string{"A": {coordinate}}}}79+ session := browseSession(t, actor)80+ if _, err := t.gate.Read(ctx, filters, session); err != nil {81+ return nil, err82+ }83+ items := make([]collaborationItem, 0, q.Limit)84+ next := ""85+ for scan := 0; scan < 100; scan++ {86+ var rows []event.Event87+ more := false88+ for _, filter := range filters {89+ page, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: session.PubKeys}, Limit: 100, Before: cursor})90+ if err != nil {91+ return nil, err92+ }93+ more = more || page.More94+ rows = append(rows, page.Events...)95+ }96+ rows = uniqueEvents(rows)97+ sortEventsNewestFirst(rows)98+ if len(rows) > 100 {99+ rows = rows[:100]100+ more = true101+ }102+ visible := make([]event.Event, 0, len(rows))103+ for _, root := range rows {104+ if t.gate.CanSee(ctx, root, session, nil) && collaborationMatches(root, q.Query) && matchesCollaborationLabel(root, q.Label) {105+ visible = append(visible, root)106+ }107+ }108+ statuses, err := t.collaborationStatuses(ctx, actor, visible, r)109+ if err != nil {110+ return nil, err111+ }112+ for _, root := range rows {113+ cursor = &storage.EventCursor{CreatedAt: root.CreatedAt, ID: root.ID}114+ status, ok := statuses[root.ID]115+ if !ok || (q.State != "" && q.State != status) {116+ continue117+ }118+ if len(items) == q.Limit {119+ return map[string]any{"repository": collaborationRepository(r), "items": items, "next_cursor": next}, nil120+ }121+ item := collaborationItemFrom(root)122+ item.Status = status123+ items = append(items, item)124+ next = collaborationCursor(item)125+ }126+ if !more {127+ return map[string]any{"repository": collaborationRepository(r), "items": items, "next_cursor": ""}, nil128+ }129+ }130+ if cursor != nil {131+ next = strconv.FormatInt(cursor.CreatedAt, 10) + ":" + cursor.ID132+ }133+ return map[string]any{"repository": collaborationRepository(r), "items": items, "next_cursor": next}, nil134+}135+136+func parseCollaborationCursor(raw string) (*storage.EventCursor, error) {137+ if raw == "" {138+ return nil, nil139+ }140+ stamp, id, ok := strings.Cut(raw, ":")141+ ts, err := strconv.ParseInt(stamp, 10, 64)142+ if !ok || err != nil || ts < 0 || len(id) != 64 || strings.Trim(id, "0123456789abcdef") != "" {143+ return nil, errors.New("invalid: collaboration cursor")144+ }145+ return &storage.EventCursor{CreatedAt: ts, ID: id}, nil146+}147+148+func matchesCollaborationLabel(root event.Event, label string) bool {149+ if label == "" {150+ return true151+ }152+ for _, value := range event.TagValues(root, "t") {153+ if strings.EqualFold(value, label) {154+ return true155+ }156+ }157+ return false158+}159+160+func (t *Tenant) browseCollaborationDetail(ctx context.Context, actor string, q clientBrowseRequest, kind int) (any, error) {161+ if len(q.Event) != 64 {162+ return nil, errors.New("invalid: collaboration event")163+ }164+ r, err := t.collaborationRepo(ctx, actor, q)165+ if err != nil {166+ return nil, err167+ }168+ rows, err := t.Query(ctx, []event.Filter{{IDs: []string{q.Event}, Limit: intPtr(1)}}, browseSession(t, actor))169+ if err != nil {170+ return nil, err171+ }172+ if len(rows) != 1 || rows[0].Kind != kind || !hasCoordinate(rows[0], r) {173+ return nil, errors.New("not found: collaboration event")174+ }175+ root := rows[0]176+ detail := collaborationDetail{Repository: collaborationRepository(r), Root: root, Item: collaborationItemFrom(root), CanStatus: actor == root.PubKey || actor == r.Owner || contains(r.Maintainers, actor)}177+ replyCursor, err := parseCollaborationCursor(q.Cursor)178+ if err != nil {179+ return nil, err180+ }181+ replyLimit := q.Limit182+ if replyLimit <= 0 || replyLimit > 200 {183+ replyLimit = 200184+ }185+ authors := append([]string{root.PubKey, r.Owner}, r.Maintainers...)186+ statuses, err := t.Query(ctx, []event.Filter{{Authors: authors, Kinds: []int{1630, 1631, 1632, 1633}, Tags: map[string][]string{"e": {root.ID}}, Limit: intPtr(100)}}, browseSession(t, actor))187+ if err != nil {188+ return nil, err189+ }190+ detail.Statuses = statuses191+ detail.Item.Status = statusFromEvents(root, statuses)192+ replyFilters := []event.Filter{193+ {Kinds: []int{1111}, Tags: map[string][]string{"E": {root.ID}, "K": {strconv.Itoa(root.Kind)}, "P": {root.PubKey}}},194+ }195+ replies, err := t.queryCollaborationThread(ctx, actor, replyFilters, replyCursor, replyLimit+1)196+ if err != nil {197+ return nil, err198+ }199+ detail.Replies = replies200+ if len(detail.Replies) > replyLimit {201+ detail.Replies = detail.Replies[:replyLimit]202+ last := detail.Replies[replyLimit-1]203+ detail.ReplyCursor = collaborationCursor(collaborationItemFrom(last))204+ }205+ if kind == event.KIND_GIT_PR {206+ detail.Updates, err = t.Query(ctx, []event.Filter{{Authors: []string{root.PubKey}, Kinds: []int{1619}, Tags: map[string][]string{"E": {root.ID}, "P": {root.PubKey}}, Limit: intPtr(100)}}, browseSession(t, actor))207+ if err != nil {208+ return nil, err209+ }210+ tipEvent := root211+ valid := make([]event.Event, 0, len(detail.Updates))212+ for _, update := range detail.Updates {213+ if event.Tag(update, "P") == root.PubKey && event.Tag(update, "E") == root.ID {214+ valid = append(valid, update)215+ }216+ }217+ detail.Updates = valid218+ if len(valid) > 0 {219+ tipEvent = valid[0]220+ }221+ diff := t.git.PRDiff(ctx, r, event.Tag(tipEvent, "merge-base"), event.Tag(tipEvent, "c"))222+ if diff.Status == "ok" {223+ detail.Diff = diff.Diff224+ detail.DiffTruncated = diff.Truncated225+ } else {226+ detail.DiffUnavailable = diff.Reason227+ }228+ }229+230+ return detail, nil231+}232+233+func collaborationItemFrom(e event.Event) collaborationItem {234+ return collaborationItem{ID: e.ID, Kind: e.Kind, Author: e.PubKey, CreatedAt: e.CreatedAt, Title: collaborationTitle(e), Content: e.Content, Labels: event.TagValues(e, "t")}235+}236+237+func collaborationTitle(e event.Event) string {238+ for _, key := range []string{"subject", "title", "name"} {239+ if value := event.Tag(e, key); value != "" {240+ return value241+ }242+ }243+ content := strings.TrimSpace(e.Content)244+ var object struct {245+ Title string `json:"title"`246+ Subject string `json:"subject"`247+ }248+ if json.Unmarshal([]byte(content), &object) == nil {249+ if object.Title != "" {250+ return object.Title251+ }252+ if object.Subject != "" {253+ return object.Subject254+ }255+ }256+ if line, _, ok := strings.Cut(content, "\n"); ok {257+ return line258+ }259+ return content260+}261+262+func collaborationMatches(e event.Event, query string) bool {263+ query = strings.ToLower(strings.TrimSpace(query))264+ return query == "" || strings.Contains(strings.ToLower(collaborationTitle(e)), query) || strings.Contains(strings.ToLower(e.Content), query) || collaborationLabels(e, query)265+}266+func collaborationLabels(e event.Event, query string) bool {267+ for _, label := range event.TagValues(e, "t") {268+ if strings.Contains(strings.ToLower(label), query) {269+ return true270+ }271+ }272+ return false273+}274+275+// collaborationStatuses resolves the status of every root on a list page with276+// one query. Only the root author, the owner and current maintainers can set a277+// status, and the newest authorized event wins.278+func (t *Tenant) collaborationStatuses(ctx context.Context, actor string, roots []event.Event, r gitrelay.Repository) (map[string]string, error) {279+ statuses := make(map[string]string, len(roots))280+ if len(roots) == 0 {281+ return statuses, nil282+ }283+ ids := make([]string, 0, len(roots))284+ authors := append([]string{r.Owner}, r.Maintainers...)285+ for _, root := range roots {286+ ids = append(ids, root.ID)287+ authors = append(authors, root.PubKey)288+ }289+ limit := 1000290+ rows, err := t.Query(ctx, []event.Filter{{Authors: uniqueStrings(authors), Kinds: []int{1630, 1631, 1632, 1633}, Tags: map[string][]string{"e": ids}, Limit: &limit}}, browseSession(t, actor))291+ if err != nil {292+ return nil, err293+ }294+ byRoot := make(map[string][]event.Event, len(roots))295+ for _, row := range rows {296+ for _, id := range event.TagValues(row, "e") {297+ byRoot[id] = append(byRoot[id], row)298+ }299+ }300+ for _, root := range roots {301+ allowed := []event.Event{}302+ for _, row := range byRoot[root.ID] {303+ if row.PubKey == root.PubKey || row.PubKey == r.Owner || contains(r.Maintainers, row.PubKey) {304+ allowed = append(allowed, row)305+ }306+ }307+ statuses[root.ID] = statusFromEvents(root, allowed)308+ }309+ return statuses, nil310+}311+312+func (t *Tenant) queryCollaborationThread(ctx context.Context, actor string, filters []event.Filter, cursor *storage.EventCursor, limit int) ([]event.Event, error) {313+ session := browseSession(t, actor)314+ if _, err := t.gate.Read(ctx, filters, session); err != nil {315+ return nil, err316+ }317+ rows := make([]event.Event, 0)318+ for _, filter := range filters {319+ page, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: session.PubKeys}, Limit: limit, Before: cursor})320+ if err != nil {321+ return nil, err322+ }323+ for _, row := range page.Events {324+ if t.gate.CanSee(ctx, row, session, nil) {325+ rows = append(rows, row)326+ }327+ }328+ }329+ rows = uniqueEvents(rows)330+ sortEventsNewestFirst(rows)331+ if len(rows) > limit {332+ rows = rows[:limit]333+ }334+ return rows, nil335+}336+337+func statusFromEvents(root event.Event, rows []event.Event) string {338+ sort.Slice(rows, func(i, j int) bool {339+ if rows[i].CreatedAt == rows[j].CreatedAt {340+ return rows[i].ID < rows[j].ID341+ }342+ return rows[i].CreatedAt > rows[j].CreatedAt343+ })344+ if len(rows) == 0 {345+ return "open"346+ }347+ statuses := map[int]string{1630: "open", 1632: "closed", 1633: "draft"}348+ if root.Kind == event.KIND_GIT_ISSUE {349+ statuses[1631] = "resolved"350+ } else {351+ statuses[1631] = "merged"352+ }353+ return statuses[rows[0].Kind]354+}355+356+func collaborationCursor(item collaborationItem) string {357+ return strconv.FormatInt(item.CreatedAt, 10) + ":" + item.ID358+}359+360+func afterCollaborationCursor(item event.Event, cursor string) bool {361+ if cursor == "" {362+ return true363+ }364+ stamp, id, ok := strings.Cut(cursor, ":")365+ if !ok {366+ return item.ID < cursor367+ }368+ timestamp, err := strconv.ParseInt(stamp, 10, 64)369+ if err != nil {370+ return false371+ }372+ return item.CreatedAt < timestamp || (item.CreatedAt == timestamp && item.ID > id)373+}374+375+func hasCoordinate(e event.Event, r gitrelay.Repository) bool {376+ coordinate := "30617:" + r.Owner + ":" + r.Identifier377+ for _, tag := range e.Tags {378+ if len(tag) > 1 && (tag[0] == "a" || tag[0] == "A") && tag[1] == coordinate {379+ return true380+ }381+ }382+ return false383+}384+func browseSession(t *Tenant, actor string) relay.Session {385+ s := relay.Session{RelayURL: t.RelayURL()}386+ if actor != "" {387+ s.PubKeys = []string{actor}388+ }389+ return s390+}391+func contains(values []string, needle string) bool {392+ for _, value := range values {393+ if value == needle {394+ return true395+ }396+ }397+ return false398+}399+400+func uniqueStrings(values []string) []string {401+ seen := make(map[string]struct{}, len(values))402+ result := make([]string, 0, len(values))403+ for _, value := range values {404+ if _, ok := seen[value]; ok {405+ continue406+ }407+ seen[value] = struct{}{}408+ result = append(result, value)409+ }410+ return result411+}
A internal/daemon/collaboration_browse_test.go +36 -0
A internal/daemon/git_sync_acceptance_test.go +250 -0
@@ -0,0 +1,250 @@
1+package daemon2+3+import (4+ "context"5+ "encoding/json"6+ "net/http"7+ "net/http/httptest"8+ "strings"9+ "sync"10+ "testing"11+ "time"12+13+ "github.com/FelineStateMachine/tinyrelay/internal/event"14+ "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"15+ "github.com/FelineStateMachine/tinyrelay/internal/relay"16+ "github.com/FelineStateMachine/tinyrelay/internal/storage"17+ "github.com/coder/websocket"18+)19+20+type gitSyncEventRelay struct {21+ mu sync.Mutex22+ events []event.Event23+ live *event.Event24+ liveSent chan struct{}25+ requests int26+}27+28+func (r *gitSyncEventRelay) ServeHTTP(w http.ResponseWriter, req *http.Request) {29+ if req.Header.Get("Accept") == "application/nostr+json" {30+ w.Header().Set("Content-Type", "application/nostr+json")31+ _, _ = w.Write([]byte(`{"supported_grasps":["GRASP-01"],"private_service":false}`))32+ return33+ }34+ conn, err := websocket.Accept(w, req, nil)35+ if err != nil {36+ return37+ }38+ defer conn.Close(websocket.StatusNormalClosure, "done")39+ _, data, err := conn.Read(req.Context())40+ if err != nil {41+ return42+ }43+ var message []json.RawMessage44+ if json.Unmarshal(data, &message) != nil || len(message) < 3 {45+ return46+ }47+ var kind, subscription string48+ _ = json.Unmarshal(message[0], &kind)49+ _ = json.Unmarshal(message[1], &subscription)50+ if kind != "REQ" {51+ if kind == "NEG-OPEN" {52+ response, _ := json.Marshal([]any{"NEG-ERR", subscription, "unsupported"})53+ _ = conn.Write(req.Context(), websocket.MessageText, response)54+ }55+ return56+ }57+ filter, err := event.ParseFilter(message[2])58+ if err != nil {59+ return60+ }61+ r.mu.Lock()62+ r.requests++63+ items := append([]event.Event(nil), r.events...)64+ r.mu.Unlock()65+ for _, item := range items {66+ if event.Matches(filter, item) {67+ response, _ := json.Marshal([]any{"EVENT", subscription, item})68+ _ = conn.Write(req.Context(), websocket.MessageText, response)69+ }70+ }71+ response, _ := json.Marshal([]any{"EOSE", subscription})72+ _ = conn.Write(req.Context(), websocket.MessageText, response)73+ if strings.HasPrefix(subscription, "tiny-live-") && r.live != nil {74+ live, _ := json.Marshal([]any{"EVENT", subscription, *r.live})75+ _ = conn.Write(req.Context(), websocket.MessageText, live)76+ if r.liveSent != nil {77+ select {78+ case r.liveSent <- struct{}{}:79+ default:80+ }81+ }82+ }83+}84+85+func (r *gitSyncEventRelay) requestCount() int {86+ r.mu.Lock()87+ defer r.mu.Unlock()88+ return r.requests89+}90+91+func TestGitSyncPeerImportsHistoricalIssueAndRootReplies(t *testing.T) {92+ _, tenant := testTenant(t)93+ tenant.app.cfg.AllowPrivateRelays = true94+ p := tenant.Policy()95+ p.Features.Grasp = true96+ p.Features.Grasp02 = true97+ p.Features.Grasp05 = true98+ if err := tenant.applyPolicy(context.Background(), p); err != nil {99+ t.Fatal(err)100+ }101+ owner := tenant.Policy().Owner102+ secret := strings.Repeat("9", 63) + "8"103+ author, err := event.PublicKey(secret)104+ if err != nil {105+ t.Fatal(err)106+ }107+ relay := &gitSyncEventRelay{}108+ server := httptest.NewServer(relay)109+ t.Cleanup(server.Close)110+ target := "ws" + strings.TrimPrefix(server.URL, "http")111+ coordinate := "30617:" + owner + ":remote"112+ root := signedCollaborationEvent(t, secret, event.KIND_GIT_ISSUE, 100,113+ [][]string{{"a", coordinate}, {"subject", "Remote issue"}}, "historical")114+ reply := signedCollaborationEvent(t, secret, 1111, 101,115+ [][]string{{"E", root.ID}, {"K", "1621"}, {"P", author}}, "remote reply")116+ relay.events = []event.Event{root, reply}117+ repo := gitrelay.Repository{Owner: owner, Identifier: "remote", Relays: []string{target}}118+ if err := tenant.gitSyncPeer(context.Background(), repo, target); err != nil {119+ t.Fatal(err)120+ }121+ rows, err := tenant.store.Query(context.Background(), event.Filter{IDs: []string{root.ID, reply.ID}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}})122+ if err != nil {123+ t.Fatal(err)124+ }125+ if len(rows.Events) != 2 {126+ t.Fatalf("synced collaboration events = %d, want 2", len(rows.Events))127+ }128+ if relay.requestCount() < 4 {129+ t.Fatalf("multi-filter sync issued %d requests, want root and reply filters", relay.requestCount())130+ }131+}132+133+func TestGitSyncPeerFeatureOffDoesNotDial(t *testing.T) {134+ _, tenant := testTenant(t)135+ relay := &gitSyncEventRelay{}136+ server := httptest.NewServer(relay)137+ t.Cleanup(server.Close)138+ target := "ws" + strings.TrimPrefix(server.URL, "http")139+ repo := gitrelay.Repository{Owner: tenant.Policy().Owner, Identifier: "remote", Relays: []string{target}}140+ if err := tenant.gitSyncPeer(context.Background(), repo, target); err == nil {141+ t.Fatal("feature-off sync unexpectedly succeeded")142+ }143+ if got := relay.requestCount(); got != 0 {144+ t.Fatalf("feature-off sync dialed relay with %d requests", got)145+ }146+}147+148+func TestGitLivePeerImportsEventAfterEOSE(t *testing.T) {149+ _, tenant := testTenant(t)150+ tenant.app.cfg.AllowPrivateRelays = true151+ p := tenant.Policy()152+ p.Features.Grasp = true153+ p.Features.Grasp02 = true154+ p.Features.Grasp05 = true155+ if err := tenant.applyPolicy(context.Background(), p); err != nil {156+ t.Fatal(err)157+ }158+ owner := tenant.Policy().Owner159+ secret := strings.Repeat("a", 63) + "b"160+ author, err := event.PublicKey(secret)161+ if err != nil {162+ t.Fatal(err)163+ }164+ remote := &gitSyncEventRelay{liveSent: make(chan struct{}, 1)}165+ server := httptest.NewServer(remote)166+ t.Cleanup(server.Close)167+ target := "ws" + strings.TrimPrefix(server.URL, "http")168+ coordinate := "30617:" + owner + ":live"169+ announcement := event.Event{Kind: event.KIND_REPO, PubKey: owner, CreatedAt: 199,170+ Tags: [][]string{{"d", "live"}, {"clone", "http://relay.test/" + owner + "/live.git"}, {"relays", "ws://relay.test"}}}171+ if err := event.Sign(&announcement, strings.Repeat("0", 63)+"1"); err != nil {172+ t.Fatal(err)173+ }174+ if _, err := tenant.router.Publish(context.Background(), announcement, relay.Session{PubKeys: []string{owner}}); err != nil {175+ t.Fatal(err)176+ }177+ root := signedCollaborationEvent(t, secret, event.KIND_GIT_ISSUE, 200,178+ [][]string{{"a", coordinate}, {"subject", "Live issue"}}, "root")179+ liveReply := signedCollaborationEvent(t, secret, 1111, 201,180+ [][]string{{"E", root.ID}, {"K", "1621"}, {"P", author}}, "after EOSE")181+ remote.events = []event.Event{root}182+ remote.live = &liveReply183+ repo := gitrelay.Repository{Owner: owner, Identifier: "live", Relays: []string{target}}184+ ctx, cancel := context.WithCancel(context.Background())185+ done := make(chan struct{})186+ go func() {187+ tenant.gitLivePeer(ctx, repo, target)188+ close(done)189+ }()190+ select {191+ case <-remote.liveSent:192+ case <-time.After(5 * time.Second):193+ cancel()194+ <-done195+ t.Fatal("live subscription did not receive post-EOSE event")196+ }197+ deadline := time.Now().Add(2 * time.Second)198+ for time.Now().Before(deadline) {199+ rows, queryErr := tenant.store.Query(context.Background(), event.Filter{IDs: []string{liveReply.ID}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}})200+ if queryErr == nil && len(rows.Events) == 1 {201+ break202+ }203+ time.Sleep(10 * time.Millisecond)204+ }205+ cancel()206+ select {207+ case <-done:208+ case <-time.After(5 * time.Second):209+ t.Fatal("live worker did not stop")210+ }211+ rows, err := tenant.store.Query(context.Background(), event.Filter{IDs: []string{liveReply.ID}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}})212+ if err != nil {213+ t.Fatal(err)214+ }215+ if len(rows.Events) != 1 {216+ t.Fatalf("post-EOSE event was not imported: %d", len(rows.Events))217+ }218+}219+220+func TestGitSyncPeerPrivatePeerReadinessRefusesBeforeWebsocket(t *testing.T) {221+ _, tenant := testTenant(t)222+ tenant.app.cfg.AllowPrivateRelays = true223+ var websocketRequests int224+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {225+ if req.Header.Get("Accept") == "application/nostr+json" {226+ w.Header().Set("Content-Type", "application/nostr+json")227+ _, _ = w.Write([]byte(`{"supported_grasps":["GRASP-01"],"private_service":false}`))228+ return229+ }230+ websocketRequests++231+ }))232+ t.Cleanup(server.Close)233+ target := "ws" + strings.TrimPrefix(server.URL, "http")234+ p := tenant.Policy()235+ p.Features.Grasp = true236+ p.Features.Grasp02 = true237+ p.Features.Grasp08 = true238+ p.Reads = "members"239+ p.PrivatePeers = []string{target}240+ if err := tenant.applyPolicy(context.Background(), p); err != nil {241+ t.Fatal(err)242+ }243+ repo := gitrelay.Repository{Owner: p.Owner, Identifier: "private", Private: true, Relays: []string{target}}244+ if err := tenant.gitSyncPeer(context.Background(), repo, target); err == nil {245+ t.Fatal("unready private peer unexpectedly accepted")246+ }247+ if websocketRequests != 0 {248+ t.Fatalf("private readiness failure opened %d websocket requests", websocketRequests)249+ }250+}
A internal/gitrelay/collaboration_diff.go +118 -0
@@ -0,0 +1,118 @@
1+package gitrelay2+3+import (4+ "context"5+ "errors"6+ "fmt"7+ "strings"8+ "time"9+)10+11+const collaborationDiffBytes = 512 * 102412+const collaborationDiffTimeout = 5 * time.Second13+14+// CollaborationDiff is the result of rendering a pull request's local Git15+// objects. Status is "ok" when Diff is authoritative and16+// "diff_unavailable" when one or both commits are not present locally.17+type CollaborationDiff struct {18+ Status string `json:"status"`19+ Base string `json:"base,omitempty"`20+ Tip string `json:"tip,omitempty"`21+ MergeBase string `json:"merge_base,omitempty"`22+ Diff string `json:"diff,omitempty"`23+ Truncated bool `json:"truncated,omitempty"`24+ Reason string `json:"reason,omitempty"`25+}26+27+// PRDiff renders a bounded diff from commits already present in the28+// repository. It never fetches or resolves a remote ref. A supplied base is29+// used only after it is verified as a local commit; when it is empty, Git's30+// merge-base with the repository target is used.31+func (g *GitRelay) PRDiff(ctx context.Context, r Repository, base, tip string) CollaborationDiff {32+ ctx, cancel := context.WithTimeout(ctx, collaborationDiffTimeout)33+ defer cancel()34+ result := CollaborationDiff{Status: "diff_unavailable", Base: base, Tip: tip}35+ if !isObjectID(base) && base != "" {36+ result.Reason = "invalid base commit"37+ return result38+ }39+ if !isObjectID(tip) {40+ result.Reason = "invalid tip commit"41+ return result42+ }43+ if err := g.localCommit(ctx, r, tip); err != nil {44+ result.Reason = "tip commit is unavailable locally"45+ return result46+ }47+ if base != "" {48+ if err := g.localCommit(ctx, r, base); err != nil {49+ result.Reason = "base commit is unavailable locally"50+ return result51+ }52+ }53+ target, err := g.targetCommit(ctx, r)54+ if err != nil {55+ result.Reason = "repository target commit is unavailable locally"56+ return result57+ }58+ if base != "" {59+ // A PR base is meaningful only when it is reachable from both sides of60+ // the comparison. Checking the current target as well prevents an old61+ // or malicious PR-only ancestor from being presented as the repository62+ // diff base.63+ if err := g.browseCommand(ctx, r, "merge-base", "--is-ancestor", base, tip).Run(); err != nil {64+ result.Reason = "base is not an ancestor of tip"65+ return result66+ }67+ if err := g.browseCommand(ctx, r, "merge-base", "--is-ancestor", base, target).Run(); err != nil {68+ result.Reason = "base is not an ancestor of repository target"69+ return result70+ }71+ result.MergeBase = base72+ } else {73+ b, err := g.browseCommand(ctx, r, "merge-base", "--", tip, target).Output()74+ if err != nil || !isObjectID(strings.TrimSpace(string(b))) {75+ result.Reason = "merge base is unavailable locally"76+ return result77+ }78+ result.MergeBase = strings.TrimSpace(string(b))79+ result.Base = result.MergeBase80+ }81+82+ b, truncated, err := readBrowseOutput(g.browseCommand(ctx, r, "diff", "--format=", "--no-ext-diff", "--no-textconv", "--no-renames", "--binary", result.MergeBase, tip, "--"), collaborationDiffBytes)83+ if err != nil {84+ result.Reason = "unable to render local diff"85+ return result86+ }87+ result.Status = "ok"88+ result.Diff = strings.ToValidUTF8(string(b), "ďż˝")89+ result.Truncated = truncated90+ return result91+}92+93+func (g *GitRelay) targetCommit(ctx context.Context, r Repository) (string, error) {94+ target := strings.TrimPrefix(r.Head, "ref: ")95+ if target == "" || !validBrowsePath(target) {96+ return "", errors.New("missing repository target")97+ }98+ b, err := g.browseCommand(ctx, r, "rev-parse", "--verify", "--end-of-options", target+"^{commit}").Output()99+ if err != nil {100+ return "", err101+ }102+ target = strings.TrimSpace(string(b))103+ if !isObjectID(target) || g.localCommit(ctx, r, target) != nil {104+ return "", errors.New("target commit unavailable")105+ }106+ return target, nil107+}108+109+func (g *GitRelay) localCommit(ctx context.Context, r Repository, oid string) error {110+ if !isObjectID(oid) {111+ return errors.New("invalid object id")112+ }113+ b, err := g.browseCommand(ctx, r, "cat-file", "-t", oid).Output()114+ if err != nil || strings.TrimSpace(string(b)) != "commit" {115+ return fmt.Errorf("commit unavailable: %s", oid)116+ }117+ return nil118+}
A internal/gitrelay/collaboration_diff_test.go +112 -0
@@ -0,0 +1,112 @@
1+package gitrelay2+3+import (4+ "context"5+ "os"6+ "os/exec"7+ "strings"8+ "testing"9+)10+11+func TestPRDiffUsesLocalCommitsAndReportsMetadata(t *testing.T) {12+ g, r, base := browseFixture(t)13+ tip := r.Refs["refs/heads/main"]14+ got := g.PRDiff(context.Background(), r, base, tip)15+ if got.Status != "ok" || got.Base != base || got.Tip != tip || got.MergeBase != base {16+ t.Fatalf("result = %+v", got)17+ }18+ if !strings.Contains(got.Diff, "+// second") || got.Truncated {19+ t.Fatalf("diff = %+v", got)20+ }21+}22+23+func TestPRDiffDoesNotTurnMissingOrUnrelatedObjectsIntoEmptyDiff(t *testing.T) {24+ g, r, base := browseFixture(t)25+ tip := r.Refs["refs/heads/main"]26+ missing := strings.Repeat("f", 40)27+ if got := g.PRDiff(context.Background(), r, base, missing); got.Status != "diff_unavailable" || got.Reason == "" {28+ t.Fatalf("missing tip result = %+v", got)29+ }30+ if got := g.PRDiff(context.Background(), r, missing, r.Refs["refs/heads/main"]); got.Status != "diff_unavailable" || got.Reason == "" {31+ t.Fatalf("missing base result = %+v", got)32+ }33+ // The first commit in the fixture is not related to an independently34+ // created root. The backend must disclose that it cannot establish a PR35+ // base instead of presenting a whole-tree comparison as a normal diff.36+ tree, err := exec.Command("git", "--git-dir", g.repoPath(r), "mktree").Output()37+ if err != nil {38+ t.Fatal(err)39+ }40+ cmd := exec.Command("git", "--git-dir", g.repoPath(r), "commit-tree", strings.TrimSpace(string(tree)), "-m", "unrelated")41+ cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.test", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.test")42+ otherBytes, err := cmd.Output()43+ if err != nil {44+ t.Fatal(err)45+ }46+ other := strings.TrimSpace(string(otherBytes))47+ if got := g.PRDiff(context.Background(), r, other, base); got.Status != "diff_unavailable" {48+ t.Fatalf("unrelated result = %+v", got)49+ }50+ // This base is an ancestor of the PR tip but belongs only to that PR;51+ // it is not an ancestor of the repository target.52+ treeBytes, err := exec.Command("git", "--git-dir", g.repoPath(r), "rev-parse", base+"^{tree}").Output()53+ if err != nil {54+ t.Fatal(err)55+ }56+ commit := func(parents ...string) string {57+ args := []string{"--git-dir", g.repoPath(r), "commit-tree", strings.TrimSpace(string(treeBytes))}58+ for _, parent := range parents {59+ args = append(args, "-p", parent)60+ }61+ args = append(args, "-m", "PR-only")62+ cmd := exec.Command("git", args...)63+ cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.test", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.test")64+ out, err := cmd.Output()65+ if err != nil {66+ t.Fatal(err)67+ }68+ return strings.TrimSpace(string(out))69+ }70+ prBase := commit(base)71+ prTip := commit(prBase)72+ if got := g.PRDiff(context.Background(), r, prBase, prTip); got.Status != "diff_unavailable" {73+ t.Fatalf("PR-only ancestor result = %+v (main tip %s)", got, tip)74+ }75+}76+77+func TestPRDiffAcceptsBaseWhenTargetBranchDiverged(t *testing.T) {78+ g, r, base := browseFixture(t)79+ tip := r.Refs["refs/heads/main"]80+ treeBytes, err := exec.Command("git", "--git-dir", g.repoPath(r), "rev-parse", base+"^{tree}").Output()81+ if err != nil {82+ t.Fatal(err)83+ }84+ commit := func(message string) string {85+ cmd := exec.Command("git", "--git-dir", g.repoPath(r), "commit-tree", strings.TrimSpace(string(treeBytes)), "-p", base, "-m", message)86+ cmd.Env = append(os.Environ(), "GIT_AUTHOR_NAME=Test", "GIT_AUTHOR_EMAIL=test@example.test", "GIT_COMMITTER_NAME=Test", "GIT_COMMITTER_EMAIL=test@example.test")87+ out, err := cmd.Output()88+ if err != nil {89+ t.Fatal(err)90+ }91+ return strings.TrimSpace(string(out))92+ }93+ target := commit("target branch")94+ if err := exec.Command("git", "--git-dir", g.repoPath(r), "update-ref", "refs/heads/target", target).Run(); err != nil {95+ t.Fatal(err)96+ }97+ r.Head = "refs/heads/target"98+ got := g.PRDiff(context.Background(), r, base, tip)99+ if got.Status != "ok" || got.MergeBase != base {100+ t.Fatalf("diverged target result = %+v", got)101+ }102+}103+104+func TestPRDiffFindsMergeBaseFromRepositoryHead(t *testing.T) {105+ g, r, base := browseFixture(t)106+ tip := r.Refs["refs/heads/main"]107+ r.Head = "refs/heads/main"108+ got := g.PRDiff(context.Background(), r, "", tip)109+ if got.Status != "ok" || got.MergeBase != tip || got.Base != tip {110+ t.Fatalf("result = %+v (base fixture commit %s)", got, base)111+ }112+}
M internal/telemetry/telemetry.go +1 -1
@@ -236,7 +236,7 @@ func latencyBuckets() []float64 {
236236 return []float64{.0005, .001, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10}237237 }238238 239-var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browse-download": {}}239+var operationNames = map[string]struct{}{"http": {}, "websocket": {}, "publish": {}, "subscribe": {}, "query": {}, "count": {}, "fanout": {}, "delivery": {}, "commit": {}, "save": {}, "parse": {}, "authorize": {}, "upgrade": {}, "callback": {}, "delivery-discovery": {}, "notification-broadcast": {}, "browserepos": {}, "browserepo": {}, "browsefiles": {}, "browsefile": {}, "browsestatus": {}, "browseissues": {}, "browsepulls": {}, "browseissue": {}, "browsepull": {}, "browse-download": {}}240240 var outcomeNames = map[string]struct{}{"success": {}, "ok": {}, "invalid": {}, "unauthorized": {}, "timeout": {}, "busy": {}, "closed": {}, "error": {}}241241 var queueNames = map[string]struct{}{"inbox": {}, "outbox": {}, "write": {}, "delivery": {}}242242 var workNames = map[string]struct{}{"delivery": {}, "push": {}, "replication": {}, "webhook": {}, "job": {}, "records-projection": {}, "site-mirror": {}, "git-metadata": {}, "catalog-owner": {}, "callback": {}, "delivery-discovery": {}, "notification-delivery": {}, "notification-broadcast": {}, "notification": {}}
A internal/webui/collaboration.go +30 -0
A internal/webui/collaboration.html +37 -0
@@ -0,0 +1,37 @@
1+{{define "collaboration"}}{{$view := repoView .Query}}{{$data := asMap .Event}}{{$coordinate := printf "30617:%s:%s" (.Query.Get "owner") (.Query.Get "repo")}}{{$issues := or (eq $view "issues") (eq $view "issue")}}2+{{if or (eq $view "issues") (eq $view "prs")}}<section id="collaboration-list">3+<h1>{{if $issues}}Issues{{else}}Pull requests{{end}}</h1>4+<form method="get" action="/repo" id="collaboration-filter">5+<input type="hidden" name="owner" value="{{.Query.Get "owner"}}"><input type="hidden" name="repo" value="{{.Query.Get "repo"}}"><input type="hidden" name="view" value="{{$view}}">6+{{$states := strs "open" "merged" "closed" "draft"}}{{if $issues}}{{$states = strs "open" "resolved" "closed" "draft"}}{{end}}<label>Status <select name="status"><option value="">all</option>{{range $state := $states}}<option value="{{$state}}"{{if eq ($.Query.Get "status") $state}} selected{{end}}>{{$state}}</option>{{end}}</select></label>7+<label>Search <input name="q" value="{{.Query.Get "q"}}"></label><label>Label <input name="label" value="{{.Query.Get "label"}}"></label><button>Filter</button>8+</form>9+{{$items := collabItems .Event}}{{if $items}}<table id="rows">10+<thead><tr><th>status</th><th>title</th><th>author</th><th>opened</th></tr></thead>11+<tbody>{{range $item := $items}}{{$item := asMap $item}}<tr><td>{{index $item "status"}}</td><td><a href="{{if $issues}}{{repoURL $.Query "issue" (str (index $item "id"))}}{{else}}{{repoURL $.Query "pr" (str (index $item "id"))}}{{end}}">{{index $item "title"}}</a>{{with collabLabels $item}} <small>{{join . ", "}}</small>{{end}}</td><td>{{template "nostrKey" (str (index $item "author"))}}</td><td><time datetime="{{datetime (index $item "created_at")}}">{{when (index $item "created_at")}}</time></td></tr>12+{{end}}</tbody></table>{{else}}<p>No matching {{if $issues}}issues{{else}}pull requests{{end}}.</p>{{end}}13+{{with index $data "next_cursor"}}<p id="pages"><a href="{{browsePageURL "/repo" $.Query "cursor" .}}">next page</a></p>{{end}}14+{{if .Actor}}<details id="collaboration-new"><summary>{{if $issues}}New issue{{else}}New pull request{{end}}</summary>15+<nostr-compose kind="{{if $issues}}1621{{else}}1618{{end}}" coordinate="{{$coordinate}}">16+<label>Title <input name="title" required maxlength="300"></label><label>Labels <input name="labels" placeholder="bug, help wanted"></label>17+{{if not $issues}}<label>Commit <input name="commit" required pattern="[0-9a-f]{40}"></label><label>Clone URL <input name="clone" type="url" required placeholder="https://example.com/project.git"></label><label>Merge base <input name="merge-base" pattern="[0-9a-f]{40}"></label>{{end}}18+<label>Description <textarea name="content" required></textarea></label><button>Sign and publish</button>19+</nostr-compose><noscript><p>JavaScript and a connected signer are required to publish.</p></noscript></details>{{else}}<p><a href="/signin">Sign in</a> to start a conversation.</p>{{end}}20+</section>21+{{else}}{{$item := asMap (index $data "item")}}<section id="collaboration-detail">22+<p><a href="{{if $issues}}{{repoURL .Query "issues" ""}}{{else}}{{repoURL .Query "prs" ""}}{{end}}">{{if $issues}}All issues{{else}}All pull requests{{end}}</a></p>23+<h1>{{index $item "title"}}</h1>24+<p>{{index $item "status"}} by {{template "nostrKey" (str (index $item "author"))}} <time datetime="{{datetime (index $item "created_at")}}">{{when (index $item "created_at")}}</time></p>25+{{with collabLabels $item}}<p>Labels: {{join . ", "}}</p>{{end}}26+<article id="collaboration-body">{{markdown (str (index $item "content"))}}</article>27+{{if not $issues}}<h2>Changes</h2>{{if index $data "diff_unavailable"}}<p>{{index $data "diff_unavailable"}}. The diff will be available after the required commits have been synchronized.</p>{{else}}{{diffHTML .Event}}{{if index $data "diff_truncated"}}<p>This diff is truncated. Clone the repository to review the complete change.</p>{{end}}{{end}}{{end}}28+<h2>Conversation</h2>29+{{$replies := collabReplies .Event}}{{if $replies}}<ol id="events">30+{{range $reply := $replies}}{{$r := asMap $reply}}<li>{{template "nostrEvent" $reply}}31+{{if $.Actor}}<details><summary>Reply to this comment</summary><nostr-compose kind="1111" coordinate="{{$coordinate}}" root="{{index $item "id"}}" root-pubkey="{{index $item "author"}}" root-kind="{{index $item "kind"}}" parent="{{index $r "id"}}" parent-pubkey="{{index $r "pubkey"}}" parent-kind="1111"><label>Reply <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose></details>{{end}}</li>32+{{end}}</ol>{{else}}<p>No replies yet.</p>{{end}}33+{{with index $data "reply_cursor"}}<p id="pages"><a href="{{browsePageURL "/repo" $.Query "cursor" .}}">older replies</a></p>{{end}}34+{{if .Actor}}<h2>Reply</h2><nostr-compose kind="1111" coordinate="{{$coordinate}}" root="{{index $item "id"}}" root-pubkey="{{index $item "author"}}" root-kind="{{index $item "kind"}}"><label>Comment <textarea name="content" required></textarea></label><button>Sign and reply</button></nostr-compose>35+{{if index $data "can_status"}}<details id="status-controls"><summary>Change status</summary><nostr-compose kind="status" coordinate="{{$coordinate}}" root="{{index $item "id"}}" root-pubkey="{{index $item "author"}}" root-kind="{{index $item "kind"}}"><label>Status <select name="status"><option value="1630">Reopen</option><option value="1631">{{if $issues}}Resolve{{else}}Mark merged{{end}}</option><option value="1632">Close</option><option value="1633">Draft</option></select></label><label>Comment <textarea name="content"></textarea></label><button>Sign and update</button></nostr-compose></details>{{end}}36+<noscript><p>JavaScript and a connected signer are required to publish.</p></noscript>{{else}}<p><a href="/signin">Sign in</a> to reply.</p>{{end}}37+</section>{{end}}{{end}}
A internal/webui/collaboration_test.go +84 -0
@@ -0,0 +1,84 @@
1+package webui2+3+import (4+ "context"5+ "encoding/json"6+ "github.com/FelineStateMachine/tinyrelay/internal/policy"7+ "html"8+ "net/http"9+ "net/http/httptest"10+ "net/url"11+ "strings"12+ "testing"13+)14+15+func TestCollaborationTypedBackendResults(t *testing.T) {16+ type item struct {17+ ID string `json:"id"`18+ Labels []string `json:"labels"`19+ }20+ value := map[string]any{"items": []item{{ID: "issue", Labels: []string{"bug"}}}, "replies": []item{{ID: "reply"}}}21+ items := collaborationItems(value)22+ if len(items) != 1 || valueMap(items[0])["id"] != "issue" {23+ t.Fatalf("typed items lost: %v", items)24+ }25+ if labels := collaborationLabels(items[0]); len(labels) != 1 || labels[0] != "bug" {26+ t.Fatalf("labels lost: %v", labels)27+ }28+ if replies := collaborationReplies(value); len(replies) != 1 || valueMap(replies[0])["id"] != "reply" {29+ t.Fatalf("typed replies lost: %v", replies)30+ }31+}32+33+type collaborationBackend struct{ *fakeBackend }34+35+func (b *collaborationBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) {36+ b.call = method37+ b.params = params38+ type item struct {39+ ID string `json:"id"`40+ Title string `json:"title"`41+ Kind int `json:"kind"`42+ Author string `json:"author"`43+ Content string `json:"content"`44+ Status string `json:"status"`45+ }46+ root := item{ID: strings.Repeat("b", 64), Title: "A visible issue", Kind: 1621, Author: b.policy.Owner, Content: "Issue body", Status: "closed"}47+ if method == "browseissues" {48+ return map[string]any{"items": []item{root}}, nil49+ }50+ if method == "browseissue" {51+ return map[string]any{"item": root, "replies": []any{}, "can_status": true, "repository": map[string]any{"owner": b.policy.Owner, "clone": []string{"https://git.example/test.git"}, "private": true}}, nil52+ }53+ return map[string]any{}, nil54+}55+56+func TestCollaborationPagesUseTypedResultsAndWorkingDetailLinks(t *testing.T) {57+ backend := &collaborationBackend{&fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}58+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})59+ if err != nil {60+ t.Fatal(err)61+ }62+ query := url.Values{"owner": {backend.policy.Owner}, "repo": {"test"}}63+ w := httptest.NewRecorder()64+ app.ServeHTTP(w, httptest.NewRequest(http.MethodGet, repoURL(query, "issues", ""), nil))65+ if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "A visible issue") {66+ t.Fatalf("list failed: %d %s", w.Code, w.Body.String())67+ }68+ link := repoURL(query, "issue", strings.Repeat("b", 64))69+ if !strings.Contains(w.Body.String(), html.EscapeString(link)) {70+ t.Fatalf("detail link missing: %s", link)71+ }72+ w = httptest.NewRecorder()73+ app.ServeHTTP(w, httptest.NewRequest(http.MethodGet, link, nil))74+ if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "Issue body") || !strings.Contains(w.Body.String(), `kind="status"`) || !strings.Contains(w.Body.String(), "https://git.example/test.git") || !strings.Contains(w.Body.String(), "<td>private</td>") {75+ t.Fatalf("detail failed: %d %s", w.Code, w.Body.String())76+ }77+ var request map[string]any78+ if err := json.Unmarshal(backend.params[0], &request); err != nil {79+ t.Fatal(err)80+ }81+ if backend.call != "browseissue" || request["event"] != strings.Repeat("b", 64) {82+ t.Fatalf("wrong detail request: %s %v", backend.call, request)83+ }84+}
M internal/webui/components.js +62 -0
@@ -23,6 +23,9 @@
2323 // <file-tools [hash="…"] [type="…"]>2424 // Copies Blossom links, encrypts and uploads a file, decrypts a share2525 // link fragment and sends a random-key file as a NIP-17 message.26+// <nostr-compose kind="…" coordinate="30617:…" [root="…" root-pubkey="…" root-kind="…"] [parent="…" …]>27+// Signs a NIP-34 issue, pull request, NIP-22 reply or status event and28+// posts it to the relay.2629 // <file-workspace> and <file-workspace-root> live in file-workspace.js;2730 // <private-services> lives in private-services.js.2831 (() => {
@@ -411,6 +414,64 @@
411414 }412415 }413416 417+ // NostrCompose signs NIP-34 issue, pull request and conversation events.418+ // The relay receives the signed event directly, so the same control works419+ // with NIP-07 and the existing NIP-46 bridge.420+ class NostrCompose extends FormElement {421+ async submit(form) {422+ if (!window.nostr?.signEvent) throw Error("Connect a signer first.");423+ const value = name => form.elements[name]?.value.trim() || "";424+ const mode = this.getAttribute("kind");425+ const kind = Number(mode === "status" ? value("status") : mode);426+ if (![1621, 1618, 1111, 1630, 1631, 1632, 1633].includes(kind)) throw Error("Choose a valid event type.");427+ const coordinate = this.getAttribute("coordinate") || "";428+ if (!/^30617:[0-9a-f]{64}:.+$/.test(coordinate)) throw Error("The repository address is missing.");429+ const tags = [["a", coordinate]];430+ if (kind === 1111 || kind >= 1630) {431+ const root = this.getAttribute("root"), pubkey = this.getAttribute("root-pubkey");432+ const rootKind = this.getAttribute("root-kind");433+ if (!isHex64(root) || !isHex64(pubkey) || !["1617", "1618", "1621"].includes(rootKind)) throw Error("The conversation address is missing.");434+ if (kind === 1111) {435+ const parent = this.getAttribute("parent") || root;436+ const parentPubkey = this.getAttribute("parent-pubkey") || pubkey;437+ const parentKind = this.getAttribute("parent-kind") || rootKind;438+ if (!isHex64(parent) || !isHex64(parentPubkey) || !["1111", rootKind].includes(parentKind)) throw Error("The reply address is invalid.");439+ tags.push(["E", root, "", pubkey], ["K", rootKind], ["P", pubkey], ["e", parent, "", parentPubkey], ["k", parentKind], ["p", parentPubkey]);440+ } else {441+ tags.push(["e", root, "", "root"], ["p", pubkey]);442+ if (coordinate.split(":")[1] !== pubkey) tags.push(["p", coordinate.split(":")[1]]);443+ }444+ }445+ if (kind === 1621 || kind === 1618) {446+ if (!value("title")) throw Error("Enter a title.");447+ tags.push(["p", coordinate.split(":")[1]], ["subject", value("title")]);448+ const labels = [...new Set(value("labels").split(",").map(label => label.trim()).filter(Boolean))];449+ labels.forEach(label => tags.push(["t", label]));450+ }451+ if (kind === 1618) {452+ const commit = value("commit"), base = value("merge-base");453+ if (!/^[0-9a-f]{40}$/.test(commit) || (base && !/^[0-9a-f]{40}$/.test(base))) throw Error("Enter a full Git commit ID.");454+ let clone; try { clone = new URL(value("clone")); } catch { throw Error("Enter a clone URL."); }455+ if (!["https:", "http:"].includes(clone.protocol) || clone.username || clone.password || clone.hash) throw Error("Enter an HTTP or HTTPS clone URL without credentials.");456+ tags.push(["c", commit], ["clone", clone.href]);457+ if (base) tags.push(["merge-base", base]);458+ }459+ const content = form.elements.content?.value || "";460+ if ((kind === 1621 || kind === 1111) && !content.trim()) throw Error("Enter a message.");461+ const unsigned = {kind, created_at: Math.floor(Date.now() / 1000), tags, content};462+ // Keep a separate copy: extensions may mutate their input while signing.463+ const expected = JSON.stringify(unsigned);464+ const event = await window.nostr.signEvent(JSON.parse(expected));465+ const actual = event && JSON.stringify({kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content});466+ if (actual !== expected || !window.NostrSigner?.verifyEvent(event)) throw Error("The signer returned an invalid or changed event.");467+ const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});468+ const result = await response.json();469+ if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the event.");470+ this.report("Published. Reload to see the update.");471+ form.reset();472+ }473+ }474+414475 class NostrKey extends HTMLElement {415476 static get observedAttributes() { return ["hex"]; }416477
@@ -775,6 +836,7 @@
775836 customElements.define("file-mirror", FileMirror);776837 customElements.define("file-tools", FileTools);777838 customElements.define("publish-list", PublishList);839+ customElements.define("nostr-compose", NostrCompose);778840 customElements.define("nostr-key", NostrKey);779841 customElements.define("json-view", JsonView);780842
M internal/webui/page.html +3 -8
@@ -62,11 +62,6 @@
6262 <script>{{script "blossom-upload.js"}}</script>6363 <script>{{script "file-messages.js"}}</script>6464 <script>{{script "file-workspace.js"}}</script>65-<script>{{script "blossom-encryption.js"}}</script>66-<script>{{script "blossom-manifests.js"}}</script>67-<script>{{script "blossom-upload.js"}}</script>68-<script>{{script "file-messages.js"}}</script>69-<script>{{script "file-workspace.js"}}</script>7065 <script>{{script "components.js"}}</script>7166 <script>{{script "private-services.js"}}</script>7267 </body>
@@ -78,7 +73,7 @@
7873 <nav><a href="/signin" aria-current="page">/signin</a></nav>7974 <p>Private relay. Sign in with a Nostr signer to continue.</p>8075 {{else if eq $kind "repo"}}<header><span><a href="/">{{.Slug}}</a>/<a href="/repos" title="{{.Query.Get "owner"}}">{{shortID (npub (.Query.Get "owner"))}}</a>/<b>{{.Query.Get "repo"}}</b></span></header>81-<nav>{{$v := repoView .Query}}<a href="{{repoURL .Query "home" ""}}"{{if eq $v "home"}} aria-current="page"{{end}}>/home</a><a href="{{repoURL .Query "tree" ""}}"{{if or (eq $v "tree") (eq $v "file")}} aria-current="page"{{end}}>/code</a><a href="{{repoURL .Query "history" ""}}"{{if or (eq $v "history") (eq $v "commit")}} aria-current="page"{{end}}>/history</a><a href="{{repoURL .Query "activity" ""}}"{{if eq $v "activity"}} aria-current="page"{{end}}>/activity</a></nav>76+<nav>{{$v := repoView .Query}}<a href="{{repoURL .Query "home" ""}}"{{if eq $v "home"}} aria-current="page"{{end}}>/home</a><a href="{{repoURL .Query "tree" ""}}"{{if or (eq $v "tree") (eq $v "file")}} aria-current="page"{{end}}>/code</a><a href="{{repoURL .Query "history" ""}}"{{if or (eq $v "history") (eq $v "commit")}} aria-current="page"{{end}}>/history</a><a href="{{repoURL .Query "issues" ""}}"{{if or (eq $v "issues") (eq $v "issue")}} aria-current="page"{{end}}>/issues</a><a href="{{repoURL .Query "prs" ""}}"{{if or (eq $v "prs") (eq $v "pr")}} aria-current="page"{{end}}>/prs</a><a href="{{repoURL .Query "activity" ""}}"{{if eq $v "activity"}} aria-current="page"{{end}}>/activity</a></nav>8277 <p>&larr; <a href="/repos">all repositories</a></p>8378 {{else if eq $kind "manage"}}<header><span><a href="/">{{.Slug}}</a>/<b>manage</b></span></header>8479 <nav>{{range $item := manageItems}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>
@@ -114,9 +109,9 @@
114109 {{define "panel-feed"}}<section><h4>Filters</h4><form method="get" action="/{{.Tab}}"><label for="filter-kinds">kinds</label><select id="filter-kinds" name="kinds"><option value="">notes and articles</option><option value="1"{{if eq (.Query.Get "kinds") "1"}} selected{{end}}>1 notes</option><option value="30023"{{if eq (.Query.Get "kinds") "30023"}} selected{{end}}>30023 articles</option><option value="7"{{if eq (.Query.Get "kinds") "7"}} selected{{end}}>7 reactions</option><option value="all"{{if eq (.Query.Get "kinds") "all"}} selected{{end}}>everything</option></select><label for="filter-author">author</label><input id="filter-author" name="author" placeholder="64 hex pubkey" value="{{.Query.Get "author"}}"><label for="filter-since">since</label><input id="filter-since" name="since" type="date" value="{{.Query.Get "since"}}">{{if eq .Tab "search"}}<label for="filter-q">search</label><input id="filter-q" name="q" value="{{.Query.Get "q"}}">{{end}}<button>apply</button></form></section>115110 {{if .Actor}}<section><h4>You</h4><table><tr><th>key</th><td>{{template "nostrKey" .Actor}}</td></tr></table></section>{{end}}{{end}}116111 117-{{define "panel-repo"}}{{$r := asMap .Event}}<section><h4>About</h4><table><tr><th>owner</th><td>{{template "nostrKey" (str (index $r "owner"))}}</td></tr><tr><th>visibility</th><td>{{if index $r "private"}}private{{else}}public{{end}}</td></tr>{{with index $r "commit"}}<tr><th>head</th><td><code title="{{.}}">{{shortID .}}</code></td></tr>{{end}}</table></section>112+{{define "panel-repo"}}{{$r := asMap .Event}}{{with index $r "repository"}}{{$r = asMap .}}{{end}}<section><h4>About</h4><table><tr><th>owner</th><td>{{template "nostrKey" (str (index $r "owner"))}}</td></tr><tr><th>visibility</th><td>{{if index $r "private"}}private{{else}}public{{end}}</td></tr>{{with index $r "commit"}}<tr><th>head</th><td><code title="{{.}}">{{shortID .}}</code></td></tr>{{end}}</table></section>118113 <section><h4>Clone</h4><ul>{{range index $r "clone"}}<li><code>{{.}}</code></li>{{else}}<li>no clone URL advertised</li>{{end}}</ul></section>119-{{with index $r "refs"}}<section><h4>Refs</h4><form method="get" action="/repo"><input type="hidden" name="owner" value="{{$.Query.Get "owner"}}"><input type="hidden" name="repo" value="{{$.Query.Get "repo"}}"><input type="hidden" name="path" value="{{$.Query.Get "path"}}"><input type="hidden" name="view" value="{{repoView $.Query}}"><label for="ref-select">branch or tag</label><select id="ref-select" name="ref"><option value="">default branch</option>{{range $name, $oid := .}}<option value="{{$name}}"{{if eq ($.Query.Get "ref") $name}} selected{{end}}>{{$name}}</option>{{end}}</select><button>go</button></form></section>{{end}}{{end}}114+{{with index $r "refs"}}<section><h4>Refs</h4><form method="get" action="/repo"><input type="hidden" name="owner" value="{{$.Query.Get "owner"}}"><input type="hidden" name="repo" value="{{$.Query.Get "repo"}}"><input type="hidden" name="path" value="{{$.Query.Get "path"}}"><input type="hidden" name="id" value="{{$.Query.Get "id"}}"><input type="hidden" name="view" value="{{repoView $.Query}}"><label for="ref-select">branch or tag</label><select id="ref-select" name="ref"><option value="">default branch</option>{{range $name, $oid := .}}<option value="{{$name}}"{{if eq ($.Query.Get "ref") $name}} selected{{end}}>{{$name}}</option>{{end}}</select><button>go</button></form></section>{{end}}{{end}}120115 121116 {{define "panel-manage"}}<section><h4>Policy</h4><table><tr><th>name</th><td>{{.Policy.Name}}</td></tr><tr><th>reads</th><td>{{.Policy.Reads}}</td></tr><tr><th>writes</th><td>{{.Policy.Writes}}</td></tr><tr><th>owner</th><td>{{template "nostrKey" .Policy.Owner}}</td></tr></table></section>122117 <section><h4>Session</h4><table><tr><th>you</th><td>{{if .Actor}}{{template "nostrKey" .Actor}}{{else}}guest{{end}}</td></tr><tr><th>role</th><td>{{if .Owner}}owner{{else if .Actor}}member{{else}}none{{end}}</td></tr></table></section>
M internal/webui/repo.html +1 -0
M internal/webui/repo_navigation.go +5 -1
M internal/webui/style.css +10 -8
@@ -20,7 +20,7 @@ pre{white-space:pre-wrap;overflow-wrap:anywhere}
2020 img{max-width:100%}2121 2222 /* Selection: chrome is not selectable, content and values are. */23-#rail,#footer,#topbar,#panel h4,#panel label,#panel th,#rows th,#grid,#gridhead,#tree header small,#tree li>span,#preview header,button,summary,rpc-form label,signed-form label,publish-list label,file-tools label,file-workspace label,nostr-event>header,diff-view caption small{user-select:none;-webkit-user-select:none}23+#rail,#footer,#topbar,#panel h4,#panel label,#panel th,#rows th,#grid,#gridhead,#tree header small,#tree li>span,#preview header,button,summary,rpc-form label,signed-form label,publish-list label,nostr-compose label,file-tools label,file-workspace label,nostr-event>header,diff-view caption small{user-select:none;-webkit-user-select:none}2424 2525 /* Shell: a table with three columns; each column scrolls on its own. */2626 #topbar{display:none}
@@ -76,8 +76,10 @@ img{max-width:100%}
7676 #rows th:last-child{text-align:right}7777 #rows td a{color:inherit}#rows td:first-child a{color:var(--accent)}7878 #pages{font:12px var(--mono)}79-#file-search{margin:0 0 14px;padding:0 0 8px;border-bottom:1px solid var(--line)}79+#collaboration-filter,#file-search{margin:0 0 14px;padding:0 0 8px;border-bottom:1px solid var(--line)}80+#collaboration-body{max-width:720px}8081 #file-actions{max-width:720px;margin:0 0 18px}82+#events li>details{margin:0 0 10px;font:12px var(--mono)}#events li>details>summary{cursor:pointer;color:var(--muted)}8183 8284 /* Data tables inside content, including json-view results. */8385 scroll-box{display:block;overflow-x:auto;max-width:100%}
@@ -105,14 +107,14 @@ dl{margin:.5rem 0}dt{font-weight:600}dd{margin:0 0 .35rem 1rem}
105107 #readme code{font-size:12.5px}#readme ul,#readme ol{padding-left:20px}106108 107109 /* rpc-form, signed-form, publish-list: one signed call each. */108-rpc-form,signed-form,publish-list,file-tools,file-workspace,file-workspace-root:not([hidden]){display:block;border-bottom:1px solid var(--line);padding:10px 0}109-file-tools,file-workspace,file-workspace-root{max-width:720px}110+rpc-form,signed-form,publish-list,nostr-compose,file-tools,file-workspace,file-workspace-root:not([hidden]){display:block;border-bottom:1px solid var(--line);padding:10px 0}111+nostr-compose,file-tools,file-workspace,file-workspace-root{max-width:720px}110112 file-tools h3,file-workspace h3{margin:0 0 4px}file-tools p,file-workspace p{margin:6px 0}111-rpc-form label,signed-form label,publish-list label,file-tools label,file-workspace label,#file-search label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}112-rpc-form label input,rpc-form label select,signed-form label input,file-tools label input,file-tools label select,file-workspace label input,#file-search label input{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}113+rpc-form label,signed-form label,publish-list label,nostr-compose label,file-tools label,file-workspace label,#collaboration-filter label,#file-search label{display:inline-block;margin:0 10px 6px 0;vertical-align:top;font:13px var(--sans);color:var(--muted)}114+rpc-form label input,rpc-form label select,signed-form label input,nostr-compose label input,nostr-compose label select,file-tools label input,file-tools label select,file-workspace label input,#collaboration-filter label input,#collaboration-filter label select,#file-search label input{display:block;margin-top:2px;font:13px var(--mono);min-width:200px}113115 file-tools label:has([data-share-url]),file-workspace label:has([data-tree-share]){display:block}file-tools input[data-share-url],file-workspace input[data-tree-share]{width:100%}114-rpc-form label:has(textarea),publish-list label:has(textarea){display:block}115-rpc-form output,signed-form output,publish-list output,file-tools output,file-workspace output,file-workspace-root output,private-services output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px;overflow-wrap:anywhere}116+rpc-form label:has(textarea),publish-list label:has(textarea),nostr-compose label:has(textarea){display:block}117+rpc-form output,signed-form output,publish-list output,nostr-compose output,file-tools output,file-workspace output,file-workspace-root output,private-services output{display:block;font:12px var(--mono);color:var(--muted);margin-top:4px;overflow-wrap:anywhere}116118 rpc-form output[data-error],file-tools output[data-error],file-workspace output[data-error]{color:var(--alert)}117119 file-workspace-root ul{list-style:none;margin:8px 0 0;padding:0 0 0 16px;border-left:1px solid var(--line)}file-workspace-root li{padding:3px 0}118120 file-workspace-root button{background:transparent;border:0;padding:0;color:var(--accent);font:inherit;text-align:left}
M internal/webui/templates.go +5 -1
M internal/webui/webmcp.go +1 -1
M internal/webui/webmcp.js +14 -3
@@ -82,8 +82,19 @@
8282 register("tiny.read_repository", "Read a repository tree, source file, history, commit diff or activity. Use ref to select a branch, tag or commit.",8383 object({...repository, offset: {type: "integer", minimum: 0}, limit}, ["owner", "repo"]), reads,8484 (input, signal) => query("browserepo", input, signal));85+ const collaborationList = object({owner: pubkey, repo: id, cursor: text, limit, q: text, label: text,86+ state: {type: "string", enum: ["open", "resolved", "merged", "closed", "draft"]}}, ["owner", "repo"]);87+ const collaborationDetail = object({owner: pubkey, repo: id, event: hash, cursor: text, limit}, ["owner", "repo", "event"]);88+ register("tiny.list_issues", "List repository issues with search, status filters and pagination.",89+ collaborationList, reads, (input, signal) => query("browseissues", input, signal));90+ register("tiny.read_issue", "Read an issue, its replies and authorized status changes.",91+ collaborationDetail, reads, (input, signal) => query("browseissue", input, signal));92+ register("tiny.list_pull_requests", "List repository pull requests with search, status filters and pagination.",93+ collaborationList, reads, (input, signal) => query("browsepulls", input, signal));94+ register("tiny.read_pull_request", "Read a pull request, its replies, authorized updates and available diff.",95+ collaborationDetail, reads, (input, signal) => query("browsepull", input, signal));8596 register("tiny.list_files", "List stored files visible to your account.",86- object({cursor: text, limit}), reads, (input, signal) => query("browsefiles", input, signal));97+ object({cursor: text, limit, q: text}), reads, (input, signal) => query("browsefiles", input, signal));8798 register("tiny.read_file", "Read a stored file's metadata and available preview by SHA-256 hash.",8899 object({hash}, ["hash"]), reads, (input, signal) => query("browsefile", input, signal));89100 register("tiny.read_status", "Read service health, storage and job status. Requires a signed-in owner or moderator session.",
@@ -108,13 +119,13 @@
108119 }109120 function repoURL(input) {110121 const params = new URLSearchParams();111- for (const key of ["owner", "repo", "ref", "path", "view"]) {122+ for (const key of ["owner", "repo", "ref", "path", "view", "id"]) {112123 if (input[key] !== undefined) params.set(key, input[key]);113124 }114125 return "/repo?" + params;115126 }116127 register("tiny.open_repository", "Open a repository in this tab for browsing code, history or activity.",117- object(repository, ["owner", "repo"]), {}, input => open(repoURL(input)));128+ object({...repository, id: hash, view: {type: "string", enum: ["tree", "file", "history", "commit", "activity", "issues", "prs", "issue", "pr"]}}, ["owner", "repo"]), {}, input => open(repoURL(input)));118129 register("tiny.open_file", "Open a stored file by its SHA-256 hash in this tab. For repository source, use tiny.open_repository with view=file.",119130 object({hash}, ["hash"]), {}, input => open("/file?hash=" + encodeURIComponent(input.hash)));120131 register("tiny.open_status", "Open the relay status page in this tab.", object(), {}, () => open("/manage/status"));
M internal/webui/webui.go +22 -1
@@ -489,7 +489,28 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
489489 if parsed, parseErr := strconv.Atoi(request.URL.Query().Get("offset")); parseErr == nil && parsed >= 0 {490490 offset = parsed491491 }492- query = map[string]any{"owner": request.URL.Query().Get("owner"), "repo": request.URL.Query().Get("repo"), "ref": request.URL.Query().Get("ref"), "path": request.URL.Query().Get("path"), "view": view, "offset": offset, "limit": 50}492+ backendView := view493+ if view == "issues" {494+ method = "browseissues"495+ backendView = ""496+ }497+ if view == "prs" {498+ method = "browsepulls"499+ backendView = ""500+ }501+ if view == "issue" {502+ method = "browseissue"503+ backendView = ""504+ }505+ if view == "pr" {506+ method = "browsepull"507+ backendView = ""508+ }509+ eventID := request.URL.Query().Get("id")510+ if eventID == "" && (view == "issue" || view == "pr") {511+ eventID = request.URL.Query().Get("path")512+ }513+ query = map[string]any{"owner": request.URL.Query().Get("owner"), "repo": request.URL.Query().Get("repo"), "event": eventID, "q": request.URL.Query().Get("q"), "state": request.URL.Query().Get("status"), "label": request.URL.Query().Get("label"), "cursor": request.URL.Query().Get("cursor"), "ref": request.URL.Query().Get("ref"), "path": request.URL.Query().Get("path"), "view": backendView, "offset": offset, "limit": 100}493514 case "browsefile":494515 query = map[string]any{"hash": request.URL.Query().Get("hash")}495516 if query["hash"] == "" {
M package.json +2 -1
A scripts/collaboration-compose.test.mjs +80 -0
@@ -0,0 +1,80 @@
1+import test from "node:test";2+import assert from "node:assert/strict";3+import fs from "node:fs";4+import vm from "node:vm";5+import { finalizeEvent, generateSecretKey, verifyEvent } from "nostr-tools";6+7+const root = "a".repeat(64), author = "b".repeat(64), parent = "c".repeat(64), parentAuthor = "d".repeat(64);8+const coordinate = `30617:${author}:test`;9+const source = fs.readFileSync("internal/webui/components.js", "utf8");10+const composeSource = source.slice(source.indexOf(" class NostrCompose "), source.indexOf(" class NostrKey "));11+function setup(attributes, values = {}, options = {}) {12+ const sent = [], signed = [];13+ let resets = 0;14+ class FormElement {15+ getAttribute(name) { return attributes[name] ?? null; }16+ report(message) { this.message = message; }17+ }18+ const window = {NostrSigner: {verifyEvent}, nostr: {signEvent: async event => {19+ signed.push(structuredClone(event));20+ if (options.change) event.content = "changed";21+ return finalizeEvent(structuredClone(event), generateSecretKey());22+ }}};23+ const tiny = {signedFetch: async (path, method, body) => {24+ sent.push({path, method, event: JSON.parse(body)});25+ return Response.json(options.result ?? {accepted: true});26+ }};27+ const Constructor = vm.runInNewContext(`${composeSource}\nNostrCompose`, {FormElement, window, tiny, URL, isHex64: value => /^[0-9a-f]{64}$/.test(value || "")});28+ const component = new Constructor();29+ const form = {elements: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, {value}])), reset: () => { resets++; }};30+ return {component, form, sent, signed, resets: () => resets};31+}32+const rootAttrs = {coordinate, root, "root-pubkey": author, "root-kind": "1621"};33+34+test("issue composition signs subject, deduplicated labels and body", async () => {35+ const s = setup({kind: "1621", coordinate}, {title: " Bug ", labels: "ui, bug, ui", content: "Markdown **body**"});36+ await s.component.submit(s.form);37+ assert.deepEqual(s.sent[0].event.tags, [["a", coordinate], ["p", author], ["subject", "Bug"], ["t", "ui"], ["t", "bug"]]);38+ assert.equal(s.sent[0].path, "/events");39+ assert.equal(s.sent[0].event.kind, 1621);40+ assert.equal(verifyEvent(s.sent[0].event), true);41+ assert.equal(s.resets(), 1);42+});43+44+test("nested NIP-22 replies preserve the root and address the immediate parent", async () => {45+ const s = setup({...rootAttrs, kind: "1111", parent, "parent-pubkey": parentAuthor, "parent-kind": "1111"}, {content: "A reply"});46+ await s.component.submit(s.form);47+ assert.deepEqual(s.sent[0].event.tags, [["a", coordinate], ["E", root, "", author], ["K", "1621"], ["P", author], ["e", parent, "", parentAuthor], ["k", "1111"], ["p", parentAuthor]]);48+});49+50+test("status selector signs a NIP-34 root marker with an optional empty body", async () => {51+ const s = setup({...rootAttrs, kind: "status"}, {status: "1632", content: ""});52+ await s.component.submit(s.form);53+ assert.equal(s.sent[0].event.kind, 1632);54+ assert.deepEqual(s.sent[0].event.tags, [["a", coordinate], ["e", root, "", "root"], ["p", author]]);55+});56+57+test("PRs publish the commit, clone and merge-base tags", async () => {58+ const commit = "1".repeat(40), base = "2".repeat(40);59+ const s = setup({kind: "1618", coordinate}, {title: "Change", commit, "merge-base": base, clone: "https://example.test/project.git", content: "Why"});60+ await s.component.submit(s.form);61+ assert.deepEqual(s.sent[0].event.tags.slice(-3), [["c", commit], ["clone", "https://example.test/project.git"], ["merge-base", base]]);62+});63+64+test("relay rejection and signer substitution retain the draft", async () => {65+ for (const options of [{result: {accepted: false, message: "blocked"}}, {change: true}]) {66+ const s = setup({kind: "1621", coordinate}, {title: "Bug", content: "Details"}, options);67+ await assert.rejects(s.component.submit(s.form), /blocked|changed/);68+ assert.equal(s.resets(), 0);69+ if (options.change) assert.equal(s.sent.length, 0);70+ }71+});72+73+test("invalid conversation addresses and PR commit IDs are rejected before signing", async () => {74+ for (const [attrs, values] of [[{...rootAttrs, kind: "1111", parent: "bad"}, {content: "Reply"}], [{kind: "1618", coordinate}, {title: "PR", commit: "main", clone: "https://example.test/repo"}]]) {75+ const s = setup(attrs, values);76+ await assert.rejects(s.component.submit(s.form));77+ assert.equal(s.signed.length, 0);78+ assert.equal(s.sent.length, 0);79+ }80+});
M scripts/qa/ux-fixture.mjs +14 -1
@@ -112,6 +112,10 @@ const entries = new Map([
112112 for (const [path, content] of entries) { const full = join(repo, path); await mkdir(join(full, ".."), { recursive: true }); await writeFile(full, content); }113113 await git(repo, "add", ".");114114 try { await git(repo, "commit", "-m", "UX fixture paths"); } catch (error) { if (!String(error.stdout).includes("nothing to commit")) throw error; }115+const mergeBase = await git(repo, "rev-parse", "HEAD");116+await writeFile(join(repo, "README UX fixture.md"), "UX fixture repository\nPaths and refs contain awkward URL characters.\nPR fixture change.\n");117+await git(repo, "add", "."); await git(repo, "commit", "-m", "PR fixture change");118+const pullTip = await git(repo, "rev-parse", "HEAD");115119 await git(repo, "tag", "-f", "release/1.0"); await git(repo, "branch", "-f", "feature/café");116120 // GRASP identifiers are intentionally conservative; awkward characters live117121 // in the display name and tracked paths, where viewers must still URL-escape.
@@ -121,8 +125,17 @@ const announcement = event(30617, "", [["d", repoId], ["name", "UX space/#/?/% c
121125 await relay.publish(announcement);122126 const state = event(30618, "", [["d", repoId], ["HEAD", "ref: refs/heads/main"], ...refs.map(([ref, oid]) => [ref, oid])]);123127 await relay.publish(state);128+// Seed NIP-34 collaboration roots and their signed thread events so browser129+// checks cover issue filtering, PR detail rendering and status transitions.130+const coordinate = `30617:${owner}:${repoId}`;131+const issue = event(1621, "Fixture issue body with a [link](https://example.com) and escaped <markup>.", [["a", coordinate], ["subject", "Fixture issue: awkward paths"], ["t", "bug"], ["t", "ux"]], created + 4);132+const pull = event(1618, "Fixture pull request body", [["a", coordinate], ["subject", "Fixture PR: add nested path"], ["t", "enhancement"], ["c", pullTip], ["clone", cloneURL], ["merge-base", mergeBase]], created + 5);133+const patch = event(1617, "diff --git a/README UX fixture.md b/README UX fixture.md\n@@\n+fixture change\n", [["a", coordinate], ["e", pull.id, "", "root"], ["E", pull.id, "", owner], ["K", "1618"], ["P", owner]], created + 6);134+const reply = event(1111, "Signed fixture reply", [["a", coordinate], ["E", issue.id, "", owner], ["K", "1621"], ["P", owner], ["e", issue.id, "", owner], ["k", "1621"], ["p", owner]], created + 7);135+const closed = event(1632, "", [["a", coordinate], ["e", issue.id, "", "root"], ["p", owner]], created + 8);136+for (const collaboration of [issue, pull, patch, reply, closed]) await relay.publish(collaboration);124137 try { await git(repo, "remote", "set-url", "origin", cloneURL); } catch { await git(repo, "remote", "add", "origin", cloneURL); }125138 await git(repo, "push", "origin", "--all"); await git(repo, "push", "origin", "--tags");126-const manifest = { schema: 1, fixture: "tinyrelay-ux", relay: http, owner, npub: npubEncode(owner), dataDir, notes: notes.map(e => ({ id: e.id, kind: e.kind, tags: e.tags })), articles: articles.map(e => ({ id: e.id, tags: e.tags })), files, repository: { id: repoId, clone: cloneURL, path: repo, refs } };139+const manifest = { schema: 1, fixture: "tinyrelay-ux", relay: http, owner, npub: npubEncode(owner), dataDir, notes: notes.map(e => ({ id: e.id, kind: e.kind, tags: e.tags })), articles: articles.map(e => ({ id: e.id, tags: e.tags })), collaboration: [issue, pull, patch, reply, closed].map(e => ({ id: e.id, kind: e.kind, tags: e.tags })), files, repository: { id: repoId, clone: cloneURL, path: repo, refs } };127140 await writeFile(join(outDir, "fixture.json"), json(manifest) + "\n");128141 relay.close(); console.log(json({ ok: true, manifest: join(outDir, "fixture.json"), notes: notes.length, articles: articles.length, files: files.length, repository: repoId }));
M scripts/webmcp.test.mjs +23 -0