Monorepo for Tangled
0
fork

Configure Feed

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

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