Monorepo for Tangled
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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