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