| 1 | package daemon |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "errors" |
| 7 | "sort" |
| 8 | "strconv" |
| 9 | "strings" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/FelineStateMachine/tinyrelay/internal/event" |
| 13 | gitrelay "github.com/FelineStateMachine/tinyrelay/tinygit" |
| 14 | "github.com/FelineStateMachine/tinyrelay/internal/relay" |
| 15 | "github.com/FelineStateMachine/tinyrelay/internal/storage" |
| 16 | ) |
| 17 | |
| 18 | type collaborationItem struct { |
| 19 | ID string `json:"id"` |
| 20 | Kind int `json:"kind"` |
| 21 | Author string `json:"author"` |
| 22 | CreatedAt int64 `json:"created_at"` |
| 23 | Title string `json:"title"` |
| 24 | Content string `json:"content"` |
| 25 | Labels []string `json:"labels"` |
| 26 | Status string `json:"status"` |
| 27 | collaborationProposal |
| 28 | } |
| 29 | |
| 30 | type 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 | |
| 45 | func (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 | |
| 49 | func (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 | |
| 64 | func (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 | |
| 155 | func 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 | |
| 167 | func 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 | |
| 179 | func (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 | |
| 272 | func 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 | |
| 276 | func 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 | |
| 301 | func 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 | } |
| 305 | func 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. |
| 317 | func (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 | |
| 355 | func (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 | |
| 380 | func 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 | |
| 399 | func collaborationCursor(item collaborationItem) string { |
| 400 | return strconv.FormatInt(item.CreatedAt, 10) + ":" + item.ID |
| 401 | } |
| 402 | |
| 403 | func 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 | |
| 418 | func 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 | } |
| 427 | func 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 | } |
| 434 | func 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 | |
| 443 | func 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 | } |