tinyrelaycommit

Commit 09a911f984e7

browse files at this commit | history

1diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go
2index 87013ad..83c3816 100644
3--- a/internal/daemon/client_browse.go
4+++ b/internal/daemon/client_browse.go
5@@ -23,10 +23,14 @@ type clientBrowseRequest struct {
6 Cursor string `json:"cursor"`
7 Query string `json:"q"`
8 Hash string `json:"hash"`
9+ Event string `json:"event"`
10+ ID string `json:"id"`
11+ State string `json:"state"`
12+ Label string `json:"label"`
13 }
14
15 func clientBrowseMethod(method string) bool {
16- return containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus"}, method)
17+ return containsString([]string{"browserepos", "browserepo", "browsefiles", "browsefile", "browsestatus", "browseissues", "browsepulls", "browseissue", "browsepull"}, method)
18 }
19
20 func (t *Tenant) browseRead(ctx context.Context, actor string) error {
21@@ -70,6 +74,20 @@ func (t *Tenant) executeBrowse(ctx context.Context, actor, method string, params
22 return t.browseFile(ctx, actor, q.Hash)
23 case "browsestatus":
24 return t.browseStatus(ctx, actor)
25+ case "browseissues":
26+ return t.browseCollaboration(ctx, actor, q, event.KIND_GIT_ISSUE)
27+ case "browsepulls":
28+ return t.browseCollaboration(ctx, actor, q, event.KIND_GIT_PR)
29+ case "browseissue":
30+ if q.Event == "" {
31+ q.Event = q.ID
32+ }
33+ return t.browseCollaborationDetail(ctx, actor, q, event.KIND_GIT_ISSUE)
34+ case "browsepull":
35+ if q.Event == "" {
36+ q.Event = q.ID
37+ }
38+ return t.browseCollaborationDetail(ctx, actor, q, event.KIND_GIT_PR)
39 default:
40 return nil, errors.New("unsupported: browse operation")
41 }
42diff --git a/internal/daemon/collaboration_acceptance_test.go b/internal/daemon/collaboration_acceptance_test.go
43new file mode 100644
44index 0000000..7fb2164
45--- /dev/null
46+++ b/internal/daemon/collaboration_acceptance_test.go
47@@ -0,0 +1,292 @@
48+package daemon
49+
50+import (
51+ "context"
52+ "encoding/json"
53+ "fmt"
54+ "net/url"
55+ "strings"
56+ "testing"
57+
58+ "github.com/FelineStateMachine/tinyrelay/internal/event"
59+ "github.com/FelineStateMachine/tinyrelay/internal/relay"
60+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
61+)
62+
63+const collaborationCoordinateRepo = "collaboration"
64+
65+func collaborationFixtureRepo(t *testing.T, tenant *Tenant) (string, string) {
66+ t.Helper()
67+ ctx := context.Background()
68+ ownerSecret := strings.Repeat("0", 63) + "1"
69+ owner := tenant.Policy().Owner
70+ serviceURL, err := url.Parse(tenant.RelayURL())
71+ if err != nil {
72+ t.Fatal(err)
73+ }
74+ cloneURL := "http://" + serviceURL.Host + "/" + owner + "/" + collaborationCoordinateRepo + ".git"
75+ relayURL := "ws://" + serviceURL.Host
76+ announcement := event.Event{
77+ Kind: event.KIND_REPO,
78+ PubKey: owner,
79+ CreatedAt: 100,
80+ Tags: [][]string{{"d", collaborationCoordinateRepo}, {"clone", cloneURL}, {"relays", relayURL}},
81+ }
82+ if err := event.Sign(&announcement, ownerSecret); err != nil {
83+ t.Fatal(err)
84+ }
85+ if _, err := tenant.router.Publish(ctx, announcement, relay.Session{PubKeys: []string{owner}}); err != nil {
86+ t.Fatal(err)
87+ }
88+ return owner, "30617:" + owner + ":" + collaborationCoordinateRepo
89+}
90+
91+func signedCollaborationEvent(t *testing.T, secret string, kind int, created int64, tags [][]string, content string) event.Event {
92+ t.Helper()
93+ e := event.Event{Kind: kind, CreatedAt: created, Tags: tags, Content: content}
94+ if err := event.Sign(&e, secret); err != nil {
95+ t.Fatal(err)
96+ }
97+ return e
98+}
99+
100+func saveCollaborationEvents(t *testing.T, tenant *Tenant, rows ...event.Event) {
101+ t.Helper()
102+ for _, row := range rows {
103+ if _, err := tenant.store.Save(context.Background(), row, storage.SaveOptions{Now: row.CreatedAt, SearchMode: tenant.Policy().Features.Search}); err != nil {
104+ t.Fatal(err)
105+ }
106+ }
107+}
108+
109+func browseCollaboration(t *testing.T, tenant *Tenant, actor, method string, args map[string]any) map[string]any {
110+ t.Helper()
111+ params, err := json.Marshal(args)
112+ if err != nil {
113+ t.Fatal(err)
114+ }
115+ value, err := tenant.Execute(context.Background(), actor, method, []json.RawMessage{params})
116+ if err != nil {
117+ t.Fatal(err)
118+ }
119+ result, ok := value.(map[string]any)
120+ if !ok {
121+ t.Fatalf("%s result = %T, want object", method, value)
122+ }
123+ return result
124+}
125+
126+func browseCollaborationDetail(t *testing.T, tenant *Tenant, actor, method string, args map[string]any) collaborationDetail {
127+ t.Helper()
128+ params, err := json.Marshal(args)
129+ if err != nil {
130+ t.Fatal(err)
131+ }
132+ value, err := tenant.Execute(context.Background(), actor, method, []json.RawMessage{params})
133+ if err != nil {
134+ t.Fatal(err)
135+ }
136+ detail, ok := value.(collaborationDetail)
137+ if !ok {
138+ t.Fatalf("%s result = %T, want collaboration detail", method, value)
139+ }
140+ return detail
141+}
142+
143+func TestCollaborationListPaginationKeepsTimestampTies(t *testing.T) {
144+ _, tenant := testTenant(t)
145+ p := tenant.Policy()
146+ p.Features.Grasp = true
147+ p.Features.Grasp02 = true
148+ p.Features.Grasp05 = true
149+ p.GuestReplies = true
150+ if err := tenant.applyPolicy(context.Background(), p); err != nil {
151+ t.Fatal(err)
152+ }
153+ owner, coordinate := collaborationFixtureRepo(t, tenant)
154+ secret := strings.Repeat("1", 63) + "2"
155+ var ids []string
156+ for i := 0; i < 9; i++ {
157+ row := signedCollaborationEvent(t, secret, event.KIND_GIT_ISSUE, 200,
158+ [][]string{{"a", coordinate}, {"subject", fmt.Sprintf("Issue %02d", i)}}, "body")
159+ saveCollaborationEvents(t, tenant, row)
160+ ids = append(ids, row.ID)
161+ }
162+ seen := map[string]bool{}
163+ cursor := ""
164+ for page := 0; page < 20; page++ {
165+ result := browseCollaboration(t, tenant, owner, "browseissues", map[string]any{"owner": owner, "repo": collaborationCoordinateRepo, "limit": 2, "cursor": cursor})
166+ items, ok := result["items"].([]collaborationItem)
167+ if !ok {
168+ t.Fatalf("items = %T", result["items"])
169+ }
170+ for _, item := range items {
171+ id := item.ID
172+ if seen[id] {
173+ t.Fatalf("duplicate item %s", id)
174+ }
175+ seen[id] = true
176+ }
177+ cursor, _ = result["next_cursor"].(string)
178+ if cursor == "" {
179+ break
180+ }
181+ }
182+ if len(seen) != len(ids) {
183+ t.Fatalf("pagination returned %d issues, want %d", len(seen), len(ids))
184+ }
185+}
186+
187+func TestCollaborationSearchAndStatusReachPastFirstPage(t *testing.T) {
188+ _, tenant := testTenant(t)
189+ p := tenant.Policy()
190+ p.Features.Grasp = true
191+ p.Features.Grasp02 = true
192+ p.Features.Grasp05 = true
193+ p.GuestReplies = true
194+ if err := tenant.applyPolicy(context.Background(), p); err != nil {
195+ t.Fatal(err)
196+ }
197+ owner, coordinate := collaborationFixtureRepo(t, tenant)
198+ secret := strings.Repeat("2", 63) + "3"
199+ for i := 0; i < 60; i++ {
200+ row := signedCollaborationEvent(t, secret, event.KIND_GIT_ISSUE, int64(300+i),
201+ [][]string{{"a", coordinate}, {"subject", fmt.Sprintf("Routine issue %02d", i)}}, "ordinary body")
202+ if i == 59 {
203+ row.Tags = append(row.Tags, []string{"t", "security"})
204+ row.Content = "Security regression"
205+ }
206+ saveCollaborationEvents(t, tenant, row)
207+ if i == 59 {
208+ status := signedCollaborationEvent(t, strings.Repeat("0", 63)+"1", 1632, 500,
209+ [][]string{{"e", row.ID}}, "closed")
210+ saveCollaborationEvents(t, tenant, status)
211+ }
212+ }
213+ result := browseCollaboration(t, tenant, owner, "browseissues", map[string]any{"owner": owner, "repo": collaborationCoordinateRepo, "limit": 2, "q": "security", "label": "security"})
214+ items := result["items"].([]collaborationItem)
215+ if len(items) != 1 || items[0].Status != "closed" {
216+ t.Fatalf("search result = %#v", result)
217+ }
218+ filtered := browseCollaboration(t, tenant, owner, "browseissues", map[string]any{"owner": owner, "repo": collaborationCoordinateRepo, "limit": 2, "state": "closed"})
219+ if len(filtered["items"].([]collaborationItem)) != 1 {
220+ t.Fatalf("status filter = %#v", filtered)
221+ }
222+}
223+
224+func TestCollaborationReplyPaginationUsesCursorAndFiltersMalformedEvents(t *testing.T) {
225+ _, tenant := testTenant(t)
226+ p := tenant.Policy()
227+ p.Features.Grasp = true
228+ p.Features.Grasp02 = true
229+ p.Features.Grasp05 = true
230+ p.GuestReplies = true
231+ if err := tenant.applyPolicy(context.Background(), p); err != nil {
232+ t.Fatal(err)
233+ }
234+ owner, coordinate := collaborationFixtureRepo(t, tenant)
235+ authorSecret := strings.Repeat("8", 63) + "9"
236+ author, err := event.PublicKey(authorSecret)
237+ if err != nil {
238+ t.Fatal(err)
239+ }
240+ issue := signedCollaborationEvent(t, authorSecret, event.KIND_GIT_ISSUE, 800,
241+ [][]string{{"a", coordinate}, {"subject", "Crowded thread"}}, "body")
242+ rows := []event.Event{issue}
243+ for i := 0; i < 205; i++ {
244+ rows = append(rows, signedCollaborationEvent(t, authorSecret, 1111, 801,
245+ [][]string{{"E", issue.ID}, {"K", "1621"}, {"P", author}}, fmt.Sprintf("reply %03d", i)))
246+ }
247+ rows = append(rows, signedCollaborationEvent(t, authorSecret, 1111, 801,
248+ [][]string{{"E", issue.ID}, {"K", "1618"}, {"P", author}}, "wrong root kind"))
249+ rows = append(rows, signedCollaborationEvent(t, authorSecret, 1111, 801,
250+ [][]string{{"E", issue.ID}, {"K", "1621"}, {"P", strings.Repeat("a", 64)}}, "wrong root author"))
251+ saveCollaborationEvents(t, tenant, rows...)
252+ seen := map[string]bool{}
253+ cursor := ""
254+ for page := 0; page < 20; page++ {
255+ result := browseCollaborationDetail(t, tenant, owner, "browseissue", map[string]any{"owner": owner, "repo": collaborationCoordinateRepo, "event": issue.ID, "limit": 37, "cursor": cursor})
256+ if len(result.Replies) > 37 {
257+ t.Fatalf("reply page has %d entries", len(result.Replies))
258+ }
259+ for _, reply := range result.Replies {
260+ if seen[reply.ID] {
261+ t.Fatalf("duplicate reply %s", reply.ID)
262+ }
263+ seen[reply.ID] = true
264+ }
265+ cursor = result.ReplyCursor
266+ if cursor == "" {
267+ break
268+ }
269+ }
270+ if len(seen) != 205 {
271+ t.Fatalf("reply pagination returned %d replies, want 205", len(seen))
272+ }
273+}
274+
275+func TestCollaborationDetailSeparatesRepliesAndAuthorizesStatus(t *testing.T) {
276+ _, tenant := testTenant(t)
277+ p := tenant.Policy()
278+ p.Features.Grasp = true
279+ p.Features.Grasp02 = true
280+ p.Features.Grasp05 = true
281+ p.GuestReplies = true
282+ if err := tenant.applyPolicy(context.Background(), p); err != nil {
283+ t.Fatal(err)
284+ }
285+ owner, coordinate := collaborationFixtureRepo(t, tenant)
286+ authorSecret := strings.Repeat("3", 63) + "4"
287+ author, err := event.PublicKey(authorSecret)
288+ if err != nil {
289+ t.Fatal(err)
290+ }
291+ issue := signedCollaborationEvent(t, authorSecret, event.KIND_GIT_ISSUE, 600,
292+ [][]string{{"a", coordinate}, {"subject", "Needs review"}, {"t", "bug"}}, "Please investigate")
293+ reply := signedCollaborationEvent(t, strings.Repeat("4", 63)+"5", 1111, 601,
294+ [][]string{{"e", issue.ID}, {"E", issue.ID}, {"K", "1621"}, {"P", author}}, "I can reproduce this")
295+ status := signedCollaborationEvent(t, authorSecret, 1631, 602, [][]string{{"e", issue.ID}}, "resolved")
296+ forged := signedCollaborationEvent(t, strings.Repeat("5", 63)+"6", 1632, 603, [][]string{{"e", issue.ID}}, "closed")
297+ saveCollaborationEvents(t, tenant, issue, reply, status, forged)
298+ for _, actor := range []string{owner, author} {
299+ result := browseCollaborationDetail(t, tenant, actor, "browseissue", map[string]any{"owner": owner, "repo": collaborationCoordinateRepo, "event": issue.ID})
300+ if !result.CanStatus {
301+ t.Fatalf("actor %s cannot status: %#v", actor, result)
302+ }
303+ if len(result.Replies) != 1 || len(result.Statuses) != 1 {
304+ t.Fatalf("authorized detail thread leaked or duplicated events: %#v", result)
305+ }
306+ }
307+ unauthorized := strings.Repeat("6", 63) + "7"
308+ unauthorizedKey, err := event.PublicKey(unauthorized)
309+ if err != nil {
310+ t.Fatal(err)
311+ }
312+ result := browseCollaborationDetail(t, tenant, unauthorizedKey, "browseissue", map[string]any{"owner": owner, "repo": collaborationCoordinateRepo, "event": issue.ID})
313+ if result.CanStatus {
314+ t.Fatalf("unauthorized actor can status: %#v", result)
315+ }
316+}
317+
318+func TestPrivateCollaborationRejectsAnonymousAndWrongRepository(t *testing.T) {
319+ ctx := context.Background()
320+ secret := strings.Repeat("7", 64)
321+ owner, err := event.PublicKey(secret)
322+ if err != nil {
323+ t.Fatal(err)
324+ }
325+ _, tenant, server := privatePeerTenant(t, "collaboration-private", owner)
326+ repo, _, _ := seedPrivateRepository(t, tenant, owner, secret, "private", server.URL)
327+ coordinate := "30617:" + owner + ":" + repo.Identifier
328+ issue := signedCollaborationEvent(t, secret, event.KIND_GIT_ISSUE, 700,
329+ [][]string{{"a", coordinate}, {"subject", "Private issue"}}, "secret")
330+ saveCollaborationEvents(t, tenant, issue)
331+ params := []json.RawMessage{json.RawMessage(fmt.Sprintf(`{"owner":%q,"repo":%q,"event":%q}`, owner, repo.Identifier, issue.ID))}
332+ if _, err := tenant.Execute(ctx, "", "browseissue", params); err == nil {
333+ t.Fatal("anonymous private collaboration detail was exposed")
334+ }
335+ _, _, _ = seedPrivateRepository(t, tenant, owner, secret, "other", server.URL)
336+ 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 {
337+ t.Fatal("collaboration root crossed repository boundary")
338+ }
339+}
340diff --git a/internal/daemon/collaboration_browse.go b/internal/daemon/collaboration_browse.go
341new file mode 100644
342index 0000000..2556f66
343--- /dev/null
344+++ b/internal/daemon/collaboration_browse.go
345@@ -0,0 +1,411 @@
346+package daemon
347+
348+import (
349+ "context"
350+ "encoding/json"
351+ "errors"
352+ "sort"
353+ "strconv"
354+ "strings"
355+ "time"
356+
357+ "github.com/FelineStateMachine/tinyrelay/internal/event"
358+ "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
359+ "github.com/FelineStateMachine/tinyrelay/internal/relay"
360+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
361+)
362+
363+type collaborationItem struct {
364+ ID string `json:"id"`
365+ Kind int `json:"kind"`
366+ Author string `json:"author"`
367+ CreatedAt int64 `json:"created_at"`
368+ Title string `json:"title"`
369+ Content string `json:"content"`
370+ Labels []string `json:"labels"`
371+ Status string `json:"status"`
372+}
373+
374+type collaborationDetail struct {
375+ Repository map[string]any `json:"repository"`
376+ Root event.Event `json:"root"`
377+ Item collaborationItem `json:"item"`
378+ Replies []event.Event `json:"replies"`
379+ Updates []event.Event `json:"updates"`
380+ Statuses []event.Event `json:"statuses"`
381+ Diff string `json:"diff,omitempty"`
382+ CanStatus bool `json:"can_status"`
383+ DiffUnavailable string `json:"diff_unavailable,omitempty"`
384+ DiffTruncated bool `json:"diff_truncated,omitempty"`
385+ ReplyCursor string `json:"reply_cursor,omitempty"`
386+}
387+
388+func collaborationRepository(repo gitrelay.Repository) map[string]any {
389+ return map[string]any{"owner": repo.Owner, "private": repo.Private, "clone": repo.Clone, "refs": repo.Refs, "head": repo.Head}
390+}
391+
392+func (t *Tenant) collaborationRepo(ctx context.Context, actor string, q clientBrowseRequest) (gitrelay.Repository, error) {
393+ if t.git == nil || !t.Policy().Features.Grasp {
394+ return gitrelay.Repository{}, errors.New("not found: Git hosting is disabled")
395+ }
396+ r, err := t.git.BrowseRepository(q.Owner, q.Repo)
397+ if err != nil {
398+ return gitrelay.Repository{}, err
399+ }
400+ if err := t.browseRepoAccess(ctx, actor, r); err != nil {
401+ return gitrelay.Repository{}, err
402+ }
403+ r.Private = r.Private || t.PrivateServiceEnabled()
404+ return r, nil
405+}
406+
407+func (t *Tenant) browseCollaboration(ctx context.Context, actor string, q clientBrowseRequest, kind int) (any, error) {
408+ r, err := t.collaborationRepo(ctx, actor, q)
409+ if err != nil {
410+ return nil, err
411+ }
412+ if q.Limit <= 0 || q.Limit > 100 {
413+ q.Limit = 50
414+ }
415+ cursor, err := parseCollaborationCursor(q.Cursor)
416+ if err != nil {
417+ return nil, err
418+ }
419+ if q.State != "" && !contains([]string{"open", "resolved", "merged", "closed", "draft"}, q.State) {
420+ return nil, errors.New("invalid: issue or pull request status")
421+ }
422+ coordinate := "30617:" + r.Owner + ":" + r.Identifier
423+ filters := []event.Filter{{Kinds: []int{kind}, Tags: map[string][]string{"a": {coordinate}}}, {Kinds: []int{kind}, Tags: map[string][]string{"A": {coordinate}}}}
424+ session := browseSession(t, actor)
425+ if _, err := t.gate.Read(ctx, filters, session); err != nil {
426+ return nil, err
427+ }
428+ items := make([]collaborationItem, 0, q.Limit)
429+ next := ""
430+ for scan := 0; scan < 100; scan++ {
431+ var rows []event.Event
432+ more := false
433+ for _, filter := range filters {
434+ page, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: session.PubKeys}, Limit: 100, Before: cursor})
435+ if err != nil {
436+ return nil, err
437+ }
438+ more = more || page.More
439+ rows = append(rows, page.Events...)
440+ }
441+ rows = uniqueEvents(rows)
442+ sortEventsNewestFirst(rows)
443+ if len(rows) > 100 {
444+ rows = rows[:100]
445+ more = true
446+ }
447+ visible := make([]event.Event, 0, len(rows))
448+ for _, root := range rows {
449+ if t.gate.CanSee(ctx, root, session, nil) && collaborationMatches(root, q.Query) && matchesCollaborationLabel(root, q.Label) {
450+ visible = append(visible, root)
451+ }
452+ }
453+ statuses, err := t.collaborationStatuses(ctx, actor, visible, r)
454+ if err != nil {
455+ return nil, err
456+ }
457+ for _, root := range rows {
458+ cursor = &storage.EventCursor{CreatedAt: root.CreatedAt, ID: root.ID}
459+ status, ok := statuses[root.ID]
460+ if !ok || (q.State != "" && q.State != status) {
461+ continue
462+ }
463+ if len(items) == q.Limit {
464+ return map[string]any{"repository": collaborationRepository(r), "items": items, "next_cursor": next}, nil
465+ }
466+ item := collaborationItemFrom(root)
467+ item.Status = status
468+ items = append(items, item)
469+ next = collaborationCursor(item)
470+ }
471+ if !more {
472+ return map[string]any{"repository": collaborationRepository(r), "items": items, "next_cursor": ""}, nil
473+ }
474+ }
475+ if cursor != nil {
476+ next = strconv.FormatInt(cursor.CreatedAt, 10) + ":" + cursor.ID
477+ }
478+ return map[string]any{"repository": collaborationRepository(r), "items": items, "next_cursor": next}, nil
479+}
480+
481+func parseCollaborationCursor(raw string) (*storage.EventCursor, error) {
482+ if raw == "" {
483+ return nil, nil
484+ }
485+ stamp, id, ok := strings.Cut(raw, ":")
486+ ts, err := strconv.ParseInt(stamp, 10, 64)
487+ if !ok || err != nil || ts < 0 || len(id) != 64 || strings.Trim(id, "0123456789abcdef") != "" {
488+ return nil, errors.New("invalid: collaboration cursor")
489+ }
490+ return &storage.EventCursor{CreatedAt: ts, ID: id}, nil
491+}
492+
493+func matchesCollaborationLabel(root event.Event, label string) bool {
494+ if label == "" {
495+ return true
496+ }
497+ for _, value := range event.TagValues(root, "t") {
498+ if strings.EqualFold(value, label) {
499+ return true
500+ }
501+ }
502+ return false
503+}
504+
505+func (t *Tenant) browseCollaborationDetail(ctx context.Context, actor string, q clientBrowseRequest, kind int) (any, error) {
506+ if len(q.Event) != 64 {
507+ return nil, errors.New("invalid: collaboration event")
508+ }
509+ r, err := t.collaborationRepo(ctx, actor, q)
510+ if err != nil {
511+ return nil, err
512+ }
513+ rows, err := t.Query(ctx, []event.Filter{{IDs: []string{q.Event}, Limit: intPtr(1)}}, browseSession(t, actor))
514+ if err != nil {
515+ return nil, err
516+ }
517+ if len(rows) != 1 || rows[0].Kind != kind || !hasCoordinate(rows[0], r) {
518+ return nil, errors.New("not found: collaboration event")
519+ }
520+ root := rows[0]
521+ detail := collaborationDetail{Repository: collaborationRepository(r), Root: root, Item: collaborationItemFrom(root), CanStatus: actor == root.PubKey || actor == r.Owner || contains(r.Maintainers, actor)}
522+ replyCursor, err := parseCollaborationCursor(q.Cursor)
523+ if err != nil {
524+ return nil, err
525+ }
526+ replyLimit := q.Limit
527+ if replyLimit <= 0 || replyLimit > 200 {
528+ replyLimit = 200
529+ }
530+ authors := append([]string{root.PubKey, r.Owner}, r.Maintainers...)
531+ 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))
532+ if err != nil {
533+ return nil, err
534+ }
535+ detail.Statuses = statuses
536+ detail.Item.Status = statusFromEvents(root, statuses)
537+ replyFilters := []event.Filter{
538+ {Kinds: []int{1111}, Tags: map[string][]string{"E": {root.ID}, "K": {strconv.Itoa(root.Kind)}, "P": {root.PubKey}}},
539+ }
540+ replies, err := t.queryCollaborationThread(ctx, actor, replyFilters, replyCursor, replyLimit+1)
541+ if err != nil {
542+ return nil, err
543+ }
544+ detail.Replies = replies
545+ if len(detail.Replies) > replyLimit {
546+ detail.Replies = detail.Replies[:replyLimit]
547+ last := detail.Replies[replyLimit-1]
548+ detail.ReplyCursor = collaborationCursor(collaborationItemFrom(last))
549+ }
550+ if kind == event.KIND_GIT_PR {
551+ 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))
552+ if err != nil {
553+ return nil, err
554+ }
555+ tipEvent := root
556+ valid := make([]event.Event, 0, len(detail.Updates))
557+ for _, update := range detail.Updates {
558+ if event.Tag(update, "P") == root.PubKey && event.Tag(update, "E") == root.ID {
559+ valid = append(valid, update)
560+ }
561+ }
562+ detail.Updates = valid
563+ if len(valid) > 0 {
564+ tipEvent = valid[0]
565+ }
566+ diff := t.git.PRDiff(ctx, r, event.Tag(tipEvent, "merge-base"), event.Tag(tipEvent, "c"))
567+ if diff.Status == "ok" {
568+ detail.Diff = diff.Diff
569+ detail.DiffTruncated = diff.Truncated
570+ } else {
571+ detail.DiffUnavailable = diff.Reason
572+ }
573+ }
574+
575+ return detail, nil
576+}
577+
578+func collaborationItemFrom(e event.Event) collaborationItem {
579+ 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")}
580+}
581+
582+func collaborationTitle(e event.Event) string {
583+ for _, key := range []string{"subject", "title", "name"} {
584+ if value := event.Tag(e, key); value != "" {
585+ return value
586+ }
587+ }
588+ content := strings.TrimSpace(e.Content)
589+ var object struct {
590+ Title string `json:"title"`
591+ Subject string `json:"subject"`
592+ }
593+ if json.Unmarshal([]byte(content), &object) == nil {
594+ if object.Title != "" {
595+ return object.Title
596+ }
597+ if object.Subject != "" {
598+ return object.Subject
599+ }
600+ }
601+ if line, _, ok := strings.Cut(content, "\n"); ok {
602+ return line
603+ }
604+ return content
605+}
606+
607+func collaborationMatches(e event.Event, query string) bool {
608+ query = strings.ToLower(strings.TrimSpace(query))
609+ return query == "" || strings.Contains(strings.ToLower(collaborationTitle(e)), query) || strings.Contains(strings.ToLower(e.Content), query) || collaborationLabels(e, query)
610+}
611+func collaborationLabels(e event.Event, query string) bool {
612+ for _, label := range event.TagValues(e, "t") {
613+ if strings.Contains(strings.ToLower(label), query) {
614+ return true
615+ }
616+ }
617+ return false
618+}
619+
620+// collaborationStatuses resolves the status of every root on a list page with
621+// one query. Only the root author, the owner and current maintainers can set a
622+// status, and the newest authorized event wins.
623+func (t *Tenant) collaborationStatuses(ctx context.Context, actor string, roots []event.Event, r gitrelay.Repository) (map[string]string, error) {
624+ statuses := make(map[string]string, len(roots))
625+ if len(roots) == 0 {
626+ return statuses, nil
627+ }
628+ ids := make([]string, 0, len(roots))
629+ authors := append([]string{r.Owner}, r.Maintainers...)
630+ for _, root := range roots {
631+ ids = append(ids, root.ID)
632+ authors = append(authors, root.PubKey)
633+ }
634+ limit := 1000
635+ 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))
636+ if err != nil {
637+ return nil, err
638+ }
639+ byRoot := make(map[string][]event.Event, len(roots))
640+ for _, row := range rows {
641+ for _, id := range event.TagValues(row, "e") {
642+ byRoot[id] = append(byRoot[id], row)
643+ }
644+ }
645+ for _, root := range roots {
646+ allowed := []event.Event{}
647+ for _, row := range byRoot[root.ID] {
648+ if row.PubKey == root.PubKey || row.PubKey == r.Owner || contains(r.Maintainers, row.PubKey) {
649+ allowed = append(allowed, row)
650+ }
651+ }
652+ statuses[root.ID] = statusFromEvents(root, allowed)
653+ }
654+ return statuses, nil
655+}
656+
657+func (t *Tenant) queryCollaborationThread(ctx context.Context, actor string, filters []event.Filter, cursor *storage.EventCursor, limit int) ([]event.Event, error) {
658+ session := browseSession(t, actor)
659+ if _, err := t.gate.Read(ctx, filters, session); err != nil {
660+ return nil, err
661+ }
662+ rows := make([]event.Event, 0)
663+ for _, filter := range filters {
664+ page, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: session.PubKeys}, Limit: limit, Before: cursor})
665+ if err != nil {
666+ return nil, err
667+ }
668+ for _, row := range page.Events {
669+ if t.gate.CanSee(ctx, row, session, nil) {
670+ rows = append(rows, row)
671+ }
672+ }
673+ }
674+ rows = uniqueEvents(rows)
675+ sortEventsNewestFirst(rows)
676+ if len(rows) > limit {
677+ rows = rows[:limit]
678+ }
679+ return rows, nil
680+}
681+
682+func statusFromEvents(root event.Event, rows []event.Event) string {
683+ sort.Slice(rows, func(i, j int) bool {
684+ if rows[i].CreatedAt == rows[j].CreatedAt {
685+ return rows[i].ID < rows[j].ID
686+ }
687+ return rows[i].CreatedAt > rows[j].CreatedAt
688+ })
689+ if len(rows) == 0 {
690+ return "open"
691+ }
692+ statuses := map[int]string{1630: "open", 1632: "closed", 1633: "draft"}
693+ if root.Kind == event.KIND_GIT_ISSUE {
694+ statuses[1631] = "resolved"
695+ } else {
696+ statuses[1631] = "merged"
697+ }
698+ return statuses[rows[0].Kind]
699+}
700+
701+func collaborationCursor(item collaborationItem) string {
702+ return strconv.FormatInt(item.CreatedAt, 10) + ":" + item.ID
703+}
704+
705+func afterCollaborationCursor(item event.Event, cursor string) bool {
706+ if cursor == "" {
707+ return true
708+ }
709+ stamp, id, ok := strings.Cut(cursor, ":")
710+ if !ok {
711+ return item.ID < cursor
712+ }
713+ timestamp, err := strconv.ParseInt(stamp, 10, 64)
714+ if err != nil {
715+ return false
716+ }
717+ return item.CreatedAt < timestamp || (item.CreatedAt == timestamp && item.ID > id)
718+}
719+
720+func hasCoordinate(e event.Event, r gitrelay.Repository) bool {
721+ coordinate := "30617:" + r.Owner + ":" + r.Identifier
722+ for _, tag := range e.Tags {
723+ if len(tag) > 1 && (tag[0] == "a" || tag[0] == "A") && tag[1] == coordinate {
724+ return true
725+ }
726+ }
727+ return false
728+}
729+func browseSession(t *Tenant, actor string) relay.Session {
730+ s := relay.Session{RelayURL: t.RelayURL()}
731+ if actor != "" {
732+ s.PubKeys = []string{actor}
733+ }
734+ return s
735+}
736+func contains(values []string, needle string) bool {
737+ for _, value := range values {
738+ if value == needle {
739+ return true
740+ }
741+ }
742+ return false
743+}
744+
745+func uniqueStrings(values []string) []string {
746+ seen := make(map[string]struct{}, len(values))
747+ result := make([]string, 0, len(values))
748+ for _, value := range values {
749+ if _, ok := seen[value]; ok {
750+ continue
751+ }
752+ seen[value] = struct{}{}
753+ result = append(result, value)
754+ }
755+ return result
756+}
757diff --git a/internal/daemon/collaboration_browse_test.go b/internal/daemon/collaboration_browse_test.go
758new file mode 100644
759index 0000000..3f7adb7
760--- /dev/null
761+++ b/internal/daemon/collaboration_browse_test.go
762@@ -0,0 +1,36 @@
763+package daemon
764+
765+import (
766+ "testing"
767+
768+ "github.com/FelineStateMachine/tinyrelay/internal/event"
769+)
770+
771+func TestCollaborationTitleAndSearch(t *testing.T) {
772+ cases := []struct {
773+ name string
774+ event event.Event
775+ want string
776+ }{
777+ {name: "subject tag", event: event.Event{Tags: [][]string{{"subject", "Fix login"}}, Content: "details"}, want: "Fix login"},
778+ {name: "json title", event: event.Event{Content: `{"title":"Add files"}`}, want: "Add files"},
779+ {name: "first line", event: event.Event{Content: "Improve docs\nMore details"}, want: "Improve docs"},
780+ }
781+ for _, tc := range cases {
782+ t.Run(tc.name, func(t *testing.T) {
783+ if got := collaborationTitle(tc.event); got != tc.want {
784+ t.Fatalf("title = %q, want %q", got, tc.want)
785+ }
786+ })
787+ }
788+ if !collaborationMatches(event.Event{Tags: [][]string{{"t", "security"}}, Content: "please review"}, "SEC") {
789+ t.Fatal("label search did not match")
790+ }
791+}
792+
793+func TestStatusFromEventsUsesNewestEvent(t *testing.T) {
794+ rows := []event.Event{{ID: "b", Kind: 1630, CreatedAt: 10}, {ID: "a", Kind: 1632, CreatedAt: 11}, {ID: "c", Kind: 1633, CreatedAt: 11}}
795+ if got := statusFromEvents(event.Event{}, rows); got != "closed" {
796+ t.Fatalf("status = %q, want closed", got)
797+ }
798+}
799diff --git a/internal/daemon/git_sync_acceptance_test.go b/internal/daemon/git_sync_acceptance_test.go
800new file mode 100644
801index 0000000..0b8be48
802--- /dev/null
803+++ b/internal/daemon/git_sync_acceptance_test.go
804@@ -0,0 +1,250 @@
805+package daemon
806+
807+import (
808+ "context"
809+ "encoding/json"
810+ "net/http"
811+ "net/http/httptest"
812+ "strings"
813+ "sync"
814+ "testing"
815+ "time"
816+
817+ "github.com/FelineStateMachine/tinyrelay/internal/event"
818+ "github.com/FelineStateMachine/tinyrelay/internal/gitrelay"
819+ "github.com/FelineStateMachine/tinyrelay/internal/relay"
820+ "github.com/FelineStateMachine/tinyrelay/internal/storage"
821+ "github.com/coder/websocket"
822+)
823+
824+type gitSyncEventRelay struct {
825+ mu sync.Mutex
826+ events []event.Event
827+ live *event.Event
828+ liveSent chan struct{}
829+ requests int
830+}
831+
832+func (r *gitSyncEventRelay) ServeHTTP(w http.ResponseWriter, req *http.Request) {
833+ if req.Header.Get("Accept") == "application/nostr+json" {
834+ w.Header().Set("Content-Type", "application/nostr+json")
835+ _, _ = w.Write([]byte(`{"supported_grasps":["GRASP-01"],"private_service":false}`))
836+ return
837+ }
838+ conn, err := websocket.Accept(w, req, nil)
839+ if err != nil {
840+ return
841+ }
842+ defer conn.Close(websocket.StatusNormalClosure, "done")
843+ _, data, err := conn.Read(req.Context())
844+ if err != nil {
845+ return
846+ }
847+ var message []json.RawMessage
848+ if json.Unmarshal(data, &message) != nil || len(message) < 3 {
849+ return
850+ }
851+ var kind, subscription string
852+ _ = json.Unmarshal(message[0], &kind)
853+ _ = json.Unmarshal(message[1], &subscription)
854+ if kind != "REQ" {
855+ if kind == "NEG-OPEN" {
856+ response, _ := json.Marshal([]any{"NEG-ERR", subscription, "unsupported"})
857+ _ = conn.Write(req.Context(), websocket.MessageText, response)
858+ }
859+ return
860+ }
861+ filter, err := event.ParseFilter(message[2])
862+ if err != nil {
863+ return
864+ }
865+ r.mu.Lock()
866+ r.requests++
867+ items := append([]event.Event(nil), r.events...)
868+ r.mu.Unlock()
869+ for _, item := range items {
870+ if event.Matches(filter, item) {
871+ response, _ := json.Marshal([]any{"EVENT", subscription, item})
872+ _ = conn.Write(req.Context(), websocket.MessageText, response)
873+ }
874+ }
875+ response, _ := json.Marshal([]any{"EOSE", subscription})
876+ _ = conn.Write(req.Context(), websocket.MessageText, response)
877+ if strings.HasPrefix(subscription, "tiny-live-") && r.live != nil {
878+ live, _ := json.Marshal([]any{"EVENT", subscription, *r.live})
879+ _ = conn.Write(req.Context(), websocket.MessageText, live)
880+ if r.liveSent != nil {
881+ select {
882+ case r.liveSent <- struct{}{}:
883+ default:
884+ }
885+ }
886+ }
887+}
888+
889+func (r *gitSyncEventRelay) requestCount() int {
890+ r.mu.Lock()
891+ defer r.mu.Unlock()
892+ return r.requests
893+}
894+
895+func TestGitSyncPeerImportsHistoricalIssueAndRootReplies(t *testing.T) {
896+ _, tenant := testTenant(t)
897+ tenant.app.cfg.AllowPrivateRelays = true
898+ p := tenant.Policy()
899+ p.Features.Grasp = true
900+ p.Features.Grasp02 = true
901+ p.Features.Grasp05 = true
902+ if err := tenant.applyPolicy(context.Background(), p); err != nil {
903+ t.Fatal(err)
904+ }
905+ owner := tenant.Policy().Owner
906+ secret := strings.Repeat("9", 63) + "8"
907+ author, err := event.PublicKey(secret)
908+ if err != nil {
909+ t.Fatal(err)
910+ }
911+ relay := &gitSyncEventRelay{}
912+ server := httptest.NewServer(relay)
913+ t.Cleanup(server.Close)
914+ target := "ws" + strings.TrimPrefix(server.URL, "http")
915+ coordinate := "30617:" + owner + ":remote"
916+ root := signedCollaborationEvent(t, secret, event.KIND_GIT_ISSUE, 100,
917+ [][]string{{"a", coordinate}, {"subject", "Remote issue"}}, "historical")
918+ reply := signedCollaborationEvent(t, secret, 1111, 101,
919+ [][]string{{"E", root.ID}, {"K", "1621"}, {"P", author}}, "remote reply")
920+ relay.events = []event.Event{root, reply}
921+ repo := gitrelay.Repository{Owner: owner, Identifier: "remote", Relays: []string{target}}
922+ if err := tenant.gitSyncPeer(context.Background(), repo, target); err != nil {
923+ t.Fatal(err)
924+ }
925+ 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}})
926+ if err != nil {
927+ t.Fatal(err)
928+ }
929+ if len(rows.Events) != 2 {
930+ t.Fatalf("synced collaboration events = %d, want 2", len(rows.Events))
931+ }
932+ if relay.requestCount() < 4 {
933+ t.Fatalf("multi-filter sync issued %d requests, want root and reply filters", relay.requestCount())
934+ }
935+}
936+
937+func TestGitSyncPeerFeatureOffDoesNotDial(t *testing.T) {
938+ _, tenant := testTenant(t)
939+ relay := &gitSyncEventRelay{}
940+ server := httptest.NewServer(relay)
941+ t.Cleanup(server.Close)
942+ target := "ws" + strings.TrimPrefix(server.URL, "http")
943+ repo := gitrelay.Repository{Owner: tenant.Policy().Owner, Identifier: "remote", Relays: []string{target}}
944+ if err := tenant.gitSyncPeer(context.Background(), repo, target); err == nil {
945+ t.Fatal("feature-off sync unexpectedly succeeded")
946+ }
947+ if got := relay.requestCount(); got != 0 {
948+ t.Fatalf("feature-off sync dialed relay with %d requests", got)
949+ }
950+}
951+
952+func TestGitLivePeerImportsEventAfterEOSE(t *testing.T) {
953+ _, tenant := testTenant(t)
954+ tenant.app.cfg.AllowPrivateRelays = true
955+ p := tenant.Policy()
956+ p.Features.Grasp = true
957+ p.Features.Grasp02 = true
958+ p.Features.Grasp05 = true
959+ if err := tenant.applyPolicy(context.Background(), p); err != nil {
960+ t.Fatal(err)
961+ }
962+ owner := tenant.Policy().Owner
963+ secret := strings.Repeat("a", 63) + "b"
964+ author, err := event.PublicKey(secret)
965+ if err != nil {
966+ t.Fatal(err)
967+ }
968+ remote := &gitSyncEventRelay{liveSent: make(chan struct{}, 1)}
969+ server := httptest.NewServer(remote)
970+ t.Cleanup(server.Close)
971+ target := "ws" + strings.TrimPrefix(server.URL, "http")
972+ coordinate := "30617:" + owner + ":live"
973+ announcement := event.Event{Kind: event.KIND_REPO, PubKey: owner, CreatedAt: 199,
974+ Tags: [][]string{{"d", "live"}, {"clone", "http://relay.test/" + owner + "/live.git"}, {"relays", "ws://relay.test"}}}
975+ if err := event.Sign(&announcement, strings.Repeat("0", 63)+"1"); err != nil {
976+ t.Fatal(err)
977+ }
978+ if _, err := tenant.router.Publish(context.Background(), announcement, relay.Session{PubKeys: []string{owner}}); err != nil {
979+ t.Fatal(err)
980+ }
981+ root := signedCollaborationEvent(t, secret, event.KIND_GIT_ISSUE, 200,
982+ [][]string{{"a", coordinate}, {"subject", "Live issue"}}, "root")
983+ liveReply := signedCollaborationEvent(t, secret, 1111, 201,
984+ [][]string{{"E", root.ID}, {"K", "1621"}, {"P", author}}, "after EOSE")
985+ remote.events = []event.Event{root}
986+ remote.live = &liveReply
987+ repo := gitrelay.Repository{Owner: owner, Identifier: "live", Relays: []string{target}}
988+ ctx, cancel := context.WithCancel(context.Background())
989+ done := make(chan struct{})
990+ go func() {
991+ tenant.gitLivePeer(ctx, repo, target)
992+ close(done)
993+ }()
994+ select {
995+ case <-remote.liveSent:
996+ case <-time.After(5 * time.Second):
997+ cancel()
998+ <-done
999+ t.Fatal("live subscription did not receive post-EOSE event")
1000+ }
1001+ deadline := time.Now().Add(2 * time.Second)
1002+ for time.Now().Before(deadline) {
1003+ rows, queryErr := tenant.store.Query(context.Background(), event.Filter{IDs: []string{liveReply.ID}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}})
1004+ if queryErr == nil && len(rows.Events) == 1 {
1005+ break
1006+ }
1007+ time.Sleep(10 * time.Millisecond)
1008+ }
1009+ cancel()
1010+ select {
1011+ case <-done:
1012+ case <-time.After(5 * time.Second):
1013+ t.Fatal("live worker did not stop")
1014+ }
1015+ rows, err := tenant.store.Query(context.Background(), event.Filter{IDs: []string{liveReply.ID}}, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{All: true}})
1016+ if err != nil {
1017+ t.Fatal(err)
1018+ }
1019+ if len(rows.Events) != 1 {
1020+ t.Fatalf("post-EOSE event was not imported: %d", len(rows.Events))
1021+ }
1022+}
1023+
1024+func TestGitSyncPeerPrivatePeerReadinessRefusesBeforeWebsocket(t *testing.T) {
1025+ _, tenant := testTenant(t)
1026+ tenant.app.cfg.AllowPrivateRelays = true
1027+ var websocketRequests int
1028+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
1029+ if req.Header.Get("Accept") == "application/nostr+json" {
1030+ w.Header().Set("Content-Type", "application/nostr+json")
1031+ _, _ = w.Write([]byte(`{"supported_grasps":["GRASP-01"],"private_service":false}`))
1032+ return
1033+ }
1034+ websocketRequests++
1035+ }))
1036+ t.Cleanup(server.Close)
1037+ target := "ws" + strings.TrimPrefix(server.URL, "http")
1038+ p := tenant.Policy()
1039+ p.Features.Grasp = true
1040+ p.Features.Grasp02 = true
1041+ p.Features.Grasp08 = true
1042+ p.Reads = "members"
1043+ p.PrivatePeers = []string{target}
1044+ if err := tenant.applyPolicy(context.Background(), p); err != nil {
1045+ t.Fatal(err)
1046+ }
1047+ repo := gitrelay.Repository{Owner: p.Owner, Identifier: "private", Private: true, Relays: []string{target}}
1048+ if err := tenant.gitSyncPeer(context.Background(), repo, target); err == nil {
1049+ t.Fatal("unready private peer unexpectedly accepted")
1050+ }
1051+ if websocketRequests != 0 {
1052+ t.Fatalf("private readiness failure opened %d websocket requests", websocketRequests)
1053+ }
1054+}
1055diff --git a/internal/gitrelay/collaboration_diff.go b/internal/gitrelay/collaboration_diff.go
1056new file mode 100644
1057index 0000000..1fdf8df
1058--- /dev/null
1059+++ b/internal/gitrelay/collaboration_diff.go
1060@@ -0,0 +1,118 @@
1061+package gitrelay
1062+
1063+import (
1064+ "context"
1065+ "errors"
1066+ "fmt"
1067+ "strings"
1068+ "time"
1069+)
1070+
1071+const collaborationDiffBytes = 512 * 1024
1072+const collaborationDiffTimeout = 5 * time.Second
1073+
1074+// CollaborationDiff is the result of rendering a pull request's local Git
1075+// objects. Status is "ok" when Diff is authoritative and
1076+// "diff_unavailable" when one or both commits are not present locally.
1077+type CollaborationDiff struct {
1078+ Status string `json:"status"`
1079+ Base string `json:"base,omitempty"`
1080+ Tip string `json:"tip,omitempty"`
1081+ MergeBase string `json:"merge_base,omitempty"`
1082+ Diff string `json:"diff,omitempty"`
1083+ Truncated bool `json:"truncated,omitempty"`
1084+ Reason string `json:"reason,omitempty"`
1085+}
1086+
1087+// PRDiff renders a bounded diff from commits already present in the
1088+// repository. It never fetches or resolves a remote ref. A supplied base is
1089+// used only after it is verified as a local commit; when it is empty, Git's
1090+// merge-base with the repository target is used.
1091+func (g *GitRelay) PRDiff(ctx context.Context, r Repository, base, tip string) CollaborationDiff {
1092+ ctx, cancel := context.WithTimeout(ctx, collaborationDiffTimeout)
1093+ defer cancel()
1094+ result := CollaborationDiff{Status: "diff_unavailable", Base: base, Tip: tip}
1095+ if !isObjectID(base) && base != "" {
1096+ result.Reason = "invalid base commit"
1097+ return result
1098+ }
1099+ if !isObjectID(tip) {
1100+ result.Reason = "invalid tip commit"
1101+ return result
1102+ }
1103+ if err := g.localCommit(ctx, r, tip); err != nil {
1104+ result.Reason = "tip commit is unavailable locally"
1105+ return result
1106+ }
1107+ if base != "" {
1108+ if err := g.localCommit(ctx, r, base); err != nil {
1109+ result.Reason = "base commit is unavailable locally"
1110+ return result
1111+ }
1112+ }
1113+ target, err := g.targetCommit(ctx, r)
1114+ if err != nil {
1115+ result.Reason = "repository target commit is unavailable locally"
1116+ return result
1117+ }
1118+ if base != "" {
1119+ // A PR base is meaningful only when it is reachable from both sides of
1120+ // the comparison. Checking the current target as well prevents an old
1121+ // or malicious PR-only ancestor from being presented as the repository
1122+ // diff base.
1123+ if err := g.browseCommand(ctx, r, "merge-base", "--is-ancestor", base, tip).Run(); err != nil {
1124+ result.Reason = "base is not an ancestor of tip"
1125+ return result
1126+ }
1127+ if err := g.browseCommand(ctx, r, "merge-base", "--is-ancestor", base, target).Run(); err != nil {
1128+ result.Reason = "base is not an ancestor of repository target"
1129+ return result
1130+ }
1131+ result.MergeBase = base
1132+ } else {
1133+ b, err := g.browseCommand(ctx, r, "merge-base", "--", tip, target).Output()
1134+ if err != nil || !isObjectID(strings.TrimSpace(string(b))) {
1135+ result.Reason = "merge base is unavailable locally"
1136+ return result
1137+ }
1138+ result.MergeBase = strings.TrimSpace(string(b))
1139+ result.Base = result.MergeBase
1140+ }
1141+
1142+ b, truncated, err := readBrowseOutput(g.browseCommand(ctx, r, "diff", "--format=", "--no-ext-diff", "--no-textconv", "--no-renames", "--binary", result.MergeBase, tip, "--"), collaborationDiffBytes)
1143+ if err != nil {
1144+ result.Reason = "unable to render local diff"
1145+ return result
1146+ }
1147+ result.Status = "ok"
1148+ result.Diff = strings.ToValidUTF8(string(b), "ďż˝")
1149+ result.Truncated = truncated
1150+ return result
1151+}
1152+
1153+func (g *GitRelay) targetCommit(ctx context.Context, r Repository) (string, error) {
1154+ target := strings.TrimPrefix(r.Head, "ref: ")
1155+ if target == "" || !validBrowsePath(target) {
1156+ return "", errors.New("missing repository target")
1157+ }
1158+ b, err := g.browseCommand(ctx, r, "rev-parse", "--verify", "--end-of-options", target+"^{commit}").Output()
1159+ if err != nil {
1160+ return "", err
1161+ }
1162+ target = strings.TrimSpace(string(b))
1163+ if !isObjectID(target) || g.localCommit(ctx, r, target) != nil {
1164+ return "", errors.New("target commit unavailable")
1165+ }
1166+ return target, nil
1167+}
1168+
1169+func (g *GitRelay) localCommit(ctx context.Context, r Repository, oid string) error {
1170+ if !isObjectID(oid) {
1171+ return errors.New("invalid object id")
1172+ }
1173+ b, err := g.browseCommand(ctx, r, "cat-file", "-t", oid).Output()
1174+ if err != nil || strings.TrimSpace(string(b)) != "commit" {
1175+ return fmt.Errorf("commit unavailable: %s", oid)
1176+ }
1177+ return nil
1178+}
1179diff --git a/internal/gitrelay/collaboration_diff_test.go b/internal/gitrelay/collaboration_diff_test.go
1180new file mode 100644
1181index 0000000..b08a4cf
1182--- /dev/null
1183+++ b/internal/gitrelay/collaboration_diff_test.go
1184@@ -0,0 +1,112 @@
1185+package gitrelay
1186+
1187+import (
1188+ "context"
1189+ "os"
1190+ "os/exec"
1191+ "strings"
1192+ "testing"
1193+)
1194+
1195+func TestPRDiffUsesLocalCommitsAndReportsMetadata(t *testing.T) {
1196+ g, r, base := browseFixture(t)
1197+ tip := r.Refs["refs/heads/main"]
1198+ got := g.PRDiff(context.Background(), r, base, tip)
1199+ if got.Status != "ok" || got.Base != base || got.Tip != tip || got.MergeBase != base {
1200+ t.Fatalf("result = %+v", got)
1201+ }
1202+ if !strings.Contains(got.Diff, "+// second") || got.Truncated {
1203+ t.Fatalf("diff = %+v", got)
1204+ }
1205+}
1206+
1207+func TestPRDiffDoesNotTurnMissingOrUnrelatedObjectsIntoEmptyDiff(t *testing.T) {
1208+ g, r, base := browseFixture(t)
1209+ tip := r.Refs["refs/heads/main"]
1210+ missing := strings.Repeat("f", 40)
1211+ if got := g.PRDiff(context.Background(), r, base, missing); got.Status != "diff_unavailable" || got.Reason == "" {
1212+ t.Fatalf("missing tip result = %+v", got)
1213+ }
1214+ if got := g.PRDiff(context.Background(), r, missing, r.Refs["refs/heads/main"]); got.Status != "diff_unavailable" || got.Reason == "" {
1215+ t.Fatalf("missing base result = %+v", got)
1216+ }
1217+ // The first commit in the fixture is not related to an independently
1218+ // created root. The backend must disclose that it cannot establish a PR
1219+ // base instead of presenting a whole-tree comparison as a normal diff.
1220+ tree, err := exec.Command("git", "--git-dir", g.repoPath(r), "mktree").Output()
1221+ if err != nil {
1222+ t.Fatal(err)
1223+ }
1224+ cmd := exec.Command("git", "--git-dir", g.repoPath(r), "commit-tree", strings.TrimSpace(string(tree)), "-m", "unrelated")
1225+ 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")
1226+ otherBytes, err := cmd.Output()
1227+ if err != nil {
1228+ t.Fatal(err)
1229+ }
1230+ other := strings.TrimSpace(string(otherBytes))
1231+ if got := g.PRDiff(context.Background(), r, other, base); got.Status != "diff_unavailable" {
1232+ t.Fatalf("unrelated result = %+v", got)
1233+ }
1234+ // This base is an ancestor of the PR tip but belongs only to that PR;
1235+ // it is not an ancestor of the repository target.
1236+ treeBytes, err := exec.Command("git", "--git-dir", g.repoPath(r), "rev-parse", base+"^{tree}").Output()
1237+ if err != nil {
1238+ t.Fatal(err)
1239+ }
1240+ commit := func(parents ...string) string {
1241+ args := []string{"--git-dir", g.repoPath(r), "commit-tree", strings.TrimSpace(string(treeBytes))}
1242+ for _, parent := range parents {
1243+ args = append(args, "-p", parent)
1244+ }
1245+ args = append(args, "-m", "PR-only")
1246+ cmd := exec.Command("git", args...)
1247+ 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")
1248+ out, err := cmd.Output()
1249+ if err != nil {
1250+ t.Fatal(err)
1251+ }
1252+ return strings.TrimSpace(string(out))
1253+ }
1254+ prBase := commit(base)
1255+ prTip := commit(prBase)
1256+ if got := g.PRDiff(context.Background(), r, prBase, prTip); got.Status != "diff_unavailable" {
1257+ t.Fatalf("PR-only ancestor result = %+v (main tip %s)", got, tip)
1258+ }
1259+}
1260+
1261+func TestPRDiffAcceptsBaseWhenTargetBranchDiverged(t *testing.T) {
1262+ g, r, base := browseFixture(t)
1263+ tip := r.Refs["refs/heads/main"]
1264+ treeBytes, err := exec.Command("git", "--git-dir", g.repoPath(r), "rev-parse", base+"^{tree}").Output()
1265+ if err != nil {
1266+ t.Fatal(err)
1267+ }
1268+ commit := func(message string) string {
1269+ cmd := exec.Command("git", "--git-dir", g.repoPath(r), "commit-tree", strings.TrimSpace(string(treeBytes)), "-p", base, "-m", message)
1270+ 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")
1271+ out, err := cmd.Output()
1272+ if err != nil {
1273+ t.Fatal(err)
1274+ }
1275+ return strings.TrimSpace(string(out))
1276+ }
1277+ target := commit("target branch")
1278+ if err := exec.Command("git", "--git-dir", g.repoPath(r), "update-ref", "refs/heads/target", target).Run(); err != nil {
1279+ t.Fatal(err)
1280+ }
1281+ r.Head = "refs/heads/target"
1282+ got := g.PRDiff(context.Background(), r, base, tip)
1283+ if got.Status != "ok" || got.MergeBase != base {
1284+ t.Fatalf("diverged target result = %+v", got)
1285+ }
1286+}
1287+
1288+func TestPRDiffFindsMergeBaseFromRepositoryHead(t *testing.T) {
1289+ g, r, base := browseFixture(t)
1290+ tip := r.Refs["refs/heads/main"]
1291+ r.Head = "refs/heads/main"
1292+ got := g.PRDiff(context.Background(), r, "", tip)
1293+ if got.Status != "ok" || got.MergeBase != tip || got.Base != tip {
1294+ t.Fatalf("result = %+v (base fixture commit %s)", got, base)
1295+ }
1296+}
1297diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go
1298index 0384ed0..0ce3936 100644
1299--- a/internal/telemetry/telemetry.go
1300+++ b/internal/telemetry/telemetry.go
1301@@ -236,7 +236,7 @@ func latencyBuckets() []float64 {
1302 return []float64{.0005, .001, .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10}
1303 }
1304
1305-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": {}}
1306+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": {}}
1307 var outcomeNames = map[string]struct{}{"success": {}, "ok": {}, "invalid": {}, "unauthorized": {}, "timeout": {}, "busy": {}, "closed": {}, "error": {}}
1308 var queueNames = map[string]struct{}{"inbox": {}, "outbox": {}, "write": {}, "delivery": {}}
1309 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": {}}
1310diff --git a/internal/webui/collaboration.go b/internal/webui/collaboration.go
1311new file mode 100644
1312index 0000000..0e2e2c3
1313--- /dev/null
1314+++ b/internal/webui/collaboration.go
1315@@ -0,0 +1,30 @@
1316+package webui
1317+
1318+import "encoding/json"
1319+
1320+// The in-process backend can return typed slices inside a map. Normalize the
1321+// slice before handing it to templates, just as a JSON client would receive it.
1322+func collaborationSlice(value any, field string) []any {
1323+ items := valueMap(value)[field]
1324+ if rows, ok := items.([]any); ok {
1325+ return rows
1326+ }
1327+ var rows []any
1328+ encoded, err := json.Marshal(items)
1329+ if err == nil {
1330+ _ = json.Unmarshal(encoded, &rows)
1331+ }
1332+ return rows
1333+}
1334+
1335+func collaborationItems(value any) []any { return collaborationSlice(value, "items") }
1336+func collaborationReplies(value any) []any { return collaborationSlice(value, "replies") }
1337+
1338+func collaborationLabels(value any) []string {
1339+ var labels []string
1340+ encoded, err := json.Marshal(valueMap(value)["labels"])
1341+ if err == nil {
1342+ _ = json.Unmarshal(encoded, &labels)
1343+ }
1344+ return labels
1345+}
1346diff --git a/internal/webui/collaboration.html b/internal/webui/collaboration.html
1347new file mode 100644
1348index 0000000..ff09316
1349--- /dev/null
1350+++ b/internal/webui/collaboration.html
1351@@ -0,0 +1,37 @@
1352+{{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")}}
1353+{{if or (eq $view "issues") (eq $view "prs")}}<section id="collaboration-list">
1354+<h1>{{if $issues}}Issues{{else}}Pull requests{{end}}</h1>
1355+<form method="get" action="/repo" id="collaboration-filter">
1356+<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}}">
1357+{{$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>
1358+<label>Search <input name="q" value="{{.Query.Get "q"}}"></label><label>Label <input name="label" value="{{.Query.Get "label"}}"></label><button>Filter</button>
1359+</form>
1360+{{$items := collabItems .Event}}{{if $items}}<table id="rows">
1361+<thead><tr><th>status</th><th>title</th><th>author</th><th>opened</th></tr></thead>
1362+<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>
1363+{{end}}</tbody></table>{{else}}<p>No matching {{if $issues}}issues{{else}}pull requests{{end}}.</p>{{end}}
1364+{{with index $data "next_cursor"}}<p id="pages"><a href="{{browsePageURL "/repo" $.Query "cursor" .}}">next page</a></p>{{end}}
1365+{{if .Actor}}<details id="collaboration-new"><summary>{{if $issues}}New issue{{else}}New pull request{{end}}</summary>
1366+<nostr-compose kind="{{if $issues}}1621{{else}}1618{{end}}" coordinate="{{$coordinate}}">
1367+<label>Title <input name="title" required maxlength="300"></label><label>Labels <input name="labels" placeholder="bug, help wanted"></label>
1368+{{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}}
1369+<label>Description <textarea name="content" required></textarea></label><button>Sign and publish</button>
1370+</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}}
1371+</section>
1372+{{else}}{{$item := asMap (index $data "item")}}<section id="collaboration-detail">
1373+<p><a href="{{if $issues}}{{repoURL .Query "issues" ""}}{{else}}{{repoURL .Query "prs" ""}}{{end}}">{{if $issues}}All issues{{else}}All pull requests{{end}}</a></p>
1374+<h1>{{index $item "title"}}</h1>
1375+<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>
1376+{{with collabLabels $item}}<p>Labels: {{join . ", "}}</p>{{end}}
1377+<article id="collaboration-body">{{markdown (str (index $item "content"))}}</article>
1378+{{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}}
1379+<h2>Conversation</h2>
1380+{{$replies := collabReplies .Event}}{{if $replies}}<ol id="events">
1381+{{range $reply := $replies}}{{$r := asMap $reply}}<li>{{template "nostrEvent" $reply}}
1382+{{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>
1383+{{end}}</ol>{{else}}<p>No replies yet.</p>{{end}}
1384+{{with index $data "reply_cursor"}}<p id="pages"><a href="{{browsePageURL "/repo" $.Query "cursor" .}}">older replies</a></p>{{end}}
1385+{{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>
1386+{{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}}
1387+<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}}
1388+</section>{{end}}{{end}}
1389diff --git a/internal/webui/collaboration_test.go b/internal/webui/collaboration_test.go
1390new file mode 100644
1391index 0000000..e3da3c3
1392--- /dev/null
1393+++ b/internal/webui/collaboration_test.go
1394@@ -0,0 +1,84 @@
1395+package webui
1396+
1397+import (
1398+ "context"
1399+ "encoding/json"
1400+ "github.com/FelineStateMachine/tinyrelay/internal/policy"
1401+ "html"
1402+ "net/http"
1403+ "net/http/httptest"
1404+ "net/url"
1405+ "strings"
1406+ "testing"
1407+)
1408+
1409+func TestCollaborationTypedBackendResults(t *testing.T) {
1410+ type item struct {
1411+ ID string `json:"id"`
1412+ Labels []string `json:"labels"`
1413+ }
1414+ value := map[string]any{"items": []item{{ID: "issue", Labels: []string{"bug"}}}, "replies": []item{{ID: "reply"}}}
1415+ items := collaborationItems(value)
1416+ if len(items) != 1 || valueMap(items[0])["id"] != "issue" {
1417+ t.Fatalf("typed items lost: %v", items)
1418+ }
1419+ if labels := collaborationLabels(items[0]); len(labels) != 1 || labels[0] != "bug" {
1420+ t.Fatalf("labels lost: %v", labels)
1421+ }
1422+ if replies := collaborationReplies(value); len(replies) != 1 || valueMap(replies[0])["id"] != "reply" {
1423+ t.Fatalf("typed replies lost: %v", replies)
1424+ }
1425+}
1426+
1427+type collaborationBackend struct{ *fakeBackend }
1428+
1429+func (b *collaborationBackend) Query(ctx context.Context, method string, params []json.RawMessage, actor string) (any, error) {
1430+ b.call = method
1431+ b.params = params
1432+ type item struct {
1433+ ID string `json:"id"`
1434+ Title string `json:"title"`
1435+ Kind int `json:"kind"`
1436+ Author string `json:"author"`
1437+ Content string `json:"content"`
1438+ Status string `json:"status"`
1439+ }
1440+ root := item{ID: strings.Repeat("b", 64), Title: "A visible issue", Kind: 1621, Author: b.policy.Owner, Content: "Issue body", Status: "closed"}
1441+ if method == "browseissues" {
1442+ return map[string]any{"items": []item{root}}, nil
1443+ }
1444+ if method == "browseissue" {
1445+ 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}}, nil
1446+ }
1447+ return map[string]any{}, nil
1448+}
1449+
1450+func TestCollaborationPagesUseTypedResultsAndWorkingDetailLinks(t *testing.T) {
1451+ backend := &collaborationBackend{&fakeBackend{policy: policy.Defaults(strings.Repeat("a", 64))}}
1452+ app, err := New(backend, Options{Actor: func(*http.Request) (string, error) { return backend.policy.Owner, nil }})
1453+ if err != nil {
1454+ t.Fatal(err)
1455+ }
1456+ query := url.Values{"owner": {backend.policy.Owner}, "repo": {"test"}}
1457+ w := httptest.NewRecorder()
1458+ app.ServeHTTP(w, httptest.NewRequest(http.MethodGet, repoURL(query, "issues", ""), nil))
1459+ if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), "A visible issue") {
1460+ t.Fatalf("list failed: %d %s", w.Code, w.Body.String())
1461+ }
1462+ link := repoURL(query, "issue", strings.Repeat("b", 64))
1463+ if !strings.Contains(w.Body.String(), html.EscapeString(link)) {
1464+ t.Fatalf("detail link missing: %s", link)
1465+ }
1466+ w = httptest.NewRecorder()
1467+ app.ServeHTTP(w, httptest.NewRequest(http.MethodGet, link, nil))
1468+ 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>") {
1469+ t.Fatalf("detail failed: %d %s", w.Code, w.Body.String())
1470+ }
1471+ var request map[string]any
1472+ if err := json.Unmarshal(backend.params[0], &request); err != nil {
1473+ t.Fatal(err)
1474+ }
1475+ if backend.call != "browseissue" || request["event"] != strings.Repeat("b", 64) {
1476+ t.Fatalf("wrong detail request: %s %v", backend.call, request)
1477+ }
1478+}
1479diff --git a/internal/webui/components.js b/internal/webui/components.js
1480index acb5372..7cef85d 100644
1481--- a/internal/webui/components.js
1482+++ b/internal/webui/components.js
1483@@ -23,6 +23,9 @@
1484 // <file-tools [hash="…"] [type="…"]>
1485 // Copies Blossom links, encrypts and uploads a file, decrypts a share
1486 // link fragment and sends a random-key file as a NIP-17 message.
1487+// <nostr-compose kind="…" coordinate="30617:…" [root="…" root-pubkey="…" root-kind="…"] [parent="…" …]>
1488+// Signs a NIP-34 issue, pull request, NIP-22 reply or status event and
1489+// posts it to the relay.
1490 // <file-workspace> and <file-workspace-root> live in file-workspace.js;
1491 // <private-services> lives in private-services.js.
1492 (() => {
1493@@ -411,6 +414,64 @@
1494 }
1495 }
1496
1497+ // NostrCompose signs NIP-34 issue, pull request and conversation events.
1498+ // The relay receives the signed event directly, so the same control works
1499+ // with NIP-07 and the existing NIP-46 bridge.
1500+ class NostrCompose extends FormElement {
1501+ async submit(form) {
1502+ if (!window.nostr?.signEvent) throw Error("Connect a signer first.");
1503+ const value = name => form.elements[name]?.value.trim() || "";
1504+ const mode = this.getAttribute("kind");
1505+ const kind = Number(mode === "status" ? value("status") : mode);
1506+ if (![1621, 1618, 1111, 1630, 1631, 1632, 1633].includes(kind)) throw Error("Choose a valid event type.");
1507+ const coordinate = this.getAttribute("coordinate") || "";
1508+ if (!/^30617:[0-9a-f]{64}:.+$/.test(coordinate)) throw Error("The repository address is missing.");
1509+ const tags = [["a", coordinate]];
1510+ if (kind === 1111 || kind >= 1630) {
1511+ const root = this.getAttribute("root"), pubkey = this.getAttribute("root-pubkey");
1512+ const rootKind = this.getAttribute("root-kind");
1513+ if (!isHex64(root) || !isHex64(pubkey) || !["1617", "1618", "1621"].includes(rootKind)) throw Error("The conversation address is missing.");
1514+ if (kind === 1111) {
1515+ const parent = this.getAttribute("parent") || root;
1516+ const parentPubkey = this.getAttribute("parent-pubkey") || pubkey;
1517+ const parentKind = this.getAttribute("parent-kind") || rootKind;
1518+ if (!isHex64(parent) || !isHex64(parentPubkey) || !["1111", rootKind].includes(parentKind)) throw Error("The reply address is invalid.");
1519+ tags.push(["E", root, "", pubkey], ["K", rootKind], ["P", pubkey], ["e", parent, "", parentPubkey], ["k", parentKind], ["p", parentPubkey]);
1520+ } else {
1521+ tags.push(["e", root, "", "root"], ["p", pubkey]);
1522+ if (coordinate.split(":")[1] !== pubkey) tags.push(["p", coordinate.split(":")[1]]);
1523+ }
1524+ }
1525+ if (kind === 1621 || kind === 1618) {
1526+ if (!value("title")) throw Error("Enter a title.");
1527+ tags.push(["p", coordinate.split(":")[1]], ["subject", value("title")]);
1528+ const labels = [...new Set(value("labels").split(",").map(label => label.trim()).filter(Boolean))];
1529+ labels.forEach(label => tags.push(["t", label]));
1530+ }
1531+ if (kind === 1618) {
1532+ const commit = value("commit"), base = value("merge-base");
1533+ if (!/^[0-9a-f]{40}$/.test(commit) || (base && !/^[0-9a-f]{40}$/.test(base))) throw Error("Enter a full Git commit ID.");
1534+ let clone; try { clone = new URL(value("clone")); } catch { throw Error("Enter a clone URL."); }
1535+ if (!["https:", "http:"].includes(clone.protocol) || clone.username || clone.password || clone.hash) throw Error("Enter an HTTP or HTTPS clone URL without credentials.");
1536+ tags.push(["c", commit], ["clone", clone.href]);
1537+ if (base) tags.push(["merge-base", base]);
1538+ }
1539+ const content = form.elements.content?.value || "";
1540+ if ((kind === 1621 || kind === 1111) && !content.trim()) throw Error("Enter a message.");
1541+ const unsigned = {kind, created_at: Math.floor(Date.now() / 1000), tags, content};
1542+ // Keep a separate copy: extensions may mutate their input while signing.
1543+ const expected = JSON.stringify(unsigned);
1544+ const event = await window.nostr.signEvent(JSON.parse(expected));
1545+ const actual = event && JSON.stringify({kind: event.kind, created_at: event.created_at, tags: event.tags, content: event.content});
1546+ if (actual !== expected || !window.NostrSigner?.verifyEvent(event)) throw Error("The signer returned an invalid or changed event.");
1547+ const response = await tiny.signedFetch("/events", "POST", JSON.stringify(event), {contentType: "application/json"});
1548+ const result = await response.json();
1549+ if (!response.ok || result.accepted !== true) throw Error(result.error || result.message || "The relay rejected the event.");
1550+ this.report("Published. Reload to see the update.");
1551+ form.reset();
1552+ }
1553+ }
1554+
1555 class NostrKey extends HTMLElement {
1556 static get observedAttributes() { return ["hex"]; }
1557
1558@@ -775,6 +836,7 @@
1559 customElements.define("file-mirror", FileMirror);
1560 customElements.define("file-tools", FileTools);
1561 customElements.define("publish-list", PublishList);
1562+ customElements.define("nostr-compose", NostrCompose);
1563 customElements.define("nostr-key", NostrKey);
1564 customElements.define("json-view", JsonView);
1565
1566diff --git a/internal/webui/page.html b/internal/webui/page.html
1567index 83135c9..53f950d 100644
1568--- a/internal/webui/page.html
1569+++ b/internal/webui/page.html
1570@@ -62,11 +62,6 @@
1571 <script>{{script "blossom-upload.js"}}</script>
1572 <script>{{script "file-messages.js"}}</script>
1573 <script>{{script "file-workspace.js"}}</script>
1574-<script>{{script "blossom-encryption.js"}}</script>
1575-<script>{{script "blossom-manifests.js"}}</script>
1576-<script>{{script "blossom-upload.js"}}</script>
1577-<script>{{script "file-messages.js"}}</script>
1578-<script>{{script "file-workspace.js"}}</script>
1579 <script>{{script "components.js"}}</script>
1580 <script>{{script "private-services.js"}}</script>
1581 </body>
1582@@ -78,7 +73,7 @@
1583 <nav><a href="/signin" aria-current="page">/signin</a></nav>
1584 <p>Private relay. Sign in with a Nostr signer to continue.</p>
1585 {{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>
1586-<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>
1587+<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>
1588 <p>&larr; <a href="/repos">all repositories</a></p>
1589 {{else if eq $kind "manage"}}<header><span><a href="/">{{.Slug}}</a>/<b>manage</b></span></header>
1590 <nav>{{range $item := manageItems}}<a href="{{$item.Href}}"{{if eq $item.Tab $.Tab}} aria-current="page"{{end}}>{{$item.Label}}</a>{{end}}</nav>
1591@@ -114,9 +109,9 @@
1592 {{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>
1593 {{if .Actor}}<section><h4>You</h4><table><tr><th>key</th><td>{{template "nostrKey" .Actor}}</td></tr></table></section>{{end}}{{end}}
1594
1595-{{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>
1596+{{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>
1597 <section><h4>Clone</h4><ul>{{range index $r "clone"}}<li><code>{{.}}</code></li>{{else}}<li>no clone URL advertised</li>{{end}}</ul></section>
1598-{{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}}
1599+{{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}}
1600
1601 {{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>
1602 <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>
1603diff --git a/internal/webui/repo.html b/internal/webui/repo.html
1604index 9aad9e7..79a44e7 100644
1605--- a/internal/webui/repo.html
1606+++ b/internal/webui/repo.html
1607@@ -29,6 +29,7 @@
1608 {{end}}</ol>
1609 {{else}}<p>No repository activity.</p>
1610 {{end}}</section>
1611+{{else if or (eq $view "issues") (eq $view "prs") (eq $view "issue") (eq $view "pr")}}{{template "collaboration" .}}
1612 {{end}}{{end}}{{end}}
1613
1614 {{define "repoTree"}}{{$repo := asMap .Event}}{{$entries := index $repo "entries"}}{{if .Tree}}{{$entries = .Tree}}{{end}}<ul>
1615diff --git a/internal/webui/repo_navigation.go b/internal/webui/repo_navigation.go
1616index d294b24..497dc7e 100644
1617--- a/internal/webui/repo_navigation.go
1618+++ b/internal/webui/repo_navigation.go
1619@@ -12,7 +12,11 @@ type repoCrumb struct{ Label, URL string }
1620 func repoURL(query url.Values, view, path string) string {
1621 values := url.Values{"owner": {query.Get("owner")}, "repo": {query.Get("repo")}, "ref": {query.Get("ref")}, "view": {view}}
1622 if path != "" {
1623- values.Set("path", path)
1624+ if view == "issue" || view == "pr" {
1625+ values.Set("id", path)
1626+ } else {
1627+ values.Set("path", path)
1628+ }
1629 }
1630 return "/repo?" + values.Encode()
1631 }
1632diff --git a/internal/webui/style.css b/internal/webui/style.css
1633index cd4959e..c5024e1 100644
1634--- a/internal/webui/style.css
1635+++ b/internal/webui/style.css
1636@@ -20,7 +20,7 @@ pre{white-space:pre-wrap;overflow-wrap:anywhere}
1637 img{max-width:100%}
1638
1639 /* Selection: chrome is not selectable, content and values are. */
1640-#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}
1641+#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}
1642
1643 /* Shell: a table with three columns; each column scrolls on its own. */
1644 #topbar{display:none}
1645@@ -76,8 +76,10 @@ img{max-width:100%}
1646 #rows th:last-child{text-align:right}
1647 #rows td a{color:inherit}#rows td:first-child a{color:var(--accent)}
1648 #pages{font:12px var(--mono)}
1649-#file-search{margin:0 0 14px;padding:0 0 8px;border-bottom:1px solid var(--line)}
1650+#collaboration-filter,#file-search{margin:0 0 14px;padding:0 0 8px;border-bottom:1px solid var(--line)}
1651+#collaboration-body{max-width:720px}
1652 #file-actions{max-width:720px;margin:0 0 18px}
1653+#events li>details{margin:0 0 10px;font:12px var(--mono)}#events li>details>summary{cursor:pointer;color:var(--muted)}
1654
1655 /* Data tables inside content, including json-view results. */
1656 scroll-box{display:block;overflow-x:auto;max-width:100%}
1657@@ -105,14 +107,14 @@ dl{margin:.5rem 0}dt{font-weight:600}dd{margin:0 0 .35rem 1rem}
1658 #readme code{font-size:12.5px}#readme ul,#readme ol{padding-left:20px}
1659
1660 /* rpc-form, signed-form, publish-list: one signed call each. */
1661-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}
1662-file-tools,file-workspace,file-workspace-root{max-width:720px}
1663+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}
1664+nostr-compose,file-tools,file-workspace,file-workspace-root{max-width:720px}
1665 file-tools h3,file-workspace h3{margin:0 0 4px}file-tools p,file-workspace p{margin:6px 0}
1666-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)}
1667-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}
1668+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)}
1669+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}
1670 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%}
1671-rpc-form label:has(textarea),publish-list label:has(textarea){display:block}
1672-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}
1673+rpc-form label:has(textarea),publish-list label:has(textarea),nostr-compose label:has(textarea){display:block}
1674+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}
1675 rpc-form output[data-error],file-tools output[data-error],file-workspace output[data-error]{color:var(--alert)}
1676 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}
1677 file-workspace-root button{background:transparent;border:0;padding:0;color:var(--accent);font:inherit;text-align:left}
1678diff --git a/internal/webui/templates.go b/internal/webui/templates.go
1679index aa806f2..55fe770 100644
1680--- a/internal/webui/templates.go
1681+++ b/internal/webui/templates.go
1682@@ -13,7 +13,7 @@ import (
1683 // templateFS holds every page template. page.html owns the shell and the
1684 // shared partials; the other files each define one group of tabs.
1685 //
1686-//go:embed page.html public.html manage.html browse.html repo.html
1687+//go:embed page.html public.html manage.html browse.html repo.html collaboration.html
1688 var templateFS embed.FS
1689
1690 // styleCSS is inlined into every page so the UI needs no extra request and
1691@@ -162,6 +162,7 @@ func parseTemplates() (*template.Template, error) {
1692 "relayItems": func() []navItem { return relayNav },
1693 "manageItems": func() []navItem { return manageNav },
1694 "add": func(a, b int) int { return a + b },
1695+ "strs": func(values ...string) []string { return values },
1696 "repoCommitURL": repoCommitURL,
1697 "hasNextOffset": hasNextOffset,
1698 "repoURL": repoURL,
1699@@ -171,6 +172,9 @@ func parseTemplates() (*template.Template, error) {
1700 "repoDate": repoDate,
1701 "sourceHTML": sourceHTML,
1702 "diffHTML": diffHTML,
1703+ "collabItems": collaborationItems,
1704+ "collabReplies": collaborationReplies,
1705+ "collabLabels": collaborationLabels,
1706 }
1707 tmpl, err := template.New("webui").Funcs(funcs).ParseFS(templateFS, "*.html")
1708 if err != nil {
1709diff --git a/internal/webui/webmcp.go b/internal/webui/webmcp.go
1710index 572a453..00d809a 100644
1711--- a/internal/webui/webmcp.go
1712+++ b/internal/webui/webmcp.go
1713@@ -26,7 +26,7 @@ func (a *App) webMCPQuery(writer http.ResponseWriter, request *http.Request) {
1714 return
1715 }
1716 }
1717- allowed := map[string]bool{"browserepos": true, "browserepo": true, "browsefiles": true, "browsefile": true, "browsestatus": true}
1718+ allowed := map[string]bool{"browserepos": true, "browserepo": true, "browsefiles": true, "browsefile": true, "browsestatus": true, "browseissues": true, "browsepulls": true, "browseissue": true, "browsepull": true}
1719 if !allowed[method] {
1720 writeJSON(writer, http.StatusBadRequest, map[string]string{"error": "unsupported WebMCP query"})
1721 return
1722diff --git a/internal/webui/webmcp.js b/internal/webui/webmcp.js
1723index 08ffce4..c0f21fa 100644
1724--- a/internal/webui/webmcp.js
1725+++ b/internal/webui/webmcp.js
1726@@ -82,8 +82,19 @@
1727 register("tiny.read_repository", "Read a repository tree, source file, history, commit diff or activity. Use ref to select a branch, tag or commit.",
1728 object({...repository, offset: {type: "integer", minimum: 0}, limit}, ["owner", "repo"]), reads,
1729 (input, signal) => query("browserepo", input, signal));
1730+ const collaborationList = object({owner: pubkey, repo: id, cursor: text, limit, q: text, label: text,
1731+ state: {type: "string", enum: ["open", "resolved", "merged", "closed", "draft"]}}, ["owner", "repo"]);
1732+ const collaborationDetail = object({owner: pubkey, repo: id, event: hash, cursor: text, limit}, ["owner", "repo", "event"]);
1733+ register("tiny.list_issues", "List repository issues with search, status filters and pagination.",
1734+ collaborationList, reads, (input, signal) => query("browseissues", input, signal));
1735+ register("tiny.read_issue", "Read an issue, its replies and authorized status changes.",
1736+ collaborationDetail, reads, (input, signal) => query("browseissue", input, signal));
1737+ register("tiny.list_pull_requests", "List repository pull requests with search, status filters and pagination.",
1738+ collaborationList, reads, (input, signal) => query("browsepulls", input, signal));
1739+ register("tiny.read_pull_request", "Read a pull request, its replies, authorized updates and available diff.",
1740+ collaborationDetail, reads, (input, signal) => query("browsepull", input, signal));
1741 register("tiny.list_files", "List stored files visible to your account.",
1742- object({cursor: text, limit}), reads, (input, signal) => query("browsefiles", input, signal));
1743+ object({cursor: text, limit, q: text}), reads, (input, signal) => query("browsefiles", input, signal));
1744 register("tiny.read_file", "Read a stored file's metadata and available preview by SHA-256 hash.",
1745 object({hash}, ["hash"]), reads, (input, signal) => query("browsefile", input, signal));
1746 register("tiny.read_status", "Read service health, storage and job status. Requires a signed-in owner or moderator session.",
1747@@ -108,13 +119,13 @@
1748 }
1749 function repoURL(input) {
1750 const params = new URLSearchParams();
1751- for (const key of ["owner", "repo", "ref", "path", "view"]) {
1752+ for (const key of ["owner", "repo", "ref", "path", "view", "id"]) {
1753 if (input[key] !== undefined) params.set(key, input[key]);
1754 }
1755 return "/repo?" + params;
1756 }
1757 register("tiny.open_repository", "Open a repository in this tab for browsing code, history or activity.",
1758- object(repository, ["owner", "repo"]), {}, input => open(repoURL(input)));
1759+ object({...repository, id: hash, view: {type: "string", enum: ["tree", "file", "history", "commit", "activity", "issues", "prs", "issue", "pr"]}}, ["owner", "repo"]), {}, input => open(repoURL(input)));
1760 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.",
1761 object({hash}, ["hash"]), {}, input => open("/file?hash=" + encodeURIComponent(input.hash)));
1762 register("tiny.open_status", "Open the relay status page in this tab.", object(), {}, () => open("/manage/status"));
1763diff --git a/internal/webui/webui.go b/internal/webui/webui.go
1764index 764afeb..2092ec9 100644
1765--- a/internal/webui/webui.go
1766+++ b/internal/webui/webui.go
1767@@ -489,7 +489,28 @@ func (a *App) browse(writer http.ResponseWriter, request *http.Request) {
1768 if parsed, parseErr := strconv.Atoi(request.URL.Query().Get("offset")); parseErr == nil && parsed >= 0 {
1769 offset = parsed
1770 }
1771- 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}
1772+ backendView := view
1773+ if view == "issues" {
1774+ method = "browseissues"
1775+ backendView = ""
1776+ }
1777+ if view == "prs" {
1778+ method = "browsepulls"
1779+ backendView = ""
1780+ }
1781+ if view == "issue" {
1782+ method = "browseissue"
1783+ backendView = ""
1784+ }
1785+ if view == "pr" {
1786+ method = "browsepull"
1787+ backendView = ""
1788+ }
1789+ eventID := request.URL.Query().Get("id")
1790+ if eventID == "" && (view == "issue" || view == "pr") {
1791+ eventID = request.URL.Query().Get("path")
1792+ }
1793+ 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}
1794 case "browsefile":
1795 query = map[string]any{"hash": request.URL.Query().Get("hash")}
1796 if query["hash"] == "" {
1797diff --git a/package.json b/package.json
1798index ac34bc9..1771e8b 100644
1799--- a/package.json
1800+++ b/package.json
1801@@ -7,7 +7,8 @@
1802 "test:conformance": "node scripts/conformance/run.mjs",
1803 "test:webmcp": "node --test scripts/webmcp.test.mjs",
1804 "test:signer": "node --test scripts/signer.test.mjs",
1805- "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"
1806+ "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",
1807+ "test:collaboration": "node --test scripts/collaboration-compose.test.mjs"
1808 },
1809 "devDependencies": {
1810 "@noble/hashes": "2.4.0",
1811diff --git a/scripts/collaboration-compose.test.mjs b/scripts/collaboration-compose.test.mjs
1812new file mode 100644
1813index 0000000..a8c4ced
1814--- /dev/null
1815+++ b/scripts/collaboration-compose.test.mjs
1816@@ -0,0 +1,80 @@
1817+import test from "node:test";
1818+import assert from "node:assert/strict";
1819+import fs from "node:fs";
1820+import vm from "node:vm";
1821+import { finalizeEvent, generateSecretKey, verifyEvent } from "nostr-tools";
1822+
1823+const root = "a".repeat(64), author = "b".repeat(64), parent = "c".repeat(64), parentAuthor = "d".repeat(64);
1824+const coordinate = `30617:${author}:test`;
1825+const source = fs.readFileSync("internal/webui/components.js", "utf8");
1826+const composeSource = source.slice(source.indexOf(" class NostrCompose "), source.indexOf(" class NostrKey "));
1827+function setup(attributes, values = {}, options = {}) {
1828+ const sent = [], signed = [];
1829+ let resets = 0;
1830+ class FormElement {
1831+ getAttribute(name) { return attributes[name] ?? null; }
1832+ report(message) { this.message = message; }
1833+ }
1834+ const window = {NostrSigner: {verifyEvent}, nostr: {signEvent: async event => {
1835+ signed.push(structuredClone(event));
1836+ if (options.change) event.content = "changed";
1837+ return finalizeEvent(structuredClone(event), generateSecretKey());
1838+ }}};
1839+ const tiny = {signedFetch: async (path, method, body) => {
1840+ sent.push({path, method, event: JSON.parse(body)});
1841+ return Response.json(options.result ?? {accepted: true});
1842+ }};
1843+ const Constructor = vm.runInNewContext(`${composeSource}\nNostrCompose`, {FormElement, window, tiny, URL, isHex64: value => /^[0-9a-f]{64}$/.test(value || "")});
1844+ const component = new Constructor();
1845+ const form = {elements: Object.fromEntries(Object.entries(values).map(([key, value]) => [key, {value}])), reset: () => { resets++; }};
1846+ return {component, form, sent, signed, resets: () => resets};
1847+}
1848+const rootAttrs = {coordinate, root, "root-pubkey": author, "root-kind": "1621"};
1849+
1850+test("issue composition signs subject, deduplicated labels and body", async () => {
1851+ const s = setup({kind: "1621", coordinate}, {title: " Bug ", labels: "ui, bug, ui", content: "Markdown **body**"});
1852+ await s.component.submit(s.form);
1853+ assert.deepEqual(s.sent[0].event.tags, [["a", coordinate], ["p", author], ["subject", "Bug"], ["t", "ui"], ["t", "bug"]]);
1854+ assert.equal(s.sent[0].path, "/events");
1855+ assert.equal(s.sent[0].event.kind, 1621);
1856+ assert.equal(verifyEvent(s.sent[0].event), true);
1857+ assert.equal(s.resets(), 1);
1858+});
1859+
1860+test("nested NIP-22 replies preserve the root and address the immediate parent", async () => {
1861+ const s = setup({...rootAttrs, kind: "1111", parent, "parent-pubkey": parentAuthor, "parent-kind": "1111"}, {content: "A reply"});
1862+ await s.component.submit(s.form);
1863+ assert.deepEqual(s.sent[0].event.tags, [["a", coordinate], ["E", root, "", author], ["K", "1621"], ["P", author], ["e", parent, "", parentAuthor], ["k", "1111"], ["p", parentAuthor]]);
1864+});
1865+
1866+test("status selector signs a NIP-34 root marker with an optional empty body", async () => {
1867+ const s = setup({...rootAttrs, kind: "status"}, {status: "1632", content: ""});
1868+ await s.component.submit(s.form);
1869+ assert.equal(s.sent[0].event.kind, 1632);
1870+ assert.deepEqual(s.sent[0].event.tags, [["a", coordinate], ["e", root, "", "root"], ["p", author]]);
1871+});
1872+
1873+test("PRs publish the commit, clone and merge-base tags", async () => {
1874+ const commit = "1".repeat(40), base = "2".repeat(40);
1875+ const s = setup({kind: "1618", coordinate}, {title: "Change", commit, "merge-base": base, clone: "https://example.test/project.git", content: "Why"});
1876+ await s.component.submit(s.form);
1877+ assert.deepEqual(s.sent[0].event.tags.slice(-3), [["c", commit], ["clone", "https://example.test/project.git"], ["merge-base", base]]);
1878+});
1879+
1880+test("relay rejection and signer substitution retain the draft", async () => {
1881+ for (const options of [{result: {accepted: false, message: "blocked"}}, {change: true}]) {
1882+ const s = setup({kind: "1621", coordinate}, {title: "Bug", content: "Details"}, options);
1883+ await assert.rejects(s.component.submit(s.form), /blocked|changed/);
1884+ assert.equal(s.resets(), 0);
1885+ if (options.change) assert.equal(s.sent.length, 0);
1886+ }
1887+});
1888+
1889+test("invalid conversation addresses and PR commit IDs are rejected before signing", async () => {
1890+ for (const [attrs, values] of [[{...rootAttrs, kind: "1111", parent: "bad"}, {content: "Reply"}], [{kind: "1618", coordinate}, {title: "PR", commit: "main", clone: "https://example.test/repo"}]]) {
1891+ const s = setup(attrs, values);
1892+ await assert.rejects(s.component.submit(s.form));
1893+ assert.equal(s.signed.length, 0);
1894+ assert.equal(s.sent.length, 0);
1895+ }
1896+});
1897diff --git a/scripts/qa/ux-fixture.mjs b/scripts/qa/ux-fixture.mjs
1898index 1709705..b9dc299 100644
1899--- a/scripts/qa/ux-fixture.mjs
1900+++ b/scripts/qa/ux-fixture.mjs
1901@@ -112,6 +112,10 @@ const entries = new Map([
1902 for (const [path, content] of entries) { const full = join(repo, path); await mkdir(join(full, ".."), { recursive: true }); await writeFile(full, content); }
1903 await git(repo, "add", ".");
1904 try { await git(repo, "commit", "-m", "UX fixture paths"); } catch (error) { if (!String(error.stdout).includes("nothing to commit")) throw error; }
1905+const mergeBase = await git(repo, "rev-parse", "HEAD");
1906+await writeFile(join(repo, "README UX fixture.md"), "UX fixture repository\nPaths and refs contain awkward URL characters.\nPR fixture change.\n");
1907+await git(repo, "add", "."); await git(repo, "commit", "-m", "PR fixture change");
1908+const pullTip = await git(repo, "rev-parse", "HEAD");
1909 await git(repo, "tag", "-f", "release/1.0"); await git(repo, "branch", "-f", "feature/café");
1910 // GRASP identifiers are intentionally conservative; awkward characters live
1911 // in the display name and tracked paths, where viewers must still URL-escape.
1912@@ -121,8 +125,17 @@ const announcement = event(30617, "", [["d", repoId], ["name", "UX space/#/?/% c
1913 await relay.publish(announcement);
1914 const state = event(30618, "", [["d", repoId], ["HEAD", "ref: refs/heads/main"], ...refs.map(([ref, oid]) => [ref, oid])]);
1915 await relay.publish(state);
1916+// Seed NIP-34 collaboration roots and their signed thread events so browser
1917+// checks cover issue filtering, PR detail rendering and status transitions.
1918+const coordinate = `30617:${owner}:${repoId}`;
1919+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);
1920+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);
1921+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);
1922+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);
1923+const closed = event(1632, "", [["a", coordinate], ["e", issue.id, "", "root"], ["p", owner]], created + 8);
1924+for (const collaboration of [issue, pull, patch, reply, closed]) await relay.publish(collaboration);
1925 try { await git(repo, "remote", "set-url", "origin", cloneURL); } catch { await git(repo, "remote", "add", "origin", cloneURL); }
1926 await git(repo, "push", "origin", "--all"); await git(repo, "push", "origin", "--tags");
1927-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 } };
1928+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 } };
1929 await writeFile(join(outDir, "fixture.json"), json(manifest) + "\n");
1930 relay.close(); console.log(json({ ok: true, manifest: join(outDir, "fixture.json"), notes: notes.length, articles: articles.length, files: files.length, repository: repoId }));
1931diff --git a/scripts/webmcp.test.mjs b/scripts/webmcp.test.mjs
1932index b7d2104..95d5eb4 100644
1933--- a/scripts/webmcp.test.mjs
1934+++ b/scripts/webmcp.test.mjs
1935@@ -107,3 +107,26 @@ test("cancellation reaches the fetch request", async () => {
1936 await tools.get("tiny.list_files").execute({}, {signal: controller.signal});
1937 assert.equal(received, controller.signal);
1938 });
1939+
1940+test("collaboration tools preserve repository, event and filter scope", async () => {
1941+ const requests = [];
1942+ const {tools} = await browser({path: "/r/work/tools", fetch: async url => {
1943+ requests.push(new URL(url, "https://tiny.example"));
1944+ return new Response(JSON.stringify({items: [], item: {title: "Issue"}}));
1945+ }});
1946+ const repo = {owner: "a".repeat(64), repo: "notes"};
1947+ for (const [name, method, extra] of [
1948+ ["tiny.list_issues", "browseissues", {q: "bug", state: "resolved", limit: 5}],
1949+ ["tiny.read_issue", "browseissue", {event: "b".repeat(64)}],
1950+ ["tiny.list_pull_requests", "browsepulls", {state: "merged"}],
1951+ ["tiny.read_pull_request", "browsepull", {event: "c".repeat(64)}]
1952+ ]) {
1953+ const tool = tools.get(name);
1954+ assert.equal(tool.annotations.readOnlyHint, true);
1955+ await tool.execute({...repo, ...extra});
1956+ const url = requests.at(-1);
1957+ assert.equal(url.pathname, "/r/work/webmcp/query");
1958+ assert.equal(url.searchParams.get("method"), method);
1959+ assert.deepEqual(JSON.parse(url.searchParams.get("params")), [{...repo, ...extra}]);
1960+ }
1961+});

About

ownerc01503e8035f
visibilitypublic
head09a911f984e7

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs