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