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