tinyrelayfile
tinyrelay/internal/daemon/collaboration_browse.go/

internal/daemon/collaboration_browse.go raw
1package daemon
2
3import (
4 "context"
5 "encoding/json"
6 "errors"
7 "sort"
8 "strconv"
9 "strings"
10 "time"
11
12 "github.com/FelineStateMachine/tinyrelay/internal/event"
13 gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit"
14 "github.com/FelineStateMachine/tinyrelay/internal/relay"
15 "github.com/FelineStateMachine/tinyrelay/internal/storage"
16)
17
18type collaborationItem struct {
19 ID string `json:"id"`
20 Kind int `json:"kind"`
21 Author string `json:"author"`
22 CreatedAt int64 `json:"created_at"`
23 Title string `json:"title"`
24 Content string `json:"content"`
25 Labels []string `json:"labels"`
26 Status string `json:"status"`
27 collaborationProposal
28}
29
30type collaborationDetail struct {
31 Repository map[string]any `json:"repository"`
32 Root event.Event `json:"root"`
33 Item collaborationItem `json:"item"`
34 Replies []collaborationReply `json:"replies"`
35 Updates []event.Event `json:"updates"`
36 Statuses []event.Event `json:"statuses"`
37 Diff string `json:"diff,omitempty"`
38 CanStatus bool `json:"can_status"`
39 CanApprove bool `json:"can_approve"`
40 DiffUnavailable string `json:"diff_unavailable,omitempty"`
41 DiffTruncated bool `json:"diff_truncated,omitempty"`
42 ReplyCursor string `json:"reply_cursor,omitempty"`
43}
44
45func (t *Tenant) collaborationRepository(ctx context.Context, repo gitrelay.Repository) map[string]any {
46 return map[string]any{"owner": repo.Owner, "private": repo.Private, "clone": repo.Clone, "refs": repo.Refs, "head": repo.Head, "maintainers": t.repositoryMaintainers(ctx, repo)}
47}
48
49func (t *Tenant) collaborationRepo(ctx context.Context, actor string, q clientBrowseRequest) (gitrelay.Repository, error) {
50 if t.git == nil || !t.Policy().Features.Grasp {
51 return gitrelay.Repository{}, errors.New("not found: Git hosting is disabled")
52 }
53 r, err := t.git.BrowseRepository(q.Owner, q.Repo)
54 if err != nil {
55 return gitrelay.Repository{}, err
56 }
57 if err := t.browseRepoAccess(ctx, actor, r); err != nil {
58 return gitrelay.Repository{}, err
59 }
60 r.Private = r.Private || t.PrivateServiceEnabled()
61 return r, nil
62}
63
64func (t *Tenant) browseCollaboration(ctx context.Context, actor string, q clientBrowseRequest, kind int) (any, error) {
65 r, err := t.collaborationRepo(ctx, actor, q)
66 if err != nil {
67 return nil, err
68 }
69 if q.Limit <= 0 || q.Limit > 100 {
70 q.Limit = 50
71 }
72 cursor, err := parseCollaborationCursor(q.Cursor)
73 if err != nil {
74 return nil, err
75 }
76 if q.State != "" && !contains([]string{"open", "resolved", "merged", "closed", "draft"}, q.State) {
77 return nil, errors.New("invalid: issue or pull request status")
78 }
79 coordinate := "30617:" + r.Owner + ":" + r.Identifier
80 filters := []event.Filter{{Kinds: []int{kind}, Tags: map[string][]string{"a": {coordinate}}}, {Kinds: []int{kind}, Tags: map[string][]string{"A": {coordinate}}}}
81 session := browseSession(t, actor)
82 if _, err := t.gate.Read(ctx, filters, session); err != nil {
83 return nil, err
84 }
85 roles := t.collaborationRoles(r)
86 canApprove, err := roles.decides(ctx, actor)
87 if err != nil {
88 return nil, err
89 }
90 result := func(items []collaborationItem, next string) map[string]any {
91 return map[string]any{"repository": t.collaborationRepository(ctx, r), "items": items, "next_cursor": next, "can_approve": canApprove}
92 }
93 items := make([]collaborationItem, 0, q.Limit)
94 next := ""
95 for scan := 0; scan < 100; scan++ {
96 var rows []event.Event
97 more := false
98 for _, filter := range filters {
99 page, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: session.PubKeys}, Limit: 100, Before: cursor})
100 if err != nil {
101 return nil, err
102 }
103 more = more || page.More
104 rows = append(rows, page.Events...)
105 }
106 rows = uniqueEvents(rows)
107 sortEventsNewestFirst(rows)
108 if len(rows) > 100 {
109 rows = rows[:100]
110 more = true
111 }
112 matching := make([]event.Event, 0, len(rows))
113 for _, root := range rows {
114 if t.gate.CanSee(ctx, root, session, nil) && collaborationMatches(root, q.Query) && matchesCollaborationLabel(root, q.Label) {
115 matching = append(matching, root)
116 }
117 }
118 visible, states, err := t.collaborationVisible(ctx, roles, actor, matching)
119 if err != nil {
120 return nil, err
121 }
122 proposals := make(map[string]collaborationProposal, len(visible))
123 for i, root := range visible {
124 proposals[root.ID] = states[i]
125 }
126 statuses, err := t.collaborationStatuses(ctx, actor, roles, visible)
127 if err != nil {
128 return nil, err
129 }
130 for _, root := range rows {
131 cursor = &storage.EventCursor{CreatedAt: root.CreatedAt, ID: root.ID}
132 status, ok := statuses[root.ID]
133 if !ok || (q.State != "" && q.State != status) {
134 continue
135 }
136 if len(items) == q.Limit {
137 return result(items, next), nil
138 }
139 item := collaborationItemFrom(root)
140 item.Status = status
141 item.collaborationProposal = proposals[root.ID]
142 items = append(items, item)
143 next = collaborationCursor(item)
144 }
145 if !more {
146 return result(items, ""), nil
147 }
148 }
149 if cursor != nil {
150 next = strconv.FormatInt(cursor.CreatedAt, 10) + ":" + cursor.ID
151 }
152 return result(items, next), nil
153}
154
155func parseCollaborationCursor(raw string) (*storage.EventCursor, error) {
156 if raw == "" {
157 return nil, nil
158 }
159 stamp, id, ok := strings.Cut(raw, ":")
160 ts, err := strconv.ParseInt(stamp, 10, 64)
161 if !ok || err != nil || ts < 0 || len(id) != 64 || strings.Trim(id, "0123456789abcdef") != "" {
162 return nil, errors.New("invalid: collaboration cursor")
163 }
164 return &storage.EventCursor{CreatedAt: ts, ID: id}, nil
165}
166
167func matchesCollaborationLabel(root event.Event, label string) bool {
168 if label == "" {
169 return true
170 }
171 for _, value := range event.TagValues(root, "t") {
172 if strings.EqualFold(value, label) {
173 return true
174 }
175 }
176 return false
177}
178
179func (t *Tenant) browseCollaborationDetail(ctx context.Context, actor string, q clientBrowseRequest, kind int) (any, error) {
180 if len(q.Event) != 64 {
181 return nil, errors.New("invalid: collaboration event")
182 }
183 r, err := t.collaborationRepo(ctx, actor, q)
184 if err != nil {
185 return nil, err
186 }
187 rows, err := t.Query(ctx, []event.Filter{{IDs: []string{q.Event}, Limit: intPtr(1)}}, browseSession(t, actor))
188 if err != nil {
189 return nil, err
190 }
191 if len(rows) != 1 || rows[0].Kind != kind || !hasCoordinate(rows[0], r) {
192 return nil, errors.New("not found: collaboration event")
193 }
194 roles := t.collaborationRoles(r)
195 roots, states, err := t.collaborationVisible(ctx, roles, actor, rows[:1])
196 if err != nil {
197 return nil, err
198 }
199 if len(roots) != 1 {
200 return nil, errors.New("not found: collaboration event")
201 }
202 root := roots[0]
203 detail := collaborationDetail{Repository: t.collaborationRepository(ctx, r), Root: root, Item: collaborationItemFrom(root), CanStatus: actor != "" && (actor == root.PubKey || t.IsMaintainer(ctx, r, actor))}
204 detail.Item.collaborationProposal = states[0]
205 if detail.CanApprove, err = roles.decides(ctx, actor); err != nil {
206 return nil, err
207 }
208 replyCursor, err := parseCollaborationCursor(q.Cursor)
209 if err != nil {
210 return nil, err
211 }
212 replyLimit := q.Limit
213 if replyLimit <= 0 || replyLimit > 200 {
214 replyLimit = 200
215 }
216 authors := append([]string{root.PubKey}, t.maintainerKeys(ctx, r)...)
217 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))
218 if err != nil {
219 return nil, err
220 }
221 if statuses, err = t.collaborationDecided(ctx, roles, statuses); err != nil {
222 return nil, err
223 }
224 detail.Statuses = statuses
225 detail.Item.Status = statusFromEvents(root, statuses)
226 replyFilters := []event.Filter{
227 {Kinds: []int{1111}, Tags: map[string][]string{"E": {root.ID}, "K": {strconv.Itoa(root.Kind)}, "P": {root.PubKey}}},
228 }
229 // Pending replies are dropped after the page is cut, so a hidden
230 // proposal never shifts the cursor for the people who cannot see it.
231 replies, err := t.queryCollaborationThread(ctx, actor, replyFilters, replyCursor, replyLimit+1)
232 if err != nil {
233 return nil, err
234 }
235 if len(replies) > replyLimit {
236 replies = replies[:replyLimit]
237 detail.ReplyCursor = collaborationCursor(collaborationItemFrom(replies[replyLimit-1]))
238 }
239 replies, replyStates, err := t.collaborationVisible(ctx, roles, actor, replies)
240 if err != nil {
241 return nil, err
242 }
243 detail.Replies = collaborationReplies(replies, replyStates)
244 if kind == event.KIND_GIT_PR {
245 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))
246 if err != nil {
247 return nil, err
248 }
249 tipEvent := root
250 valid := make([]event.Event, 0, len(detail.Updates))
251 for _, update := range detail.Updates {
252 if event.Tag(update, "P") == root.PubKey && event.Tag(update, "E") == root.ID {
253 valid = append(valid, update)
254 }
255 }
256 detail.Updates = valid
257 if len(valid) > 0 {
258 tipEvent = valid[0]
259 }
260 diff := t.git.PRDiff(ctx, r, event.Tag(tipEvent, "merge-base"), event.Tag(tipEvent, "c"))
261 if diff.Status == "ok" {
262 detail.Diff = diff.Diff
263 detail.DiffTruncated = diff.Truncated
264 } else {
265 detail.DiffUnavailable = diff.Reason
266 }
267 }
268
269 return detail, nil
270}
271
272func collaborationItemFrom(e event.Event) collaborationItem {
273 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")}
274}
275
276func collaborationTitle(e event.Event) string {
277 for _, key := range []string{"subject", "title", "name"} {
278 if value := event.Tag(e, key); value != "" {
279 return value
280 }
281 }
282 content := strings.TrimSpace(e.Content)
283 var object struct {
284 Title string `json:"title"`
285 Subject string `json:"subject"`
286 }
287 if json.Unmarshal([]byte(content), &object) == nil {
288 if object.Title != "" {
289 return object.Title
290 }
291 if object.Subject != "" {
292 return object.Subject
293 }
294 }
295 if line, _, ok := strings.Cut(content, "\n"); ok {
296 return line
297 }
298 return content
299}
300
301func collaborationMatches(e event.Event, query string) bool {
302 query = strings.ToLower(strings.TrimSpace(query))
303 return query == "" || strings.Contains(strings.ToLower(collaborationTitle(e)), query) || strings.Contains(strings.ToLower(e.Content), query) || collaborationLabels(e, query)
304}
305func collaborationLabels(e event.Event, query string) bool {
306 for _, label := range event.TagValues(e, "t") {
307 if strings.Contains(strings.ToLower(label), query) {
308 return true
309 }
310 }
311 return false
312}
313
314// collaborationStatuses resolves the status of every root on a list page with
315// one query. Only the root author, the owner and current maintainers can set a
316// status, a proposer never can, and the newest authorized event wins.
317func (t *Tenant) collaborationStatuses(ctx context.Context, actor string, roles *collaborationRoles, roots []event.Event) (map[string]string, error) {
318 statuses := make(map[string]string, len(roots))
319 if len(roots) == 0 {
320 return statuses, nil
321 }
322 ids := make([]string, 0, len(roots))
323 maintainers := t.maintainerKeys(ctx, roles.repo)
324 authors := append([]string(nil), maintainers...)
325 for _, root := range roots {
326 ids = append(ids, root.ID)
327 authors = append(authors, root.PubKey)
328 }
329 limit := 1000
330 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))
331 if err != nil {
332 return nil, err
333 }
334 if rows, err = t.collaborationDecided(ctx, roles, rows); err != nil {
335 return nil, err
336 }
337 byRoot := make(map[string][]event.Event, len(roots))
338 for _, row := range rows {
339 for _, id := range event.TagValues(row, "e") {
340 byRoot[id] = append(byRoot[id], row)
341 }
342 }
343 for _, root := range roots {
344 allowed := []event.Event{}
345 for _, row := range byRoot[root.ID] {
346 if row.PubKey == root.PubKey || contains(maintainers, row.PubKey) {
347 allowed = append(allowed, row)
348 }
349 }
350 statuses[root.ID] = statusFromEvents(root, allowed)
351 }
352 return statuses, nil
353}
354
355func (t *Tenant) queryCollaborationThread(ctx context.Context, actor string, filters []event.Filter, cursor *storage.EventCursor, limit int) ([]event.Event, error) {
356 session := browseSession(t, actor)
357 if _, err := t.gate.Read(ctx, filters, session); err != nil {
358 return nil, err
359 }
360 rows := make([]event.Event, 0)
361 for _, filter := range filters {
362 page, err := t.store.Query(ctx, filter, storage.QueryOptions{Now: time.Now().Unix(), Access: storage.Access{PubKeys: session.PubKeys}, Limit: limit, Before: cursor})
363 if err != nil {
364 return nil, err
365 }
366 for _, row := range page.Events {
367 if t.gate.CanSee(ctx, row, session, nil) {
368 rows = append(rows, row)
369 }
370 }
371 }
372 rows = uniqueEvents(rows)
373 sortEventsNewestFirst(rows)
374 if len(rows) > limit {
375 rows = rows[:limit]
376 }
377 return rows, nil
378}
379
380func statusFromEvents(root event.Event, rows []event.Event) string {
381 sort.Slice(rows, func(i, j int) bool {
382 if rows[i].CreatedAt == rows[j].CreatedAt {
383 return rows[i].ID < rows[j].ID
384 }
385 return rows[i].CreatedAt > rows[j].CreatedAt
386 })
387 if len(rows) == 0 {
388 return "open"
389 }
390 statuses := map[int]string{1630: "open", 1632: "closed", 1633: "draft"}
391 if root.Kind == event.KIND_GIT_ISSUE {
392 statuses[1631] = "resolved"
393 } else {
394 statuses[1631] = "merged"
395 }
396 return statuses[rows[0].Kind]
397}
398
399func collaborationCursor(item collaborationItem) string {
400 return strconv.FormatInt(item.CreatedAt, 10) + ":" + item.ID
401}
402
403func afterCollaborationCursor(item event.Event, cursor string) bool {
404 if cursor == "" {
405 return true
406 }
407 stamp, id, ok := strings.Cut(cursor, ":")
408 if !ok {
409 return item.ID < cursor
410 }
411 timestamp, err := strconv.ParseInt(stamp, 10, 64)
412 if err != nil {
413 return false
414 }
415 return item.CreatedAt < timestamp || (item.CreatedAt == timestamp && item.ID > id)
416}
417
418func hasCoordinate(e event.Event, r gitrelay.Repository) bool {
419 coordinate := "30617:" + r.Owner + ":" + r.Identifier
420 for _, tag := range e.Tags {
421 if len(tag) > 1 && (tag[0] == "a" || tag[0] == "A") && tag[1] == coordinate {
422 return true
423 }
424 }
425 return false
426}
427func browseSession(t *Tenant, actor string) relay.Session {
428 s := relay.Session{RelayURL: t.RelayURL()}
429 if actor != "" {
430 s.PubKeys = []string{actor}
431 }
432 return s
433}
434func contains(values []string, needle string) bool {
435 for _, value := range values {
436 if value == needle {
437 return true
438 }
439 }
440 return false
441}
442
443func uniqueStrings(values []string) []string {
444 seen := make(map[string]struct{}, len(values))
445 result := make([]string, 0, len(values))
446 for _, value := range values {
447 if _, ok := seen[value]; ok {
448 continue
449 }
450 seen[value] = struct{}{}
451 result = append(result, value)
452 }
453 return result
454}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

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

Refs