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 []Comment
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
285func (p *Pull) TotalComments() int {
286 total := 0
287 for _, s := range p.Submissions {
288 total += len(s.Comments)
289 }
290 return total
291}
292
293func (p *Pull) LastRoundNumber() int {
294 return len(p.Submissions) - 1
295}
296
297func (p *Pull) LatestSubmission() *PullSubmission {
298 return p.Submissions[p.LastRoundNumber()]
299}
300
301func (p *Pull) LatestPatch() string {
302 return p.LatestSubmission().Patch
303}
304
305func (p *Pull) LatestSha() string {
306 return p.LatestSubmission().SourceRev
307}
308
309func (p *Pull) AtUri() syntax.ATURI {
310 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey))
311}
312
313func (p *Pull) IsPatchBased() bool {
314 return p.PullSource == nil
315}
316
317func (p *Pull) IsBranchBased() bool {
318 if p.PullSource != nil {
319 if p.PullSource.RepoDid != nil {
320 return *p.PullSource.RepoDid == p.RepoDid
321 }
322 // no repo specified
323 return true
324 }
325 return false
326}
327
328func (p *Pull) IsForkBased() bool {
329 if p.PullSource != nil {
330 if p.PullSource.RepoDid != nil {
331 // make sure repos are different
332 return *p.PullSource.RepoDid != p.RepoDid
333 }
334 }
335 return false
336}
337
338func (p *Pull) Participants() []syntax.DID {
339 participantSet := make(map[syntax.DID]struct{})
340 participants := []syntax.DID{}
341
342 addParticipant := func(did syntax.DID) {
343 if _, exists := participantSet[did]; !exists {
344 participantSet[did] = struct{}{}
345 participants = append(participants, did)
346 }
347 }
348
349 addParticipant(syntax.DID(p.OwnerDid))
350
351 for _, s := range p.Submissions {
352 for _, sp := range s.Participants() {
353 addParticipant(syntax.DID(sp))
354 }
355 }
356
357 return participants
358}
359
360func (s PullSubmission) IsFormatPatch() bool {
361 return patchutil.IsFormatPatch(s.Patch)
362}
363
364func (s PullSubmission) AsFormatPatch() []types.FormatPatch {
365 patches, err := patchutil.ExtractPatches(s.Patch)
366 if err != nil {
367 log.Println("error extracting patches from submission:", err)
368 return []types.FormatPatch{}
369 }
370
371 return patches
372}
373
374// empty if invalid, not otherwise
375func (s PullSubmission) ChangeId() string {
376 patches := s.AsFormatPatch()
377 if len(patches) != 1 {
378 return ""
379 }
380
381 c, err := patches[0].ChangeId()
382 if err != nil {
383 return ""
384 }
385
386 return c
387}
388
389func (s *PullSubmission) Participants() []string {
390 participantSet := make(map[string]struct{})
391 participants := []string{}
392
393 addParticipant := func(did string) {
394 if _, exists := participantSet[did]; !exists {
395 participantSet[did] = struct{}{}
396 participants = append(participants, did)
397 }
398 }
399
400 addParticipant(s.PullAt.Authority().String())
401
402 for _, c := range s.Comments {
403 addParticipant(c.Did.String())
404 }
405
406 return participants
407}
408
409func (s PullSubmission) CombinedPatch() string {
410 if s.Combined == "" {
411 return s.Patch
412 }
413
414 return s.Combined
415}
416
417func (s *PullSubmission) GetBlob() *lexutil.LexBlob {
418 if !s.Blob.Ref.Defined() {
419 return nil
420 }
421
422 return &s.Blob
423}
424
425func (s *PullSubmission) AsRecord() *tangled.RepoPull_Round {
426 return &tangled.RepoPull_Round{
427 CreatedAt: s.Created.Format(time.RFC3339),
428 PatchBlob: s.GetBlob(),
429 }
430}
431
432type Stack []*Pull
433
434// position of this pull in the stack
435func (stack Stack) Position(pull *Pull) int {
436 return slices.IndexFunc(stack, func(p *Pull) bool {
437 return p.AtUri() == pull.AtUri()
438 })
439}
440
441// all pulls below this pull (including self) in this stack
442//
443// nil if this pull does not belong to this stack
444func (stack Stack) Below(pull *Pull) Stack {
445 position := stack.Position(pull)
446
447 if position < 0 {
448 return nil
449 }
450
451 return stack[position:]
452}
453
454// all pulls below this pull (excluding self) in this stack
455func (stack Stack) StrictlyBelow(pull *Pull) Stack {
456 below := stack.Below(pull)
457
458 if len(below) > 0 {
459 return below[1:]
460 }
461
462 return nil
463}
464
465// all pulls above this pull (including self) in this stack
466func (stack Stack) Above(pull *Pull) Stack {
467 position := stack.Position(pull)
468
469 if position < 0 {
470 return nil
471 }
472
473 return stack[:position+1]
474}
475
476// all pulls below this pull (excluding self) in this stack
477func (stack Stack) StrictlyAbove(pull *Pull) Stack {
478 above := stack.Above(pull)
479
480 if len(above) > 0 {
481 return above[:len(above)-1]
482 }
483
484 return nil
485}
486
487// the combined format-patches of all the newest submissions in this stack
488func (stack Stack) CombinedPatch() string {
489 // go in reverse order because the bottom of the stack is the last element in the slice
490 var combined strings.Builder
491 for idx := range stack {
492 pull := stack[len(stack)-1-idx]
493 combined.WriteString(pull.LatestPatch())
494 combined.WriteString("\n")
495 }
496 return combined.String()
497}
498
499// filter out PRs that are "active"
500//
501// PRs that are still open are active
502func (stack Stack) Mergeable() Stack {
503 var mergeable Stack
504
505 for _, p := range stack {
506 // stop at the first merged PR
507 if p.State == PullMerged || p.State == PullClosed {
508 break
509 }
510
511 // skip over abandoned PRs
512 if p.State != PullAbandoned {
513 mergeable = append(mergeable, p)
514 }
515 }
516
517 return mergeable
518}
519
520type BranchDeleteStatus struct {
521 Repo *Repo
522 Branch string
523}
524
525func extractGzip(blob io.Reader) (string, error) {
526 var b bytes.Buffer
527 r, err := gzip.NewReader(blob)
528 if err != nil {
529 return "", err
530 }
531 defer r.Close()
532
533 const maxSize = 15 * 1024 * 1024
534 limitedReader := io.LimitReader(r, maxSize)
535
536 _, err = io.Copy(&b, limitedReader)
537 if err != nil {
538 return "", err
539 }
540
541 return b.String(), nil
542}