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