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 577 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/appview/pages/markup/sanitizer" 15 "tangled.org/core/patchutil" 16 "tangled.org/core/types" 17 18 "github.com/bluesky-social/indigo/atproto/syntax" 19 lexutil "github.com/bluesky-social/indigo/lex/util" 20) 21 22type PullState int 23 24const ( 25 PullClosed PullState = iota 26 PullOpen 27 PullMerged 28 PullAbandoned 29) 30 31func (p PullState) String() string { 32 switch p { 33 case PullOpen: 34 return "open" 35 case PullMerged: 36 return "merged" 37 case PullClosed: 38 return "closed" 39 case PullAbandoned: 40 return "abandoned" 41 default: 42 return "closed" 43 } 44} 45 46func (p PullState) IsOpen() bool { 47 return p == PullOpen 48} 49func (p PullState) IsMerged() bool { 50 return p == PullMerged 51} 52func (p PullState) IsClosed() bool { 53 return p == PullClosed 54} 55func (p PullState) IsAbandoned() bool { 56 return p == PullAbandoned 57} 58 59type Pull struct { 60 // ids 61 ID int 62 PullId int 63 64 // at ids 65 RepoDid syntax.DID 66 OwnerDid string 67 Rkey string 68 69 // content 70 Title string 71 Body string 72 TargetBranch string 73 State PullState 74 Submissions []*PullSubmission 75 Mentions []syntax.DID 76 References []syntax.ATURI 77 78 // stacking 79 DependentOn *syntax.ATURI 80 81 // meta 82 Created time.Time 83 PullSource *PullSource 84 85 // optionally, populate this when querying for reverse mappings 86 Labels LabelState 87 Repo *Repo 88} 89 90// NOTE: This method does not include patch blob in returned atproto record 91func (p Pull) AsRecord() tangled.RepoPull { 92 mentions := make([]string, len(p.Mentions)) 93 for i, did := range p.Mentions { 94 mentions[i] = string(did) 95 } 96 references := make([]string, len(p.References)) 97 for i, uri := range p.References { 98 references[i] = string(uri) 99 } 100 101 rounds := make([]*tangled.RepoPull_Round, len(p.Submissions)) 102 for i, submission := range p.Submissions { 103 rounds[i] = submission.AsRecord() 104 } 105 106 var dependentOn *string 107 if p.DependentOn != nil { 108 x := p.DependentOn.String() 109 dependentOn = &x 110 } 111 112 return tangled.RepoPull{ 113 Title: p.Title, 114 Body: &p.Body, 115 Mentions: mentions, 116 References: references, 117 CreatedAt: p.Created.Format(time.RFC3339), 118 Target: &tangled.RepoPull_Target{ 119 Repo: string(p.RepoDid), 120 Branch: p.TargetBranch, 121 }, 122 Rounds: rounds, 123 Source: p.PullSource.AsRecord(), 124 DependentOn: dependentOn, 125 } 126} 127 128func (pull *Pull) Validate() error { 129 if len(pull.Submissions) == 0 { 130 return fmt.Errorf("pull must have at least one submission") 131 } 132 133 latestSubmission := pull.LatestSubmission() 134 if latestSubmission == nil { 135 return fmt.Errorf("pull must have a valid latest submission") 136 } 137 138 isFormatPatch := patchutil.IsFormatPatch(latestSubmission.Patch) 139 140 // title and body can only be empty if the patch is a format-patch 141 if !isFormatPatch { 142 if pull.Title == "" { 143 return fmt.Errorf("pull title is empty (required for non-format-patch pulls)") 144 } 145 146 if pull.Body == "" { 147 return fmt.Errorf("pull body is empty (required for non-format-patch pulls)") 148 } 149 150 if st := strings.TrimSpace(sanitizer.SanitizeDescription(pull.Title)); st == "" { 151 return fmt.Errorf("title is empty after HTML sanitization") 152 } 153 154 if sb := strings.TrimSpace(sanitizer.SanitizeDefault(pull.Body)); sb == "" { 155 return fmt.Errorf("body is empty after HTML sanitization") 156 } 157 } 158 return nil 159} 160 161func PullFromRecord(did, rkey string, record tangled.RepoPull, blobs []io.Reader) (*Pull, error) { 162 created, err := time.Parse(time.RFC3339, record.CreatedAt) 163 if err != nil { 164 return nil, fmt.Errorf("invalid createdAt: %w", err) 165 } 166 167 body := "" 168 if record.Body != nil { 169 body = *record.Body 170 } 171 172 var mentions []syntax.DID 173 for _, m := range record.Mentions { 174 if did, err := syntax.ParseDID(m); err == nil { 175 mentions = append(mentions, did) 176 } 177 } 178 179 var targetRepoDid syntax.DID 180 var targetBranch string 181 if record.Target != nil { 182 did, err := syntax.ParseDID(record.Target.Repo) 183 if err != nil { 184 return nil, fmt.Errorf("invalid target.repo did: %w", err) 185 } 186 targetRepoDid = did 187 targetBranch = record.Target.Branch 188 } 189 190 var pullSource *PullSource 191 if record.Source != nil { 192 pullSource = &PullSource{ 193 Branch: record.Source.Branch, 194 } 195 196 if record.Source.Repo != nil { 197 did, err := syntax.ParseDID(*record.Source.Repo) 198 if err != nil { 199 return nil, fmt.Errorf("invalid source.repo did: %w", err) 200 } 201 pullSource.RepoDid = &did 202 } 203 } 204 205 var dependentOn *syntax.ATURI 206 if record.DependentOn != nil { 207 uri, err := syntax.ParseATURI(*record.DependentOn) 208 if err != nil { 209 return nil, fmt.Errorf("invalid dependentOn aturi: %w", err) 210 } 211 dependentOn = &uri 212 } 213 214 var submissions []*PullSubmission 215 for i, s := range record.Rounds { 216 var blob io.Reader 217 if i < len(blobs) { 218 blob = blobs[i] 219 } 220 submission, err := PullSubmissionFromRecord(did, rkey, i, s, blob) 221 if err != nil { 222 return nil, fmt.Errorf("invalid pull round at index %d: %w", i, err) 223 } 224 submissions = append(submissions, submission) 225 } 226 227 return &Pull{ 228 RepoDid: targetRepoDid, 229 OwnerDid: did, 230 Rkey: rkey, 231 Title: record.Title, 232 Body: body, 233 TargetBranch: targetBranch, 234 PullSource: pullSource, 235 State: PullOpen, 236 Submissions: submissions, 237 Created: created, 238 DependentOn: dependentOn, 239 }, nil 240} 241 242func PullSubmissionFromRecord(did, rkey string, roundNumber int, round *tangled.RepoPull_Round, blob io.Reader) (*PullSubmission, error) { 243 created, err := time.Parse(time.RFC3339, round.CreatedAt) 244 if err != nil { 245 return nil, fmt.Errorf("invalid createdAt: %w", err) 246 } 247 248 var patch, sourceRev string 249 if blob != nil { 250 p, err := extractGzip(blob) 251 if err != nil { 252 return nil, fmt.Errorf("failed to extract gzip: %w", err) 253 } 254 patch = p 255 if patchutil.IsFormatPatch(p) { 256 patches, err := patchutil.ExtractPatches(p) 257 if err != nil { 258 return nil, fmt.Errorf("failed to extract patches: %w", err) 259 } 260 261 for _, part := range patches { 262 sourceRev = part.SHA 263 } 264 } 265 } 266 267 return &PullSubmission{ 268 PullAt: syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", did, tangled.RepoPullNSID, rkey)), 269 RoundNumber: roundNumber, 270 Blob: *round.PatchBlob, 271 Created: created, 272 Patch: patch, 273 SourceRev: sourceRev, 274 }, nil 275} 276 277type PullSource struct { 278 Branch string 279 RepoDid *syntax.DID 280 281 // optionally populate this for reverse mappings 282 Repo *Repo 283} 284 285func (s *PullSource) AsRecord() *tangled.RepoPull_Source { 286 if s == nil { 287 return nil 288 } 289 var repo *string 290 if s.RepoDid != nil { 291 r := s.RepoDid.String() 292 repo = &r 293 } 294 return &tangled.RepoPull_Source{ 295 Branch: s.Branch, 296 Repo: repo, 297 } 298} 299 300type PullSubmission struct { 301 // ids 302 ID int 303 304 // at ids 305 PullAt syntax.ATURI 306 307 // content 308 RoundNumber int 309 Blob lexutil.LexBlob 310 Patch string 311 Combined string 312 Comments []Comment 313 SourceRev string // include the rev that was used to create this submission: only for branch/fork PRs 314 MergeBase string // merge-base of source and target at submission time 315 316 // meta 317 Created time.Time 318} 319 320func (p *Pull) TotalComments() int { 321 total := 0 322 for _, s := range p.Submissions { 323 total += len(s.Comments) 324 } 325 return total 326} 327 328func (p *Pull) LastRoundNumber() int { 329 return len(p.Submissions) - 1 330} 331 332func (p *Pull) LatestSubmission() *PullSubmission { 333 return p.Submissions[p.LastRoundNumber()] 334} 335 336func (p *Pull) LatestPatch() string { 337 return p.LatestSubmission().Patch 338} 339 340func (p *Pull) LatestSha() string { 341 return p.LatestSubmission().SourceRev 342} 343 344func (p *Pull) AtUri() syntax.ATURI { 345 return syntax.ATURI(fmt.Sprintf("at://%s/%s/%s", p.OwnerDid, tangled.RepoPullNSID, p.Rkey)) 346} 347 348func (p *Pull) IsPatchBased() bool { 349 return p.PullSource == nil 350} 351 352func (p *Pull) IsBranchBased() bool { 353 if p.PullSource != nil { 354 if p.PullSource.RepoDid != nil { 355 return *p.PullSource.RepoDid == p.RepoDid 356 } 357 // no repo specified 358 return true 359 } 360 return false 361} 362 363func (p *Pull) IsForkBased() bool { 364 if p.PullSource != nil { 365 if p.PullSource.RepoDid != nil { 366 // make sure repos are different 367 return *p.PullSource.RepoDid != p.RepoDid 368 } 369 } 370 return false 371} 372 373func (p *Pull) Participants() []syntax.DID { 374 participantSet := make(map[syntax.DID]struct{}) 375 participants := []syntax.DID{} 376 377 addParticipant := func(did syntax.DID) { 378 if _, exists := participantSet[did]; !exists { 379 participantSet[did] = struct{}{} 380 participants = append(participants, did) 381 } 382 } 383 384 addParticipant(syntax.DID(p.OwnerDid)) 385 386 for _, s := range p.Submissions { 387 for _, sp := range s.Participants() { 388 addParticipant(syntax.DID(sp)) 389 } 390 } 391 392 return participants 393} 394 395func (s PullSubmission) IsFormatPatch() bool { 396 return patchutil.IsFormatPatch(s.Patch) 397} 398 399func (s PullSubmission) AsFormatPatch() []types.FormatPatch { 400 patches, err := patchutil.ExtractPatches(s.Patch) 401 if err != nil { 402 log.Println("error extracting patches from submission:", err) 403 return []types.FormatPatch{} 404 } 405 406 return patches 407} 408 409// empty if invalid, not otherwise 410func (s PullSubmission) ChangeId() string { 411 patches := s.AsFormatPatch() 412 if len(patches) != 1 { 413 return "" 414 } 415 416 c, err := patches[0].ChangeId() 417 if err != nil { 418 return "" 419 } 420 421 return c 422} 423 424func (s *PullSubmission) Participants() []string { 425 participantSet := make(map[string]struct{}) 426 participants := []string{} 427 428 addParticipant := func(did string) { 429 if _, exists := participantSet[did]; !exists { 430 participantSet[did] = struct{}{} 431 participants = append(participants, did) 432 } 433 } 434 435 addParticipant(s.PullAt.Authority().String()) 436 437 for _, c := range s.Comments { 438 addParticipant(c.Did.String()) 439 } 440 441 return participants 442} 443 444func (s PullSubmission) CombinedPatch() string { 445 if s.Combined == "" { 446 return s.Patch 447 } 448 449 return s.Combined 450} 451 452func (s *PullSubmission) GetBlob() *lexutil.LexBlob { 453 if !s.Blob.Ref.Defined() { 454 return nil 455 } 456 457 return &s.Blob 458} 459 460func (s *PullSubmission) AsRecord() *tangled.RepoPull_Round { 461 return &tangled.RepoPull_Round{ 462 CreatedAt: s.Created.Format(time.RFC3339), 463 PatchBlob: s.GetBlob(), 464 } 465} 466 467type Stack []*Pull 468 469// position of this pull in the stack 470func (stack Stack) Position(pull *Pull) int { 471 return slices.IndexFunc(stack, func(p *Pull) bool { 472 return p.AtUri() == pull.AtUri() 473 }) 474} 475 476// all pulls below this pull (including self) in this stack 477// 478// nil if this pull does not belong to this stack 479func (stack Stack) Below(pull *Pull) Stack { 480 position := stack.Position(pull) 481 482 if position < 0 { 483 return nil 484 } 485 486 return stack[position:] 487} 488 489// all pulls below this pull (excluding self) in this stack 490func (stack Stack) StrictlyBelow(pull *Pull) Stack { 491 below := stack.Below(pull) 492 493 if len(below) > 0 { 494 return below[1:] 495 } 496 497 return nil 498} 499 500// all pulls above this pull (including self) in this stack 501func (stack Stack) Above(pull *Pull) Stack { 502 position := stack.Position(pull) 503 504 if position < 0 { 505 return nil 506 } 507 508 return stack[:position+1] 509} 510 511// all pulls below this pull (excluding self) in this stack 512func (stack Stack) StrictlyAbove(pull *Pull) Stack { 513 above := stack.Above(pull) 514 515 if len(above) > 0 { 516 return above[:len(above)-1] 517 } 518 519 return nil 520} 521 522// the combined format-patches of all the newest submissions in this stack 523func (stack Stack) CombinedPatch() string { 524 // go in reverse order because the bottom of the stack is the last element in the slice 525 var combined strings.Builder 526 for idx := range stack { 527 pull := stack[len(stack)-1-idx] 528 combined.WriteString(pull.LatestPatch()) 529 combined.WriteString("\n") 530 } 531 return combined.String() 532} 533 534// filter out PRs that are "active" 535// 536// PRs that are still open are active 537func (stack Stack) Mergeable() Stack { 538 var mergeable Stack 539 540 for _, p := range stack { 541 // stop at the first merged PR 542 if p.State == PullMerged || p.State == PullClosed { 543 break 544 } 545 546 // skip over abandoned PRs 547 if p.State != PullAbandoned { 548 mergeable = append(mergeable, p) 549 } 550 } 551 552 return mergeable 553} 554 555type BranchDeleteStatus struct { 556 Repo *Repo 557 Branch string 558} 559 560func extractGzip(blob io.Reader) (string, error) { 561 var b bytes.Buffer 562 r, err := gzip.NewReader(blob) 563 if err != nil { 564 return "", err 565 } 566 defer r.Close() 567 568 const maxSize = 15 * 1024 * 1024 569 limitedReader := io.LimitReader(r, maxSize) 570 571 _, err = io.Copy(&b, limitedReader) 572 if err != nil { 573 return "", err 574 } 575 576 return b.String(), nil 577}