Monorepo for Tangled
tangled.org
1package models
2
3import (
4 "bytes"
5 "compress/gzip"
6 "fmt"
7 "io"
8 "log"
9 "slices"
10 "strings"
11 "time"
12
13 "tangled.org/core/api/tangled"
14 "tangled.org/core/patchutil"
15 "tangled.org/core/types"
16
17 "github.com/bluesky-social/indigo/atproto/syntax"
18 lexutil "github.com/bluesky-social/indigo/lex/util"
19)
20
21type PullState int
22
23const (
24 PullClosed PullState = iota
25 PullOpen
26 PullMerged
27 PullAbandoned
28)
29
30func (p PullState) String() string {
31 switch p {
32 case PullOpen:
33 return "open"
34 case PullMerged:
35 return "merged"
36 case PullClosed:
37 return "closed"
38 case PullAbandoned:
39 return "abandoned"
40 default:
41 return "closed"
42 }
43}
44
45func (p PullState) IsOpen() bool {
46 return p == PullOpen
47}
48func (p PullState) IsMerged() bool {
49 return p == PullMerged
50}
51func (p PullState) IsClosed() bool {
52 return p == PullClosed
53}
54func (p PullState) IsAbandoned() bool {
55 return p == PullAbandoned
56}
57
58type Pull struct {
59 // ids
60 ID int
61 PullId int
62
63 // at ids
64 RepoDid syntax.DID
65 OwnerDid string
66 Rkey string
67
68 // content
69 Title string
70 Body string
71 TargetBranch string
72 State PullState
73 Submissions []*PullSubmission
74 Mentions []syntax.DID
75 References []syntax.ATURI
76
77 // stacking
78 DependentOn *syntax.ATURI
79
80 // meta
81 Created time.Time
82 PullSource *PullSource
83
84 // optionally, populate this when querying for reverse mappings
85 Labels LabelState
86 Repo *Repo
87}
88
89// NOTE: This method does not include patch blob in returned atproto record
90func (p Pull) AsRecord() tangled.RepoPull {
91 mentions := make([]string, len(p.Mentions))
92 for i, did := range p.Mentions {
93 mentions[i] = string(did)
94 }
95 references := make([]string, len(p.References))
96 for i, uri := range p.References {
97 references[i] = string(uri)
98 }
99
100 rounds := make([]*tangled.RepoPull_Round, len(p.Submissions))
101 for i, submission := range p.Submissions {
102 rounds[i] = submission.AsRecord()
103 }
104
105 var dependentOn *string
106 if p.DependentOn != nil {
107 x := p.DependentOn.String()
108 dependentOn = &x
109 }
110
111 return tangled.RepoPull{
112 Title: p.Title,
113 Body: &p.Body,
114 Mentions: mentions,
115 References: references,
116 CreatedAt: p.Created.Format(time.RFC3339),
117 Target: &tangled.RepoPull_Target{
118 Repo: string(p.RepoDid),
119 Branch: p.TargetBranch,
120 },
121 Rounds: rounds,
122 Source: p.PullSource.AsRecord(),
123 DependentOn: dependentOn,
124 }
125}
126
127func PullFromRecord(did, rkey string, record tangled.RepoPull, blobs []*io.ReadCloser) (*Pull, error) {
128 created, err := time.Parse(time.RFC3339, record.CreatedAt)
129 if err != nil {
130 return nil, fmt.Errorf("invalid createdAt: %w", err)
131 }
132
133 body := ""
134 if record.Body != nil {
135 body = *record.Body
136 }
137
138 var mentions []syntax.DID
139 for _, m := range record.Mentions {
140 if did, err := syntax.ParseDID(m); err == nil {
141 mentions = append(mentions, did)
142 }
143 }
144
145 var targetRepoDid syntax.DID
146 var targetBranch string
147 if record.Target != nil {
148 did, err := syntax.ParseDID(record.Target.Repo)
149 if err != nil {
150 return nil, fmt.Errorf("invalid target.repo did: %w", err)
151 }
152 targetRepoDid = did
153 targetBranch = record.Target.Branch
154 }
155
156 var pullSource *PullSource
157 if record.Source != nil {
158 pullSource = &PullSource{
159 Branch: record.Source.Branch,
160 }
161
162 if record.Source.Repo != nil {
163 did, err := syntax.ParseDID(*record.Source.Repo)
164 if err != nil {
165 return nil, fmt.Errorf("invalid source.repo did: %w", err)
166 }
167 pullSource.RepoDid = &did
168 }
169 }
170
171 var dependentOn *syntax.ATURI
172 if record.DependentOn != nil {
173 uri, err := syntax.ParseATURI(*record.DependentOn)
174 if err != nil {
175 return nil, fmt.Errorf("invalid dependentOn aturi: %w", err)
176 }
177 dependentOn = &uri
178 }
179
180 var submissions []*PullSubmission
181 for i, s := range record.Rounds {
182 var blob *io.ReadCloser
183 if i < len(blobs) {
184 blob = blobs[i]
185 }
186 submission, err := PullSubmissionFromRecord(did, rkey, i, s, blob)
187 if err != nil {
188 return nil, fmt.Errorf("invalid pull round at index %d: %w", i, err)
189 }
190 submissions = append(submissions, submission)
191 }
192
193 return &Pull{
194 RepoDid: targetRepoDid,
195 OwnerDid: did,
196 Rkey: rkey,
197 Title: record.Title,
198 Body: body,
199 TargetBranch: targetBranch,
200 PullSource: pullSource,
201 State: PullOpen,
202 Submissions: submissions,
203 Created: created,
204 DependentOn: dependentOn,
205 }, nil
206}
207
208func PullSubmissionFromRecord(did, rkey string, roundNumber int, round *tangled.RepoPull_Round, blob *io.ReadCloser) (*PullSubmission, error) {
209 created, err := time.Parse(time.RFC3339, round.CreatedAt)
210 if err != nil {
211 return nil, fmt.Errorf("invalid createdAt: %w", err)
212 }
213
214 var patch, sourceRev string
215 if blob != nil {
216 p, err := extractGzip(*blob)
217 if err != nil {
218 return nil, fmt.Errorf("failed to extract gzip: %w", err)
219 }
220 patch = p
221 if patchutil.IsFormatPatch(p) {
222 patches, err := patchutil.ExtractPatches(p)
223 if err != nil {
224 return nil, fmt.Errorf("failed to extract patches: %w", err)
225 }
226
227 for _, part := range patches {
228 sourceRev = part.SHA
229 }
230 }
231 }
232
233 return &PullSubmission{
234 PullAt: syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", did, tangled.RepoPullNSID, rkey)),
235 RoundNumber: roundNumber,
236 Blob: *round.PatchBlob,
237 Created: created,
238 Patch: patch,
239 SourceRev: sourceRev,
240 }, nil
241}
242
243type PullSource struct {
244 Branch string
245 RepoDid *syntax.DID
246
247 // optionally populate this for reverse mappings
248 Repo *Repo
249}
250
251func (s *PullSource) AsRecord() *tangled.RepoPull_Source {
252 if s == nil {
253 return nil
254 }
255 var repo *string
256 if s.RepoDid != nil {
257 r := s.RepoDid.String()
258 repo = &r
259 }
260 return &tangled.RepoPull_Source{
261 Branch: s.Branch,
262 Repo: repo,
263 }
264}
265
266type PullSubmission struct {
267 // ids
268 ID int
269
270 // at ids
271 PullAt syntax.ATURI
272
273 // content
274 RoundNumber int
275 Blob lexutil.LexBlob
276 Patch string
277 Combined string
278 Comments []PullComment
279 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs
280
281 // meta
282 Created time.Time
283}
284
285type PullComment struct {
286 // ids
287 ID int
288 PullId int
289 SubmissionId int
290
291 // at ids
292 RepoDid string
293 OwnerDid string
294 CommentAt string
295
296 // content
297 Body string
298
299 // meta
300 Mentions []syntax.DID
301 References []syntax.ATURI
302
303 // meta
304 Created time.Time
305}
306
307func (p *PullComment) AtUri() syntax.ATURI {
308 return syntax.ATURI(p.CommentAt)
309}
310
311func (p *Pull) TotalComments() int {
312 total := 0
313 for _, s := range p.Submissions {
314 total += len(s.Comments)
315 }
316 return total
317}
318
319func (p *Pull) LastRoundNumber() int {
320 return len(p.Submissions) - 1
321}
322
323func (p *Pull) LatestSubmission() *PullSubmission {
324 return p.Submissions[p.LastRoundNumber()]
325}
326
327func (p *Pull) LatestPatch() string {
328 return p.LatestSubmission().Patch
329}
330
331func (p *Pull) LatestSha() string {
332 return p.LatestSubmission().SourceRev
333}
334
335func (p *Pull) AtUri() syntax.ATURI {
336 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey))
337}
338
339func (p *Pull) IsPatchBased() bool {
340 return p.PullSource == nil
341}
342
343func (p *Pull) IsBranchBased() bool {
344 if p.PullSource != nil {
345 if p.PullSource.RepoDid != nil {
346 return *p.PullSource.RepoDid == p.RepoDid
347 }
348 // no repo specified
349 return true
350 }
351 return false
352}
353
354func (p *Pull) IsForkBased() bool {
355 if p.PullSource != nil {
356 if p.PullSource.RepoDid != nil {
357 // make sure repos are different
358 return *p.PullSource.RepoDid != p.RepoDid
359 }
360 }
361 return false
362}
363
364func (p *Pull) Participants() []syntax.DID {
365 participantSet := make(map[syntax.DID]struct{})
366 participants := []syntax.DID{}
367
368 addParticipant := func(did syntax.DID) {
369 if _, exists := participantSet[did]; !exists {
370 participantSet[did] = struct{}{}
371 participants = append(participants, did)
372 }
373 }
374
375 addParticipant(syntax.DID(p.OwnerDid))
376
377 for _, s := range p.Submissions {
378 for _, sp := range s.Participants() {
379 addParticipant(syntax.DID(sp))
380 }
381 }
382
383 return participants
384}
385
386func (s PullSubmission) IsFormatPatch() bool {
387 return patchutil.IsFormatPatch(s.Patch)
388}
389
390func (s PullSubmission) AsFormatPatch() []types.FormatPatch {
391 patches, err := patchutil.ExtractPatches(s.Patch)
392 if err != nil {
393 log.Println("error extracting patches from submission:", err)
394 return []types.FormatPatch{}
395 }
396
397 return patches
398}
399
400// empty if invalid, not otherwise
401func (s PullSubmission) ChangeId() string {
402 patches := s.AsFormatPatch()
403 if len(patches) != 1 {
404 return ""
405 }
406
407 c, err := patches[0].ChangeId()
408 if err != nil {
409 return ""
410 }
411
412 return c
413}
414
415func (s *PullSubmission) Participants() []string {
416 participantSet := make(map[string]struct{})
417 participants := []string{}
418
419 addParticipant := func(did string) {
420 if _, exists := participantSet[did]; !exists {
421 participantSet[did] = struct{}{}
422 participants = append(participants, did)
423 }
424 }
425
426 addParticipant(s.PullAt.Authority().String())
427
428 for _, c := range s.Comments {
429 addParticipant(c.OwnerDid)
430 }
431
432 return participants
433}
434
435func (s PullSubmission) CombinedPatch() string {
436 if s.Combined == "" {
437 return s.Patch
438 }
439
440 return s.Combined
441}
442
443func (s *PullSubmission) GetBlob() *lexutil.LexBlob {
444 if !s.Blob.Ref.Defined() {
445 return nil
446 }
447
448 return &s.Blob
449}
450
451func (s *PullSubmission) AsRecord() *tangled.RepoPull_Round {
452 return &tangled.RepoPull_Round{
453 CreatedAt: s.Created.Format(time.RFC3339),
454 PatchBlob: s.GetBlob(),
455 }
456}
457
458type Stack []*Pull
459
460// position of this pull in the stack
461func (stack Stack) Position(pull *Pull) int {
462 return slices.IndexFunc(stack, func(p *Pull) bool {
463 return p.AtUri() == pull.AtUri()
464 })
465}
466
467// all pulls below this pull (including self) in this stack
468//
469// nil if this pull does not belong to this stack
470func (stack Stack) Below(pull *Pull) Stack {
471 position := stack.Position(pull)
472
473 if position < 0 {
474 return nil
475 }
476
477 return stack[position:]
478}
479
480// all pulls below this pull (excluding self) in this stack
481func (stack Stack) StrictlyBelow(pull *Pull) Stack {
482 below := stack.Below(pull)
483
484 if len(below) > 0 {
485 return below[1:]
486 }
487
488 return nil
489}
490
491// all pulls above this pull (including self) in this stack
492func (stack Stack) Above(pull *Pull) Stack {
493 position := stack.Position(pull)
494
495 if position < 0 {
496 return nil
497 }
498
499 return stack[:position+1]
500}
501
502// all pulls below this pull (excluding self) in this stack
503func (stack Stack) StrictlyAbove(pull *Pull) Stack {
504 above := stack.Above(pull)
505
506 if len(above) > 0 {
507 return above[:len(above)-1]
508 }
509
510 return nil
511}
512
513// the combined format-patches of all the newest submissions in this stack
514func (stack Stack) CombinedPatch() string {
515 // go in reverse order because the bottom of the stack is the last element in the slice
516 var combined strings.Builder
517 for idx := range stack {
518 pull := stack[len(stack)-1-idx]
519 combined.WriteString(pull.LatestPatch())
520 combined.WriteString("\n")
521 }
522 return combined.String()
523}
524
525// filter out PRs that are "active"
526//
527// PRs that are still open are active
528func (stack Stack) Mergeable() Stack {
529 var mergeable Stack
530
531 for _, p := range stack {
532 // stop at the first merged PR
533 if p.State == PullMerged || p.State == PullClosed {
534 break
535 }
536
537 // skip over abandoned PRs
538 if p.State != PullAbandoned {
539 mergeable = append(mergeable, p)
540 }
541 }
542
543 return mergeable
544}
545
546type BranchDeleteStatus struct {
547 Repo *Repo
548 Branch string
549}
550
551func extractGzip(blob io.Reader) (string, error) {
552 var b bytes.Buffer
553 r, err := gzip.NewReader(blob)
554 if err != nil {
555 return "", err
556 }
557 defer r.Close()
558
559 const maxSize = 15 * 1024 * 1024
560 limitedReader := io.LimitReader(r, maxSize)
561
562 _, err = io.Copy(&b, limitedReader)
563 if err != nil {
564 return "", err
565 }
566
567 return b.String(), nil
568}