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