Monorepo for Tangled tangled.org
at sl/pdsmigration 542 lines 11 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 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}