This repository has no description
0

Configure Feed

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

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