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