Monorepo for Tangled
tangled.org
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 []Comment
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
284func (p *Pull) TotalComments() int {
285 total := 0
286 for _, s := range p.Submissions {
287 total += len(s.Comments)
288 }
289 return total
290}
291
292func (p *Pull) LastRoundNumber() int {
293 return len(p.Submissions) - 1
294}
295
296func (p *Pull) LatestSubmission() *PullSubmission {
297 return p.Submissions[p.LastRoundNumber()]
298}
299
300func (p *Pull) LatestPatch() string {
301 return p.LatestSubmission().Patch
302}
303
304func (p *Pull) LatestSha() string {
305 return p.LatestSubmission().SourceRev
306}
307
308func (p *Pull) AtUri() syntax.ATURI {
309 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey))
310}
311
312func (p *Pull) IsPatchBased() bool {
313 return p.PullSource == nil
314}
315
316func (p *Pull) IsBranchBased() bool {
317 if p.PullSource != nil {
318 if p.PullSource.RepoAt != nil {
319 return p.PullSource.RepoAt == &p.RepoAt
320 } else {
321 // no repo specified
322 return true
323 }
324 }
325 return false
326}
327
328func (p *Pull) IsForkBased() bool {
329 if p.PullSource != nil {
330 if p.PullSource.RepoAt != nil {
331 // make sure repos are different
332 return p.PullSource.RepoAt != &p.RepoAt
333 }
334 }
335 return false
336}
337
338func (p *Pull) Participants() []string {
339 participantSet := make(map[string]struct{})
340 participants := []string{}
341
342 addParticipant := func(did string) {
343 if _, exists := participantSet[did]; !exists {
344 participantSet[did] = struct{}{}
345 participants = append(participants, did)
346 }
347 }
348
349 addParticipant(p.OwnerDid)
350
351 for _, s := range p.Submissions {
352 for _, sp := range s.Participants() {
353 addParticipant(sp)
354 }
355 }
356
357 return participants
358}
359
360func (s PullSubmission) IsFormatPatch() bool {
361 return patchutil.IsFormatPatch(s.Patch)
362}
363
364func (s PullSubmission) AsFormatPatch() []types.FormatPatch {
365 patches, err := patchutil.ExtractPatches(s.Patch)
366 if err != nil {
367 log.Println("error extracting patches from submission:", err)
368 return []types.FormatPatch{}
369 }
370
371 return patches
372}
373
374// empty if invalid, not otherwise
375func (s PullSubmission) ChangeId() string {
376 patches := s.AsFormatPatch()
377 if len(patches) != 1 {
378 return ""
379 }
380
381 c, err := patches[0].ChangeId()
382 if err != nil {
383 return ""
384 }
385
386 return c
387}
388
389func (s *PullSubmission) Participants() []string {
390 participantSet := make(map[string]struct{})
391 participants := []string{}
392
393 addParticipant := func(did string) {
394 if _, exists := participantSet[did]; !exists {
395 participantSet[did] = struct{}{}
396 participants = append(participants, did)
397 }
398 }
399
400 addParticipant(s.PullAt.Authority().String())
401
402 for _, c := range s.Comments {
403 addParticipant(c.Did.String())
404 }
405
406 return participants
407}
408
409func (s PullSubmission) CombinedPatch() string {
410 if s.Combined == "" {
411 return s.Patch
412 }
413
414 return s.Combined
415}
416
417func (s *PullSubmission) GetBlob() *lexutil.LexBlob {
418 if !s.Blob.Ref.Defined() {
419 return nil
420 }
421
422 return &s.Blob
423}
424
425func (s *PullSubmission) AsRecord() *tangled.RepoPull_Round {
426 return &tangled.RepoPull_Round{
427 CreatedAt: s.Created.Format(time.RFC3339),
428 PatchBlob: s.GetBlob(),
429 }
430}
431
432type Stack []*Pull
433
434// position of this pull in the stack
435func (stack Stack) Position(pull *Pull) int {
436 return slices.IndexFunc(stack, func(p *Pull) bool {
437 return p.AtUri() == pull.AtUri()
438 })
439}
440
441// all pulls below this pull (including self) in this stack
442//
443// nil if this pull does not belong to this stack
444func (stack Stack) Below(pull *Pull) Stack {
445 position := stack.Position(pull)
446
447 if position < 0 {
448 return nil
449 }
450
451 return stack[position:]
452}
453
454// all pulls below this pull (excluding self) in this stack
455func (stack Stack) StrictlyBelow(pull *Pull) Stack {
456 below := stack.Below(pull)
457
458 if len(below) > 0 {
459 return below[1:]
460 }
461
462 return nil
463}
464
465// all pulls above this pull (including self) in this stack
466func (stack Stack) Above(pull *Pull) Stack {
467 position := stack.Position(pull)
468
469 if position < 0 {
470 return nil
471 }
472
473 return stack[:position+1]
474}
475
476// all pulls below this pull (excluding self) in this stack
477func (stack Stack) StrictlyAbove(pull *Pull) Stack {
478 above := stack.Above(pull)
479
480 if len(above) > 0 {
481 return above[:len(above)-1]
482 }
483
484 return nil
485}
486
487// the combined format-patches of all the newest submissions in this stack
488func (stack Stack) CombinedPatch() string {
489 // go in reverse order because the bottom of the stack is the last element in the slice
490 var combined strings.Builder
491 for idx := range stack {
492 pull := stack[len(stack)-1-idx]
493 combined.WriteString(pull.LatestPatch())
494 combined.WriteString("\n")
495 }
496 return combined.String()
497}
498
499// filter out PRs that are "active"
500//
501// PRs that are still open are active
502func (stack Stack) Mergeable() Stack {
503 var mergeable Stack
504
505 for _, p := range stack {
506 // stop at the first merged PR
507 if p.State == PullMerged || p.State == PullClosed {
508 break
509 }
510
511 // skip over abandoned PRs
512 if p.State != PullAbandoned {
513 mergeable = append(mergeable, p)
514 }
515 }
516
517 return mergeable
518}
519
520type BranchDeleteStatus struct {
521 Repo *Repo
522 Branch string
523}
524
525func extractGzip(blob io.Reader) (string, error) {
526 var b bytes.Buffer
527 r, err := gzip.NewReader(blob)
528 if err != nil {
529 return "", err
530 }
531 defer r.Close()
532
533 const maxSize = 15 * 1024 * 1024
534 limitedReader := io.LimitReader(r, maxSize)
535
536 _, err = io.Copy(&b, limitedReader)
537 if err != nil {
538 return "", err
539 }
540
541 return b.String(), nil
542}