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