| 1 | diff --git a/internal/daemon/client_browse.go b/internal/daemon/client_browse.go |
| 2 | index 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 | } |
| 42 | diff --git a/internal/daemon/collaboration_acceptance_test.go b/internal/daemon/collaboration_acceptance_test.go |
| 43 | new file mode 100644 |
| 44 | index 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 | +} |
| 340 | diff --git a/internal/daemon/collaboration_browse.go b/internal/daemon/collaboration_browse.go |
| 341 | new file mode 100644 |
| 342 | index 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 | +} |
| 757 | diff --git a/internal/daemon/collaboration_browse_test.go b/internal/daemon/collaboration_browse_test.go |
| 758 | new file mode 100644 |
| 759 | index 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 | +} |
| 799 | diff --git a/internal/daemon/git_sync_acceptance_test.go b/internal/daemon/git_sync_acceptance_test.go |
| 800 | new file mode 100644 |
| 801 | index 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 | +} |
| 1055 | diff --git a/internal/gitrelay/collaboration_diff.go b/internal/gitrelay/collaboration_diff.go |
| 1056 | new file mode 100644 |
| 1057 | index 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 | +} |
| 1179 | diff --git a/internal/gitrelay/collaboration_diff_test.go b/internal/gitrelay/collaboration_diff_test.go |
| 1180 | new file mode 100644 |
| 1181 | index 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 | +} |
| 1297 | diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go |
| 1298 | index 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": {}} |
| 1310 | diff --git a/internal/webui/collaboration.go b/internal/webui/collaboration.go |
| 1311 | new file mode 100644 |
| 1312 | index 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 | +} |
| 1346 | diff --git a/internal/webui/collaboration.html b/internal/webui/collaboration.html |
| 1347 | new file mode 100644 |
| 1348 | index 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}} |
| 1389 | diff --git a/internal/webui/collaboration_test.go b/internal/webui/collaboration_test.go |
| 1390 | new file mode 100644 |
| 1391 | index 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 | +} |
| 1479 | diff --git a/internal/webui/components.js b/internal/webui/components.js |
| 1480 | index 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 | |
| 1566 | diff --git a/internal/webui/page.html b/internal/webui/page.html |
| 1567 | index 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>← <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> |
| 1603 | diff --git a/internal/webui/repo.html b/internal/webui/repo.html |
| 1604 | index 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> |
| 1615 | diff --git a/internal/webui/repo_navigation.go b/internal/webui/repo_navigation.go |
| 1616 | index 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 | } |
| 1632 | diff --git a/internal/webui/style.css b/internal/webui/style.css |
| 1633 | index 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} |
| 1678 | diff --git a/internal/webui/templates.go b/internal/webui/templates.go |
| 1679 | index 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 { |
| 1709 | diff --git a/internal/webui/webmcp.go b/internal/webui/webmcp.go |
| 1710 | index 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 |
| 1722 | diff --git a/internal/webui/webmcp.js b/internal/webui/webmcp.js |
| 1723 | index 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")); |
| 1763 | diff --git a/internal/webui/webui.go b/internal/webui/webui.go |
| 1764 | index 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"] == "" { |
| 1797 | diff --git a/package.json b/package.json |
| 1798 | index 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", |
| 1811 | diff --git a/scripts/collaboration-compose.test.mjs b/scripts/collaboration-compose.test.mjs |
| 1812 | new file mode 100644 |
| 1813 | index 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 | +}); |
| 1897 | diff --git a/scripts/qa/ux-fixture.mjs b/scripts/qa/ux-fixture.mjs |
| 1898 | index 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 })); |
| 1931 | diff --git a/scripts/webmcp.test.mjs b/scripts/webmcp.test.mjs |
| 1932 | index 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 | +}); |