- commit
- 09a911f984e7b58f67f2fc07570259acb3f40d27
- parent
- f9b06b9bec648c98e985ca3bd7d303d5e4809ebb
- author
- Dami <dami.etoile@gmail.com>
| M | internal/ | 20 | +- |
| A | internal/ | 292 | +++++++++++++++++++++ |
| A | internal/ | 411 | ++++++++++++++++++++++++++++++ |
| A | internal/ | 36 | +++ |
| A | internal/ | 250 | ++++++++++++++++++ |
| A | internal/ | 118 | +++++++++ |
| A | internal/ | 112 | ++++++++ |
| M | internal/ | 2 | +- |
| A | internal/ | 30 | ++ |
| A | internal/ | 37 | +++ |
| A | internal/ | 84 | ++++++ |
| M | internal/ | 62 | +++++ |
| M | internal/ | 11 | +- |
| M | internal/ | 1 | + |
| M | internal/ | 6 | +- |
| M | internal/ | 18 | +- |
| M | internal/ | 6 | +- |
| M | internal/ | 2 | +- |
| M | internal/ | 17 | +- |
| M | internal/ | 23 | ++- |
| M | package.json | 3 | +- |
| A | scripts/ | 80 | ++++++ |
| M | scripts/ | 15 | +- |
| M | scripts/ | 23 | ++ |
@@ -23,10 +23,14 @@ type clientBrowseRequest struct {
2323 Cursor string `json:"cursor"`2424 Query string `json:"q"`2525 Hash string `json:"hash"`26+ Event string `json:"event"`27+ ID string `json:"id"`28+ State string `json:"state"`29+ Label string `json:"label"`2630 }2731 2832 func clientBrowseMethod(method string) bool {29- return containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus"}, method)33+ return containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull"}, method)3034 }3135 3236 func (t *Tenant) browseRead(ctx context.Context, actor string) error {@@ -70,6 +74,20 @@ func (t *Tenant) executeBrowse(ctx context.Context, actor, method string, params
7074 return t.browseFile(ctx, actor, q.Hash)7175 case "browsestatus":7276 return t.browseStatus(ctx, actor)77+ case "browseissues":78+ return t.browseCollaboration(ctx, actor, q, event.KIND_GIT_ISSUE)79+ case "browsepulls":80+ return t.browseCollaboration(ctx, actor, q, event.KIND_GIT_PR)81+ case "browseissue":82+ if q.Event == "" {83+ q.Event = q.ID84+ }85+ return t.browseCollaborationDetail(ctx, actor, q, event.KIND_GIT_ISSUE)86+ case "browsepull":87+ if q.Event == "" {88+ q.Event = q.ID89+ }90+ return t.browseCollaborationDetail(ctx, actor, q, event.KIND_GIT_PR)7391 default:7492 return nil, errors.New("unsupported: browse operation")7593 }@@ -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+}@@ -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+}@@ -0,0 +1,36 @@
1+package daemon2+3+import (4+ "testing"5+6+ "github.com/FelineStateMachine/tinyrelay/internal/event"7+)8+9+func TestCollaborationTitleAndSearch(t *testing.T) {10+ cases := []struct {11+ name string12+ event event.Event13+ want string14+ }{15+ {name: "subject tag", event: event.Event{Tags: [][]string{{"subject", "Fix login"}}, Content: "details"}, want: "Fix login"},16+ {name: "json title", event: event.Event{Content: `{"title":"Add files"}`}, want: "Add files"},17+ {name: "first line", event: event.Event{Content: "Improve docs\nMore details"}, want: "Improve docs"},18+ }19+ for _, tc := range cases {20+ t.Run(tc.name, func(t *testing.T) {21+ if got := collaborationTitle(tc.event); got != tc.want {22+ t.Fatalf("title = %q, want %q", got, tc.want)23+ }24+ })25+ }26+ if !collaborationMatches(event.Event{Tags: [][]string{{"t", "security"}}, Content: "please review"}, "SEC") {27+ t.Fatal("label search did not match")28+ }29+}30+31+func TestStatusFromEventsUsesNewestEvent(t *testing.T) {32+ rows := []event.Event{{ID: "b", Kind: 1630, CreatedAt: 10}, {ID: "a", Kind: 1632, CreatedAt: 11}, {ID: "c", Kind: 1633, CreatedAt: 11}}33+ if got := statusFromEvents(event.Event{}, rows); got != "closed" {34+ t.Fatalf("status = %q, want closed", got)35+ }36+}@@ -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+}@@ -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+}@@ -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+}@@ -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": {}}@@ -0,0 +1,30 @@
1+package webui2+3+import "encoding/json"4+5+// The in-process backend can return typed slices inside a map. Normalize the6+// slice before handing it to templates, just as a JSON client would receive it.7+func collaborationSlice(value any, field string) []any {8+ items := valueMap(value)[field]9+ if rows, ok := items.([]any); ok {10+ return rows11+ }12+ var rows []any13+ encoded, err := json.Marshal(items)14+ if err == nil {15+ _ = json.Unmarshal(encoded, &rows)16+ }17+ return rows18+}19+20+func collaborationItems(value any) []any { return collaborationSlice(value, "items") }21+func collaborationReplies(value any) []any { return collaborationSlice(value, "replies") }22+23+func collaborationLabels(value any) []string {24+ var labels []string25+ encoded, err := json.Marshal(valueMap(value)["labels"])26+ if err == nil {27+ _ = json.Unmarshal(encoded, &labels)28+ }29+ return labels30+}@@ -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}}@@ -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+}@@ -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 @@ -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>← <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>@@ -29,6 +29,7 @@
2929 {{end}}</ol>3030 {{else}}<p>No repository activity.</p>3131 {{end}}</section>32+{{else if or (eq $view "issues") (eq $view "prs") (eq $view "issue") (eq $view "pr")}}{{template "collaboration" .}}3233 {{end}}{{end}}{{end}}3334 3435 {{define "repoTree"}}{{$repo := asMap .Event}}{{$entries := index $repo "entries"}}{{if .Tree}}{{$entries = .Tree}}{{end}}<ul>@@ -12,7 +12,11 @@ type repoCrumb struct{ Label, URL string }
1212 func repoURL(query url.Values, view, path string) string {1313 values := url.Values{"owner": {query.Get("owner")}, "repo": {query.Get("repo")}, "ref": {query.Get("ref")}, "view": {view}}1414 if path != "" {15- values.Set("path", path)15+ if view == "issue" || view == "pr" {16+ values.Set("id", path)17+ } else {18+ values.Set("path", path)19+ }1620 }1721 return "/repo?" + values.Encode()1822 }@@ -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}@@ -13,7 +13,7 @@ import (
1313 // templateFS holds every page template. page.html owns the shell and the1414 // shared partials; the other files each define one group of tabs.1515 //16-//go:embed page.html public.html manage.html browse.html repo.html16+//go:embed page.html public.html manage.html browse.html repo.html collaboration.html1717 var templateFS embed.FS1818 1919 // styleCSS is inlined into every page so the UI needs no extra request and@@ -162,6 +162,7 @@ func parseTemplates() (*template.Template, error) {
162162 "relayItems": func() []navItem { return relayNav },163163 "manageItems": func() []navItem { return manageNav },164164 "add": func(a, b int) int { return a + b },165+ "strs": func(values ...string) []string { return values },165166 "repoCommitURL": repoCommitURL,166167 "hasNextOffset": hasNextOffset,167168 "repoURL": repoURL,@@ -171,6 +172,9 @@ func parseTemplates() (*template.Template, error) {
171172 "repoDate": repoDate,172173 "sourceHTML": sourceHTML,173174 "diffHTML": diffHTML,175+ "collabItems": collaborationItems,176+ "collabReplies": collaborationReplies,177+ "collabLabels": collaborationLabels,174178 }175179 tmpl, err := template.New("webui").Funcs(funcs).ParseFS(templateFS, "*.html")176180 if err != nil {@@ -26,7 +26,7 @@ func (a *App) webMCPQuery(writer http.ResponseWriter, request *http.Request) {
2626 return2727 }2828 }29- allowed := map[string]bool{"browserepos": true, "browserepo": true, "browsefiles": true, "browsefile": true, "browsestatus": true}29+ allowed := map[string]bool{"browserepos": true, "browserepo": true, "browsefiles": true, "browsefile": true, "browsestatus": true, "browseissues": true, "browsepulls": true, "browseissue": true, "browsepull": true}3030 if !allowed[method] {3131 writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "unsupported WebMCP query"})3232 return@@ -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"));@@ -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"] == "" {@@ -7,7 +7,8 @@
77 "test:conformance": "node scripts/conformance/run.mjs",88 "test:webmcp": "node --test scripts/webmcp.test.mjs",99 "test:signer": "node --test scripts/signer.test.mjs",10- "test:files": "node --test scripts/blossom-encryption.test.mjs scripts/blossom-manifests.test.mjs scripts/blossom-manifests-review.test.mjs scripts/blossom-upload.test.mjs scripts/file-messages.test.mjs scripts/file-workspace.test.mjs scripts/private-services.test.mjs"10+ "test:files": "node --test scripts/blossom-encryption.test.mjs scripts/blossom-manifests.test.mjs scripts/blossom-manifests-review.test.mjs scripts/blossom-upload.test.mjs scripts/file-messages.test.mjs scripts/file-workspace.test.mjs scripts/private-services.test.mjs",11+ "test:collaboration": "node --test scripts/collaboration-compose.test.mjs"1112 },1213 "devDependencies": {1314 "@noble/hashes": "2.4.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+});@@ -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 }));@@ -107,3 +107,26 @@ test("cancellation reaches the fetch request", async () => {
107107 await tools.get("tiny.list_files").execute({}, {signal: controller.signal});108108 assert.equal(received, controller.signal);109109 });110+111+test("collaboration tools preserve repository, event and filter scope", async () => {112+ const requests = [];113+ const {tools} = await browser({path: "/r/work/tools", fetch: async url => {114+ requests.push(new URL(url, "https://tiny.example"));115+ return new Response(JSON.stringify({items: [], item: {title: "Issue"}}));116+ }});117+ const repo = {owner: "a".repeat(64), repo: "notes"};118+ for (const [name, method, extra] of [119+ ["tiny.list_issues", "browseissues", {q: "bug", state: "resolved", limit: 5}],120+ ["tiny.read_issue", "browseissue", {event: "b".repeat(64)}],121+ ["tiny.list_pull_requests", "browsepulls", {state: "merged"}],122+ ["tiny.read_pull_request", "browsepull", {event: "c".repeat(64)}]123+ ]) {124+ const tool = tools.get(name);125+ assert.equal(tool.annotations.readOnlyHint, true);126+ await tool.execute({...repo, ...extra});127+ const url = requests.at(-1);128+ assert.equal(url.pathname, "/r/work/webmcp/query");129+ assert.equal(url.searchParams.get("method"), method);130+ assert.deepEqual(JSON.parse(url.searchParams.get("params")), [{...repo, ...extra}]);131+ }132+});