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