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