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