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