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