Monorepo for Tangled tangled.org
6

Configure Feed

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

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