A vibe coded tangled fork which supports pijul.
at op/zllonksruqxw 2572 lines 74 kB view raw
1package pulls 2 3import ( 4 "bytes" 5 "compress/gzip" 6 "context" 7 "database/sql" 8 "encoding/json" 9 "errors" 10 "fmt" 11 "io" 12 "log" 13 "log/slog" 14 "net/http" 15 "slices" 16 "sort" 17 "strconv" 18 "strings" 19 "time" 20 21 "tangled.org/core/api/tangled" 22 "tangled.org/core/appview/config" 23 "tangled.org/core/appview/db" 24 pulls_indexer "tangled.org/core/appview/indexer/pulls" 25 "tangled.org/core/appview/mentions" 26 "tangled.org/core/appview/models" 27 "tangled.org/core/appview/notify" 28 "tangled.org/core/appview/oauth" 29 "tangled.org/core/appview/pages" 30 "tangled.org/core/appview/pages/markup" 31 "tangled.org/core/appview/pages/repoinfo" 32 "tangled.org/core/appview/pagination" 33 "tangled.org/core/appview/reporesolver" 34 "tangled.org/core/appview/searchquery" 35 "tangled.org/core/appview/validator" 36 "tangled.org/core/appview/xrpcclient" 37 "tangled.org/core/idresolver" 38 "tangled.org/core/orm" 39 "tangled.org/core/patchutil" 40 "tangled.org/core/rbac" 41 "tangled.org/core/tid" 42 "tangled.org/core/types" 43 "tangled.org/core/xrpc" 44 45 comatproto "github.com/bluesky-social/indigo/api/atproto" 46 "github.com/bluesky-social/indigo/atproto/syntax" 47 lexutil "github.com/bluesky-social/indigo/lex/util" 48 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 49 "github.com/go-chi/chi/v5" 50) 51 52const ApplicationGzip = "application/gzip" 53 54type Pulls struct { 55 oauth *oauth.OAuth 56 repoResolver *reporesolver.RepoResolver 57 pages *pages.Pages 58 idResolver *idresolver.Resolver 59 mentionsResolver *mentions.Resolver 60 db *db.DB 61 config *config.Config 62 notifier notify.Notifier 63 enforcer *rbac.Enforcer 64 logger *slog.Logger 65 validator *validator.Validator 66 indexer *pulls_indexer.Indexer 67} 68 69func New( 70 oauth *oauth.OAuth, 71 repoResolver *reporesolver.RepoResolver, 72 pages *pages.Pages, 73 resolver *idresolver.Resolver, 74 mentionsResolver *mentions.Resolver, 75 db *db.DB, 76 config *config.Config, 77 notifier notify.Notifier, 78 enforcer *rbac.Enforcer, 79 validator *validator.Validator, 80 indexer *pulls_indexer.Indexer, 81 logger *slog.Logger, 82) *Pulls { 83 return &Pulls{ 84 oauth: oauth, 85 repoResolver: repoResolver, 86 pages: pages, 87 idResolver: resolver, 88 mentionsResolver: mentionsResolver, 89 db: db, 90 config: config, 91 notifier: notifier, 92 enforcer: enforcer, 93 logger: logger, 94 validator: validator, 95 indexer: indexer, 96 } 97} 98 99// htmx fragment 100func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) { 101 switch r.Method { 102 case http.MethodGet: 103 user := s.oauth.GetMultiAccountUser(r) 104 f, err := s.repoResolver.Resolve(r) 105 if err != nil { 106 log.Println("failed to get repo and knot", err) 107 return 108 } 109 110 pull, ok := r.Context().Value("pull").(*models.Pull) 111 if !ok { 112 log.Println("failed to get pull") 113 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 114 return 115 } 116 117 // can be nil if this pull is not stacked 118 stack, _ := r.Context().Value("stack").(models.Stack) 119 120 roundNumberStr := chi.URLParam(r, "round") 121 roundNumber, err := strconv.Atoi(roundNumberStr) 122 if err != nil { 123 roundNumber = pull.LastRoundNumber() 124 } 125 if roundNumber >= len(pull.Submissions) { 126 http.Error(w, "bad round id", http.StatusBadRequest) 127 log.Println("failed to parse round id", err) 128 return 129 } 130 131 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 132 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 133 resubmitResult := pages.Unknown 134 if user.Active.Did == pull.OwnerDid { 135 resubmitResult = s.resubmitCheck(r, f, pull, stack) 136 } 137 138 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 139 LoggedInUser: user, 140 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 141 Pull: pull, 142 RoundNumber: roundNumber, 143 MergeCheck: mergeCheckResponse, 144 ResubmitCheck: resubmitResult, 145 BranchDeleteStatus: branchDeleteStatus, 146 Stack: stack, 147 }) 148 return 149 } 150} 151 152func (s *Pulls) repoPullHelper(w http.ResponseWriter, r *http.Request, interdiff bool) { 153 user := s.oauth.GetMultiAccountUser(r) 154 f, err := s.repoResolver.Resolve(r) 155 if err != nil { 156 log.Println("failed to get repo and knot", err) 157 return 158 } 159 160 pull, ok := r.Context().Value("pull").(*models.Pull) 161 if !ok { 162 log.Println("failed to get pull") 163 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 164 return 165 } 166 167 backlinks, err := db.GetBacklinks(s.db, pull.AtUri()) 168 if err != nil { 169 log.Println("failed to get pull backlinks", err) 170 s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.") 171 return 172 } 173 174 roundId := chi.URLParam(r, "round") 175 roundIdInt := pull.LastRoundNumber() 176 if r, err := strconv.Atoi(roundId); err == nil { 177 roundIdInt = r 178 } 179 if roundIdInt >= len(pull.Submissions) { 180 http.Error(w, "bad round id", http.StatusBadRequest) 181 log.Println("failed to parse round id", err) 182 return 183 } 184 185 var diffOpts types.DiffOpts 186 if d := r.URL.Query().Get("diff"); d == "split" { 187 diffOpts.Split = true 188 } 189 190 // can be nil if this pull is not stacked 191 stack, _ := r.Context().Value("stack").(models.Stack) 192 193 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 194 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 195 resubmitResult := pages.Unknown 196 if user != nil && user.Active != nil && user.Active.Did == pull.OwnerDid { 197 resubmitResult = s.resubmitCheck(r, f, pull, stack) 198 } 199 200 m := make(map[string]models.Pipeline) 201 202 var shas []string 203 for _, s := range pull.Submissions { 204 shas = append(shas, s.SourceRev) 205 } 206 for _, p := range stack { 207 shas = append(shas, p.LatestSha()) 208 } 209 210 ps, err := db.GetPipelineStatuses( 211 s.db, 212 len(shas), 213 orm.FilterEq("p.repo_owner", f.Did), 214 orm.FilterEq("p.repo_name", f.Name), 215 orm.FilterEq("p.knot", f.Knot), 216 orm.FilterIn("p.sha", shas), 217 ) 218 if err != nil { 219 log.Printf("failed to fetch pipeline statuses: %s", err) 220 // non-fatal 221 } 222 223 for _, p := range ps { 224 m[p.Sha] = p 225 } 226 227 reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri()) 228 if err != nil { 229 log.Println("failed to get pull reactions") 230 } 231 232 userReactions := map[models.ReactionKind]bool{} 233 if user != nil { 234 userReactions = db.GetReactionStatusMap(s.db, user.Active.Did, pull.AtUri()) 235 } 236 237 labelDefs, err := db.GetLabelDefinitions( 238 s.db, 239 orm.FilterIn("at_uri", f.Labels), 240 orm.FilterContains("scope", tangled.RepoPullNSID), 241 ) 242 if err != nil { 243 log.Println("failed to fetch labels", err) 244 s.pages.Error503(w) 245 return 246 } 247 248 defs := make(map[string]*models.LabelDefinition) 249 for _, l := range labelDefs { 250 defs[l.AtUri().String()] = &l 251 } 252 253 patch := pull.Submissions[roundIdInt].CombinedPatch() 254 var diff types.DiffRenderer 255 diff = patchutil.AsNiceDiff(patch, pull.TargetBranch) 256 257 if interdiff { 258 currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 259 if err != nil { 260 log.Println("failed to interdiff; current patch malformed") 261 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 262 return 263 } 264 265 previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 266 if err != nil { 267 log.Println("failed to interdiff; previous patch malformed") 268 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 269 return 270 } 271 272 diff = patchutil.Interdiff(previousPatch, currentPatch) 273 } 274 275 fmt.Println(s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 276 LoggedInUser: user, 277 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 278 Pull: pull, 279 Stack: stack, 280 Backlinks: backlinks, 281 BranchDeleteStatus: branchDeleteStatus, 282 MergeCheck: mergeCheckResponse, 283 ResubmitCheck: resubmitResult, 284 Pipelines: m, 285 Diff: diff, 286 DiffOpts: diffOpts, 287 ActiveRound: roundIdInt, 288 IsInterdiff: interdiff, 289 290 Reactions: reactionMap, 291 UserReacted: userReactions, 292 293 LabelDefs: defs, 294 })) 295} 296 297func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 298 pull, ok := r.Context().Value("pull").(*models.Pull) 299 if !ok { 300 log.Println("failed to get pull") 301 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 302 return 303 } 304 305 http.Redirect(w, r, r.URL.String()+fmt.Sprintf("/round/%d", pull.LastRoundNumber()), http.StatusFound) 306} 307 308func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 309 if pull.State == models.PullMerged { 310 return types.MergeCheckResponse{} 311 } 312 313 scheme := "https" 314 if s.config.Core.Dev { 315 scheme = "http" 316 } 317 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 318 319 xrpcc := indigoxrpc.Client{ 320 Host: host, 321 } 322 323 // combine patches of substack 324 subStack := stack.Below(pull) 325 // collect the portion of the stack that is mergeable 326 mergeable := subStack.Mergeable() 327 // combine each patch 328 patch := mergeable.CombinedPatch() 329 330 resp, xe := tangled.RepoMergeCheck( 331 r.Context(), 332 &xrpcc, 333 &tangled.RepoMergeCheck_Input{ 334 Did: f.Did, 335 Name: f.Name, 336 Branch: pull.TargetBranch, 337 Patch: patch, 338 }, 339 ) 340 if err := xrpcclient.HandleXrpcErr(xe); err != nil { 341 log.Println("failed to check for mergeability", "err", err) 342 return types.MergeCheckResponse{ 343 Error: fmt.Sprintf("failed to check merge status: %s", err.Error()), 344 } 345 } 346 347 // convert xrpc response to internal types 348 conflicts := make([]types.ConflictInfo, len(resp.Conflicts)) 349 for i, conflict := range resp.Conflicts { 350 conflicts[i] = types.ConflictInfo{ 351 Filename: conflict.Filename, 352 Reason: conflict.Reason, 353 } 354 } 355 356 result := types.MergeCheckResponse{ 357 IsConflicted: resp.Is_conflicted, 358 Conflicts: conflicts, 359 } 360 361 if resp.Message != nil { 362 result.Message = *resp.Message 363 } 364 365 if resp.Error != nil { 366 result.Error = *resp.Error 367 } 368 369 return result 370} 371 372func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus { 373 if pull.State != models.PullMerged { 374 return nil 375 } 376 377 user := s.oauth.GetMultiAccountUser(r) 378 if user == nil { 379 return nil 380 } 381 382 var branch string 383 // check if the branch exists 384 // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 385 if pull.IsBranchBased() { 386 branch = pull.PullSource.Branch 387 } else if pull.IsForkBased() { 388 branch = pull.PullSource.Branch 389 repo = pull.PullSource.Repo 390 } else { 391 return nil 392 } 393 394 // deleted fork 395 if repo == nil { 396 return nil 397 } 398 399 // user can only delete branch if they are a collaborator in the repo that the branch belongs to 400 perms := s.enforcer.GetPermissionsInRepo(user.Active.Did, repo.Knot, repo.DidSlashRepo()) 401 if !slices.Contains(perms, "repo:push") { 402 return nil 403 } 404 405 scheme := "http" 406 if !s.config.Core.Dev { 407 scheme = "https" 408 } 409 host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 410 xrpcc := &indigoxrpc.Client{ 411 Host: host, 412 } 413 414 resp, err := tangled.RepoBranch(r.Context(), xrpcc, branch, fmt.Sprintf("%s/%s", repo.Did, repo.Name)) 415 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 416 return nil 417 } 418 419 return &models.BranchDeleteStatus{ 420 Repo: repo, 421 Branch: resp.Name, 422 } 423} 424 425func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 426 if pull.State == models.PullMerged || pull.State == models.PullAbandoned || pull.PullSource == nil { 427 return pages.Unknown 428 } 429 430 var knot, ownerDid, repoName string 431 432 if pull.PullSource.RepoAt != nil { 433 // fork-based pulls 434 sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 435 if err != nil { 436 log.Println("failed to get source repo", err) 437 return pages.Unknown 438 } 439 440 knot = sourceRepo.Knot 441 ownerDid = sourceRepo.Did 442 repoName = sourceRepo.Name 443 } else { 444 // pulls within the same repo 445 knot = repo.Knot 446 ownerDid = repo.Did 447 repoName = repo.Name 448 } 449 450 scheme := "http" 451 if !s.config.Core.Dev { 452 scheme = "https" 453 } 454 host := fmt.Sprintf("%s://%s", scheme, knot) 455 xrpcc := &indigoxrpc.Client{ 456 Host: host, 457 } 458 459 didSlashName := fmt.Sprintf("%s/%s", ownerDid, repoName) 460 branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, didSlashName) 461 if err != nil { 462 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 463 log.Println("failed to call XRPC repo.branches", xrpcerr) 464 return pages.Unknown 465 } 466 log.Println("failed to reach knotserver", err) 467 return pages.Unknown 468 } 469 470 targetBranch := branchResp 471 472 top := stack[0] 473 latestSourceRev := top.LatestSha() 474 475 if latestSourceRev != targetBranch.Hash { 476 return pages.ShouldResubmit 477 } 478 479 return pages.ShouldNotResubmit 480} 481 482func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 483 s.repoPullHelper(w, r, false) 484} 485 486func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 487 s.repoPullHelper(w, r, true) 488} 489 490func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 491 pull, ok := r.Context().Value("pull").(*models.Pull) 492 if !ok { 493 log.Println("failed to get pull") 494 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 495 return 496 } 497 498 roundId := chi.URLParam(r, "round") 499 roundIdInt, err := strconv.Atoi(roundId) 500 if err != nil || roundIdInt >= len(pull.Submissions) { 501 http.Error(w, "bad round id", http.StatusBadRequest) 502 log.Println("failed to parse round id", err) 503 return 504 } 505 506 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 507 w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 508} 509 510func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 511 l := s.logger.With("handler", "RepoPulls") 512 513 user := s.oauth.GetMultiAccountUser(r) 514 params := r.URL.Query() 515 page := pagination.FromContext(r.Context()) 516 517 f, err := s.repoResolver.Resolve(r) 518 if err != nil { 519 log.Println("failed to get repo and knot", err) 520 return 521 } 522 523 query := searchquery.Parse(params.Get("q")) 524 525 var state *models.PullState 526 if urlState := params.Get("state"); urlState != "" { 527 switch urlState { 528 case "open": 529 state = ptrPullState(models.PullOpen) 530 case "closed": 531 state = ptrPullState(models.PullClosed) 532 case "merged": 533 state = ptrPullState(models.PullMerged) 534 } 535 query.Set("state", urlState) 536 } else if queryState := query.Get("state"); queryState != nil { 537 switch *queryState { 538 case "open": 539 state = ptrPullState(models.PullOpen) 540 case "closed": 541 state = ptrPullState(models.PullClosed) 542 case "merged": 543 state = ptrPullState(models.PullMerged) 544 } 545 } else if _, hasQ := params["q"]; !hasQ { 546 state = ptrPullState(models.PullOpen) 547 query.Set("state", "open") 548 } 549 550 resolve := func(ctx context.Context, ident string) (string, error) { 551 id, err := s.idResolver.ResolveIdent(ctx, ident) 552 if err != nil { 553 return "", err 554 } 555 return id.DID.String(), nil 556 } 557 558 authorDid, negatedAuthorDids := searchquery.ResolveAuthor(r.Context(), query, resolve, l) 559 560 labels := query.GetAll("label") 561 negatedLabels := query.GetAllNegated("label") 562 labelValues := query.GetDynamicTags() 563 negatedLabelValues := query.GetNegatedDynamicTags() 564 565 // resolve DID-format label values: if a dynamic tag's label 566 // definition has format "did", resolve the handle to a DID 567 if len(labelValues) > 0 || len(negatedLabelValues) > 0 { 568 labelDefs, err := db.GetLabelDefinitions( 569 s.db, 570 orm.FilterIn("at_uri", f.Labels), 571 orm.FilterContains("scope", tangled.RepoPullNSID), 572 ) 573 if err == nil { 574 didLabels := make(map[string]bool) 575 for _, def := range labelDefs { 576 if def.ValueType.Format == models.ValueTypeFormatDid { 577 didLabels[def.Name] = true 578 } 579 } 580 labelValues = searchquery.ResolveDIDLabelValues(r.Context(), labelValues, didLabels, resolve, l) 581 negatedLabelValues = searchquery.ResolveDIDLabelValues(r.Context(), negatedLabelValues, didLabels, resolve, l) 582 } else { 583 l.Debug("failed to fetch label definitions for DID resolution", "err", err) 584 } 585 } 586 587 tf := searchquery.ExtractTextFilters(query) 588 589 searchOpts := models.PullSearchOptions{ 590 Keywords: tf.Keywords, 591 Phrases: tf.Phrases, 592 RepoAt: f.RepoAt().String(), 593 State: state, 594 AuthorDid: authorDid, 595 Labels: labels, 596 LabelValues: labelValues, 597 NegatedKeywords: tf.NegatedKeywords, 598 NegatedPhrases: tf.NegatedPhrases, 599 NegatedLabels: negatedLabels, 600 NegatedLabelValues: negatedLabelValues, 601 NegatedAuthorDids: negatedAuthorDids, 602 Page: page, 603 } 604 605 var totalPulls int 606 if state == nil { 607 totalPulls = f.RepoStats.PullCount.Open + f.RepoStats.PullCount.Merged + f.RepoStats.PullCount.Closed 608 } else { 609 switch *state { 610 case models.PullOpen: 611 totalPulls = f.RepoStats.PullCount.Open 612 case models.PullMerged: 613 totalPulls = f.RepoStats.PullCount.Merged 614 case models.PullClosed: 615 totalPulls = f.RepoStats.PullCount.Closed 616 } 617 } 618 619 repoInfo := s.repoResolver.GetRepoInfo(r, user) 620 621 var pulls []*models.Pull 622 623 if searchOpts.HasSearchFilters() { 624 res, err := s.indexer.Search(r.Context(), searchOpts) 625 if err != nil { 626 l.Error("failed to search for pulls", "err", err) 627 return 628 } 629 totalPulls = int(res.Total) 630 l.Debug("searched pulls with indexer", "count", len(res.Hits)) 631 632 // update tab counts to reflect filtered results 633 countOpts := searchOpts 634 countOpts.Page = pagination.Page{Limit: 1} 635 for _, ps := range []models.PullState{models.PullOpen, models.PullMerged, models.PullClosed} { 636 countOpts.State = &ps 637 countRes, err := s.indexer.Search(r.Context(), countOpts) 638 if err != nil { 639 continue 640 } 641 switch ps { 642 case models.PullOpen: 643 repoInfo.Stats.PullCount.Open = int(countRes.Total) 644 case models.PullMerged: 645 repoInfo.Stats.PullCount.Merged = int(countRes.Total) 646 case models.PullClosed: 647 repoInfo.Stats.PullCount.Closed = int(countRes.Total) 648 } 649 } 650 651 if len(res.Hits) > 0 { 652 pulls, err = db.GetPulls( 653 s.db, 654 orm.FilterIn("id", res.Hits), 655 ) 656 if err != nil { 657 l.Error("failed to get pulls", "err", err) 658 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 659 return 660 } 661 } 662 } else { 663 filters := []orm.Filter{ 664 orm.FilterEq("repo_at", f.RepoAt()), 665 } 666 if state != nil { 667 filters = append(filters, orm.FilterEq("state", *state)) 668 } 669 pulls, err = db.GetPullsPaginated( 670 s.db, 671 page, 672 filters..., 673 ) 674 if err != nil { 675 l.Error("failed to get pulls", "err", err) 676 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 677 return 678 } 679 } 680 681 for _, p := range pulls { 682 var pullSourceRepo *models.Repo 683 if p.PullSource != nil { 684 if p.PullSource.RepoAt != nil { 685 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) 686 if err != nil { 687 log.Printf("failed to get repo by at uri: %v", err) 688 continue 689 } else { 690 p.PullSource.Repo = pullSourceRepo 691 } 692 } 693 } 694 } 695 696 // we want to group all stacked PRs into just one list 697 stacks := make(map[string]models.Stack) 698 var shas []string 699 // n := 0 700 for _, p := range pulls { 701 // store the sha for later 702 shas = append(shas, p.LatestSha()) 703 // this PR is stacked 704 // if p.StackId != "" { 705 // // we have already seen this PR stack 706 // if _, seen := stacks[p.StackId]; seen { 707 // stacks[p.StackId] = append(stacks[p.StackId], p) 708 // // skip this PR 709 // } else { 710 // stacks[p.StackId] = nil 711 // pulls[n] = p 712 // n++ 713 // } 714 // } else { 715 // pulls[n] = p 716 // n++ 717 // } 718 } 719 // pulls = pulls[:n] 720 721 ps, err := db.GetPipelineStatuses( 722 s.db, 723 len(shas), 724 orm.FilterEq("p.repo_owner", f.Did), 725 orm.FilterEq("p.repo_name", f.Name), 726 orm.FilterEq("p.knot", f.Knot), 727 orm.FilterIn("p.sha", shas), 728 ) 729 if err != nil { 730 log.Printf("failed to fetch pipeline statuses: %s", err) 731 // non-fatal 732 } 733 m := make(map[string]models.Pipeline) 734 for _, p := range ps { 735 m[p.Sha] = p 736 } 737 738 labelDefs, err := db.GetLabelDefinitions( 739 s.db, 740 orm.FilterIn("at_uri", f.Labels), 741 orm.FilterContains("scope", tangled.RepoPullNSID), 742 ) 743 if err != nil { 744 l.Error("failed to fetch labels", "err", err) 745 s.pages.Error503(w) 746 return 747 } 748 749 defs := make(map[string]*models.LabelDefinition) 750 for _, l := range labelDefs { 751 defs[l.AtUri().String()] = &l 752 } 753 754 filterState := "" 755 if state != nil { 756 filterState = state.String() 757 } 758 759 fmt.Println(s.pages.RepoPulls(w, pages.RepoPullsParams{ 760 LoggedInUser: s.oauth.GetMultiAccountUser(r), 761 RepoInfo: repoInfo, 762 Pulls: pulls, 763 LabelDefs: defs, 764 FilterState: filterState, 765 FilterQuery: query.String(), 766 Stacks: stacks, 767 Pipelines: m, 768 Page: page, 769 PullCount: totalPulls, 770 })) 771} 772 773func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 774 user := s.oauth.GetMultiAccountUser(r) 775 f, err := s.repoResolver.Resolve(r) 776 if err != nil { 777 log.Println("failed to get repo and knot", err) 778 return 779 } 780 781 pull, ok := r.Context().Value("pull").(*models.Pull) 782 if !ok { 783 log.Println("failed to get pull") 784 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 785 return 786 } 787 788 roundNumberStr := chi.URLParam(r, "round") 789 roundNumber, err := strconv.Atoi(roundNumberStr) 790 if err != nil || roundNumber >= len(pull.Submissions) { 791 http.Error(w, "bad round id", http.StatusBadRequest) 792 log.Println("failed to parse round id", err) 793 return 794 } 795 796 switch r.Method { 797 case http.MethodGet: 798 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ 799 LoggedInUser: user, 800 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 801 Pull: pull, 802 RoundNumber: roundNumber, 803 }) 804 return 805 case http.MethodPost: 806 body := r.FormValue("body") 807 if body == "" { 808 s.pages.Notice(w, "pull", "Comment body is required") 809 return 810 } 811 812 mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 813 814 // Start a transaction 815 tx, err := s.db.BeginTx(r.Context(), nil) 816 if err != nil { 817 log.Println("failed to start transaction", err) 818 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 819 return 820 } 821 defer tx.Rollback() 822 823 createdAt := time.Now().Format(time.RFC3339) 824 825 client, err := s.oauth.AuthorizedClient(r) 826 if err != nil { 827 log.Println("failed to get authorized client", err) 828 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 829 return 830 } 831 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 832 Collection: tangled.RepoPullCommentNSID, 833 Repo: user.Active.Did, 834 Rkey: tid.TID(), 835 Record: &lexutil.LexiconTypeDecoder{ 836 Val: &tangled.RepoPullComment{ 837 Pull: pull.AtUri().String(), 838 Body: body, 839 CreatedAt: createdAt, 840 }, 841 }, 842 }) 843 if err != nil { 844 log.Println("failed to create pull comment", err) 845 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 846 return 847 } 848 849 comment := &models.PullComment{ 850 OwnerDid: user.Active.Did, 851 RepoAt: f.RepoAt().String(), 852 PullId: pull.PullId, 853 Body: body, 854 CommentAt: atResp.Uri, 855 SubmissionId: pull.Submissions[roundNumber].ID, 856 Mentions: mentions, 857 References: references, 858 } 859 860 // Create the pull comment in the database with the commentAt field 861 commentId, err := db.NewPullComment(tx, comment) 862 if err != nil { 863 log.Println("failed to create pull comment", err) 864 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 865 return 866 } 867 868 // Commit the transaction 869 if err = tx.Commit(); err != nil { 870 log.Println("failed to commit transaction", err) 871 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 872 return 873 } 874 875 s.notifier.NewPullComment(r.Context(), comment, mentions) 876 877 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 878 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId)) 879 return 880 } 881} 882 883func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) { 884 user := s.oauth.GetMultiAccountUser(r) 885 f, err := s.repoResolver.Resolve(r) 886 if err != nil { 887 log.Println("failed to get repo and knot", err) 888 return 889 } 890 891 switch r.Method { 892 case http.MethodGet: 893 scheme := "http" 894 if !s.config.Core.Dev { 895 scheme = "https" 896 } 897 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 898 xrpcc := &indigoxrpc.Client{ 899 Host: host, 900 } 901 902 repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 903 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 904 if err != nil { 905 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 906 log.Println("failed to call XRPC repo.branches", xrpcerr) 907 s.pages.Error503(w) 908 return 909 } 910 log.Println("failed to fetch branches", err) 911 return 912 } 913 914 var result types.RepoBranchesResponse 915 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 916 log.Println("failed to decode XRPC response", err) 917 s.pages.Error503(w) 918 return 919 } 920 921 // can be one of "patch", "branch" or "fork" 922 strategy := r.URL.Query().Get("strategy") 923 // ignored if strategy is "patch" 924 sourceBranch := r.URL.Query().Get("sourceBranch") 925 targetBranch := r.URL.Query().Get("targetBranch") 926 927 s.pages.RepoNewPull(w, pages.RepoNewPullParams{ 928 LoggedInUser: user, 929 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 930 Branches: result.Branches, 931 Strategy: strategy, 932 SourceBranch: sourceBranch, 933 TargetBranch: targetBranch, 934 Title: r.URL.Query().Get("title"), 935 Body: r.URL.Query().Get("body"), 936 }) 937 938 case http.MethodPost: 939 title := r.FormValue("title") 940 body := r.FormValue("body") 941 targetBranch := r.FormValue("targetBranch") 942 fromFork := r.FormValue("fork") 943 sourceBranch := r.FormValue("sourceBranch") 944 patch := r.FormValue("patch") 945 946 if targetBranch == "" { 947 s.pages.Notice(w, "pull", "Target branch is required.") 948 return 949 } 950 951 // Determine PR type based on input parameters 952 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 953 isPushAllowed := roles.IsPushAllowed() 954 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 955 isForkBased := fromFork != "" && sourceBranch != "" 956 isPatchBased := patch != "" && !isBranchBased && !isForkBased 957 isStacked := r.FormValue("isStacked") == "on" 958 959 if isPatchBased && !patchutil.IsFormatPatch(patch) { 960 if title == "" { 961 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 962 return 963 } 964 sanitizer := markup.NewSanitizer() 965 if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 966 s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 967 return 968 } 969 } 970 971 // Validate we have at least one valid PR creation method 972 if !isBranchBased && !isPatchBased && !isForkBased { 973 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 974 return 975 } 976 977 // Can't mix branch-based and patch-based approaches 978 if isBranchBased && patch != "" { 979 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 980 return 981 } 982 983 // us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 984 // if err != nil { 985 // log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 986 // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 987 // return 988 // } 989 990 // TODO: make capabilities an xrpc call 991 caps := struct { 992 PullRequests struct { 993 FormatPatch bool 994 BranchSubmissions bool 995 ForkSubmissions bool 996 PatchSubmissions bool 997 } 998 }{ 999 PullRequests: struct { 1000 FormatPatch bool 1001 BranchSubmissions bool 1002 ForkSubmissions bool 1003 PatchSubmissions bool 1004 }{ 1005 FormatPatch: true, 1006 BranchSubmissions: true, 1007 ForkSubmissions: true, 1008 PatchSubmissions: true, 1009 }, 1010 } 1011 1012 // caps, err := us.Capabilities() 1013 // if err != nil { 1014 // log.Println("error fetching knot caps", f.Knot, err) 1015 // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 1016 // return 1017 // } 1018 1019 if !caps.PullRequests.FormatPatch { 1020 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") 1021 return 1022 } 1023 1024 // Handle the PR creation based on the type 1025 if isBranchBased { 1026 if !caps.PullRequests.BranchSubmissions { 1027 s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") 1028 return 1029 } 1030 s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch, isStacked) 1031 } else if isForkBased { 1032 if !caps.PullRequests.ForkSubmissions { 1033 s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") 1034 return 1035 } 1036 s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch, isStacked) 1037 } else if isPatchBased { 1038 if !caps.PullRequests.PatchSubmissions { 1039 s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") 1040 return 1041 } 1042 s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch, isStacked) 1043 } 1044 return 1045 } 1046} 1047 1048func (s *Pulls) handleBranchBasedPull( 1049 w http.ResponseWriter, 1050 r *http.Request, 1051 repo *models.Repo, 1052 user *oauth.MultiAccountUser, 1053 title, 1054 body, 1055 targetBranch, 1056 sourceBranch string, 1057 isStacked bool, 1058) { 1059 scheme := "http" 1060 if !s.config.Core.Dev { 1061 scheme = "https" 1062 } 1063 host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 1064 xrpcc := &indigoxrpc.Client{ 1065 Host: host, 1066 } 1067 1068 didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 1069 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, didSlashRepo, targetBranch, sourceBranch) 1070 if err != nil { 1071 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1072 log.Println("failed to call XRPC repo.compare", xrpcerr) 1073 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1074 return 1075 } 1076 log.Println("failed to compare", err) 1077 s.pages.Notice(w, "pull", err.Error()) 1078 return 1079 } 1080 1081 var comparison types.RepoFormatPatchResponse 1082 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 1083 log.Println("failed to decode XRPC compare response", err) 1084 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1085 return 1086 } 1087 1088 sourceRev := comparison.Rev2 1089 patch := comparison.FormatPatchRaw 1090 combined := comparison.CombinedPatchRaw 1091 1092 if err := s.validator.ValidatePatch(&patch); err != nil { 1093 s.logger.Error("failed to validate patch", "err", err) 1094 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1095 return 1096 } 1097 1098 pullSource := &models.PullSource{ 1099 Branch: sourceBranch, 1100 } 1101 recordPullSource := &tangled.RepoPull_Source{ 1102 Branch: sourceBranch, 1103 } 1104 1105 s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1106} 1107 1108func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.MultiAccountUser, title, body, targetBranch, patch string, isStacked bool) { 1109 if err := s.validator.ValidatePatch(&patch); err != nil { 1110 s.logger.Error("patch validation failed", "err", err) 1111 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1112 return 1113 } 1114 1115 s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 1116} 1117 1118func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.MultiAccountUser, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 1119 repoString := strings.SplitN(forkRepo, "/", 2) 1120 forkOwnerDid := repoString[0] 1121 repoName := repoString[1] 1122 fork, err := db.GetForkByDid(s.db, forkOwnerDid, repoName) 1123 if errors.Is(err, sql.ErrNoRows) { 1124 s.pages.Notice(w, "pull", "No such fork.") 1125 return 1126 } else if err != nil { 1127 log.Println("failed to fetch fork:", err) 1128 s.pages.Notice(w, "pull", "Failed to fetch fork.") 1129 return 1130 } 1131 1132 client, err := s.oauth.ServiceClient( 1133 r, 1134 oauth.WithService(fork.Knot), 1135 oauth.WithLxm(tangled.RepoHiddenRefNSID), 1136 oauth.WithDev(s.config.Core.Dev), 1137 ) 1138 1139 resp, err := tangled.RepoHiddenRef( 1140 r.Context(), 1141 client, 1142 &tangled.RepoHiddenRef_Input{ 1143 ForkRef: sourceBranch, 1144 RemoteRef: targetBranch, 1145 Repo: fork.RepoAt().String(), 1146 }, 1147 ) 1148 if err := xrpcclient.HandleXrpcErr(err); err != nil { 1149 s.pages.Notice(w, "pull", err.Error()) 1150 return 1151 } 1152 1153 if !resp.Success { 1154 errorMsg := "Failed to create pull request" 1155 if resp.Error != nil { 1156 errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error) 1157 } 1158 s.pages.Notice(w, "pull", errorMsg) 1159 return 1160 } 1161 1162 hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 1163 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking 1164 // the targetBranch on the target repository. This code is a bit confusing, but here's an example: 1165 // hiddenRef: hidden/feature-1/main (on repo-fork) 1166 // targetBranch: main (on repo-1) 1167 // sourceBranch: feature-1 (on repo-fork) 1168 forkScheme := "http" 1169 if !s.config.Core.Dev { 1170 forkScheme = "https" 1171 } 1172 forkHost := fmt.Sprintf("%s://%s", forkScheme, fork.Knot) 1173 forkXrpcc := &indigoxrpc.Client{ 1174 Host: forkHost, 1175 } 1176 1177 forkRepoId := fmt.Sprintf("%s/%s", fork.Did, fork.Name) 1178 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, forkRepoId, hiddenRef, sourceBranch) 1179 if err != nil { 1180 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1181 log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1182 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1183 return 1184 } 1185 log.Println("failed to compare across branches", err) 1186 s.pages.Notice(w, "pull", err.Error()) 1187 return 1188 } 1189 1190 var comparison types.RepoFormatPatchResponse 1191 if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 1192 log.Println("failed to decode XRPC compare response for fork", err) 1193 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1194 return 1195 } 1196 1197 sourceRev := comparison.Rev2 1198 patch := comparison.FormatPatchRaw 1199 combined := comparison.CombinedPatchRaw 1200 1201 if err := s.validator.ValidatePatch(&patch); err != nil { 1202 s.logger.Error("failed to validate patch", "err", err) 1203 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1204 return 1205 } 1206 1207 forkAtUri := fork.RepoAt() 1208 forkAtUriStr := forkAtUri.String() 1209 1210 pullSource := &models.PullSource{ 1211 Branch: sourceBranch, 1212 RepoAt: &forkAtUri, 1213 } 1214 recordPullSource := &tangled.RepoPull_Source{ 1215 Branch: sourceBranch, 1216 Repo: &forkAtUriStr, 1217 } 1218 1219 s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1220} 1221 1222func (s *Pulls) createPullRequest( 1223 w http.ResponseWriter, 1224 r *http.Request, 1225 repo *models.Repo, 1226 user *oauth.MultiAccountUser, 1227 title, body, targetBranch string, 1228 patch string, 1229 combined string, 1230 sourceRev string, 1231 pullSource *models.PullSource, 1232 recordPullSource *tangled.RepoPull_Source, 1233 isStacked bool, 1234) { 1235 if isStacked { 1236 // creates a series of PRs, each linking to the previous, identified by jj's change-id 1237 s.createStackedPullRequest( 1238 w, 1239 r, 1240 repo, 1241 user, 1242 targetBranch, 1243 patch, 1244 sourceRev, 1245 pullSource, 1246 ) 1247 return 1248 } 1249 1250 client, err := s.oauth.AuthorizedClient(r) 1251 if err != nil { 1252 log.Println("failed to get authorized client", err) 1253 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1254 return 1255 } 1256 1257 tx, err := s.db.BeginTx(r.Context(), nil) 1258 if err != nil { 1259 log.Println("failed to start tx") 1260 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1261 return 1262 } 1263 defer tx.Rollback() 1264 1265 // We've already checked earlier if it's diff-based and title is empty, 1266 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1267 if title == "" || body == "" { 1268 formatPatches, err := patchutil.ExtractPatches(patch) 1269 if err != nil { 1270 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 1271 return 1272 } 1273 if len(formatPatches) == 0 { 1274 s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.") 1275 return 1276 } 1277 1278 if title == "" { 1279 title = formatPatches[0].Title 1280 } 1281 if body == "" { 1282 body = formatPatches[0].Body 1283 } 1284 } 1285 1286 mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 1287 1288 rkey := tid.TID() 1289 1290 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 1291 if err != nil { 1292 log.Println("failed to upload patch", err) 1293 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1294 return 1295 } 1296 1297 record := tangled.RepoPull{ 1298 Title: title, 1299 Body: &body, 1300 Target: &tangled.RepoPull_Target{ 1301 Repo: string(repo.RepoAt()), 1302 Branch: targetBranch, 1303 }, 1304 Source: recordPullSource, 1305 CreatedAt: time.Now().Format(time.RFC3339), 1306 Rounds: []*tangled.RepoPull_Round{ 1307 { 1308 CreatedAt: time.Now().Format(time.RFC3339), 1309 PatchBlob: blob.Blob, 1310 }, 1311 }, 1312 } 1313 initialSubmission := models.PullSubmission{ 1314 Patch: patch, 1315 Combined: combined, 1316 SourceRev: sourceRev, 1317 Blob: *blob.Blob, 1318 } 1319 pull := &models.Pull{ 1320 Title: title, 1321 Body: body, 1322 TargetBranch: targetBranch, 1323 OwnerDid: user.Active.Did, 1324 RepoAt: repo.RepoAt(), 1325 Rkey: rkey, 1326 Mentions: mentions, 1327 References: references, 1328 Submissions: []*models.PullSubmission{ 1329 &initialSubmission, 1330 }, 1331 PullSource: pullSource, 1332 State: models.PullOpen, 1333 } 1334 1335 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1336 Collection: tangled.RepoPullNSID, 1337 Repo: user.Active.Did, 1338 Rkey: rkey, 1339 Record: &lexutil.LexiconTypeDecoder{ 1340 Val: &record, 1341 }, 1342 }) 1343 if err != nil { 1344 log.Println("failed to create pull request", err) 1345 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1346 return 1347 } 1348 1349 err = db.PutPull(tx, pull) 1350 if err != nil { 1351 log.Println("failed to create pull request", err) 1352 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1353 return 1354 } 1355 pullId, err := db.NextPullId(tx, repo.RepoAt()) 1356 if err != nil { 1357 log.Println("failed to get pull id", err) 1358 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1359 return 1360 } 1361 1362 if err = tx.Commit(); err != nil { 1363 log.Println("failed to create pull request", err) 1364 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1365 return 1366 } 1367 1368 s.notifier.NewPull(r.Context(), pull) 1369 1370 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1371 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId)) 1372} 1373 1374func (s *Pulls) createStackedPullRequest( 1375 w http.ResponseWriter, 1376 r *http.Request, 1377 repo *models.Repo, 1378 user *oauth.MultiAccountUser, 1379 targetBranch string, 1380 patch string, 1381 sourceRev string, 1382 pullSource *models.PullSource, 1383) { 1384 // run some necessary checks for stacked-prs first 1385 1386 // must be branch or fork based 1387 if sourceRev == "" { 1388 s.logger.Error("stacked PR from patch-based pull") 1389 s.pages.Notice(w, "pull", "Stacking is only supported on branch and fork based pull-requests.") 1390 return 1391 } 1392 1393 formatPatches, err := patchutil.ExtractPatches(patch) 1394 if err != nil { 1395 s.logger.Error("failed to extract patches", "err", err) 1396 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 1397 return 1398 } 1399 1400 // must have atleast 1 patch to begin with 1401 if len(formatPatches) == 0 { 1402 s.logger.Error("empty patches") 1403 s.pages.Notice(w, "pull", "No patches found in the generated format-patch.") 1404 return 1405 } 1406 1407 client, err := s.oauth.AuthorizedClient(r) 1408 if err != nil { 1409 s.logger.Error("failed to get authorized client", "err", err) 1410 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1411 return 1412 } 1413 1414 // first upload all blobs 1415 blobs := make([]*lexutil.LexBlob, len(formatPatches)) 1416 for i, p := range formatPatches { 1417 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.Raw), ApplicationGzip) 1418 if err != nil { 1419 s.logger.Error("failed to upload patch blob", "err", err) 1420 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1421 return 1422 } 1423 s.logger.Info("uploaded blob", "idx", i+1, "total", len(formatPatches)) 1424 blobs[i] = blob.Blob 1425 } 1426 1427 // build a stack out of this patch 1428 stack, err := s.newStack(r.Context(), repo, user, targetBranch, pullSource, formatPatches, blobs) 1429 if err != nil { 1430 s.logger.Error("failed to create stack", "err", err) 1431 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) 1432 return 1433 } 1434 1435 // apply all record creations at once 1436 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1437 for _, p := range stack { 1438 record := p.AsRecord() 1439 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1440 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1441 Collection: tangled.RepoPullNSID, 1442 Rkey: &p.Rkey, 1443 Value: &lexutil.LexiconTypeDecoder{ 1444 Val: &record, 1445 }, 1446 }, 1447 }) 1448 } 1449 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1450 Repo: user.Active.Did, 1451 Writes: writes, 1452 }) 1453 if err != nil { 1454 s.logger.Error("failed to create stacked pull request", "err", err) 1455 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 1456 return 1457 } 1458 1459 // create all pulls at once 1460 tx, err := s.db.BeginTx(r.Context(), nil) 1461 if err != nil { 1462 s.logger.Error("failed to start tx") 1463 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1464 return 1465 } 1466 defer tx.Rollback() 1467 1468 for _, p := range stack { 1469 err = db.PutPull(tx, p) 1470 if err != nil { 1471 s.logger.Error("failed to create pull request", "err", err) 1472 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1473 return 1474 } 1475 1476 } 1477 1478 if err = tx.Commit(); err != nil { 1479 s.logger.Error("failed to create pull request", "err", err) 1480 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1481 return 1482 } 1483 1484 // notify about each pull 1485 // 1486 // this is performed after tx.Commit, because it could result in a locked DB otherwise 1487 for _, p := range stack { 1488 s.notifier.NewPull(r.Context(), p) 1489 } 1490 1491 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1492 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo)) 1493} 1494 1495func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) { 1496 _, err := s.repoResolver.Resolve(r) 1497 if err != nil { 1498 s.logger.Error("failed to get repo and knot", "err", err) 1499 return 1500 } 1501 1502 patch := r.FormValue("patch") 1503 if patch == "" { 1504 s.pages.Notice(w, "patch-error", "Patch is required.") 1505 return 1506 } 1507 1508 if err := s.validator.ValidatePatch(&patch); err != nil { 1509 s.logger.Error("faield to validate patch", "err", err) 1510 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1511 return 1512 } 1513 1514 if patchutil.IsFormatPatch(patch) { 1515 s.pages.Notice(w, "patch-preview", "git-format-patch detected. Title and description are optional; if left out, they will be extracted from the first commit.") 1516 } else { 1517 s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.") 1518 } 1519} 1520 1521func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 1522 user := s.oauth.GetMultiAccountUser(r) 1523 1524 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 1525 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1526 }) 1527} 1528 1529func (s *Pulls) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { 1530 user := s.oauth.GetMultiAccountUser(r) 1531 f, err := s.repoResolver.Resolve(r) 1532 if err != nil { 1533 log.Println("failed to get repo and knot", err) 1534 return 1535 } 1536 1537 scheme := "http" 1538 if !s.config.Core.Dev { 1539 scheme = "https" 1540 } 1541 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1542 xrpcc := &indigoxrpc.Client{ 1543 Host: host, 1544 } 1545 1546 repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1547 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1548 if err != nil { 1549 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1550 log.Println("failed to call XRPC repo.branches", xrpcerr) 1551 s.pages.Error503(w) 1552 return 1553 } 1554 log.Println("failed to fetch branches", err) 1555 return 1556 } 1557 1558 var result types.RepoBranchesResponse 1559 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1560 log.Println("failed to decode XRPC response", err) 1561 s.pages.Error503(w) 1562 return 1563 } 1564 1565 branches := result.Branches 1566 sort.Slice(branches, func(i int, j int) bool { 1567 return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When) 1568 }) 1569 1570 withoutDefault := []types.Branch{} 1571 for _, b := range branches { 1572 if b.IsDefault { 1573 continue 1574 } 1575 withoutDefault = append(withoutDefault, b) 1576 } 1577 1578 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 1579 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1580 Branches: withoutDefault, 1581 }) 1582} 1583 1584func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 1585 user := s.oauth.GetMultiAccountUser(r) 1586 1587 forks, err := db.GetForksByDid(s.db, user.Active.Did) 1588 if err != nil { 1589 log.Println("failed to get forks", err) 1590 return 1591 } 1592 1593 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ 1594 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1595 Forks: forks, 1596 Selected: r.URL.Query().Get("fork"), 1597 }) 1598} 1599 1600func (s *Pulls) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { 1601 user := s.oauth.GetMultiAccountUser(r) 1602 1603 f, err := s.repoResolver.Resolve(r) 1604 if err != nil { 1605 log.Println("failed to get repo and knot", err) 1606 return 1607 } 1608 1609 forkVal := r.URL.Query().Get("fork") 1610 repoString := strings.SplitN(forkVal, "/", 2) 1611 forkOwnerDid := repoString[0] 1612 forkName := repoString[1] 1613 // fork repo 1614 repo, err := db.GetRepo( 1615 s.db, 1616 orm.FilterEq("did", forkOwnerDid), 1617 orm.FilterEq("name", forkName), 1618 ) 1619 if err != nil { 1620 log.Println("failed to get repo", "did", forkOwnerDid, "name", forkName, "err", err) 1621 return 1622 } 1623 1624 sourceScheme := "http" 1625 if !s.config.Core.Dev { 1626 sourceScheme = "https" 1627 } 1628 sourceHost := fmt.Sprintf("%s://%s", sourceScheme, repo.Knot) 1629 sourceXrpcc := &indigoxrpc.Client{ 1630 Host: sourceHost, 1631 } 1632 1633 sourceRepo := fmt.Sprintf("%s/%s", forkOwnerDid, repo.Name) 1634 sourceXrpcBytes, err := tangled.RepoBranches(r.Context(), sourceXrpcc, "", 0, sourceRepo) 1635 if err != nil { 1636 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1637 log.Println("failed to call XRPC repo.branches for source", xrpcerr) 1638 s.pages.Error503(w) 1639 return 1640 } 1641 log.Println("failed to fetch source branches", err) 1642 return 1643 } 1644 1645 // Decode source branches 1646 var sourceBranches types.RepoBranchesResponse 1647 if err := json.Unmarshal(sourceXrpcBytes, &sourceBranches); err != nil { 1648 log.Println("failed to decode source branches XRPC response", err) 1649 s.pages.Error503(w) 1650 return 1651 } 1652 1653 targetScheme := "http" 1654 if !s.config.Core.Dev { 1655 targetScheme = "https" 1656 } 1657 targetHost := fmt.Sprintf("%s://%s", targetScheme, f.Knot) 1658 targetXrpcc := &indigoxrpc.Client{ 1659 Host: targetHost, 1660 } 1661 1662 targetRepo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1663 targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo) 1664 if err != nil { 1665 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1666 log.Println("failed to call XRPC repo.branches for target", xrpcerr) 1667 s.pages.Error503(w) 1668 return 1669 } 1670 log.Println("failed to fetch target branches", err) 1671 return 1672 } 1673 1674 // Decode target branches 1675 var targetBranches types.RepoBranchesResponse 1676 if err := json.Unmarshal(targetXrpcBytes, &targetBranches); err != nil { 1677 log.Println("failed to decode target branches XRPC response", err) 1678 s.pages.Error503(w) 1679 return 1680 } 1681 1682 sort.Slice(sourceBranches.Branches, func(i int, j int) bool { 1683 return sourceBranches.Branches[i].Commit.Committer.When.After(sourceBranches.Branches[j].Commit.Committer.When) 1684 }) 1685 1686 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1687 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1688 SourceBranches: sourceBranches.Branches, 1689 TargetBranches: targetBranches.Branches, 1690 }) 1691} 1692 1693func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { 1694 user := s.oauth.GetMultiAccountUser(r) 1695 1696 pull, ok := r.Context().Value("pull").(*models.Pull) 1697 if !ok { 1698 log.Println("failed to get pull") 1699 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1700 return 1701 } 1702 1703 switch r.Method { 1704 case http.MethodGet: 1705 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 1706 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1707 Pull: pull, 1708 }) 1709 return 1710 case http.MethodPost: 1711 if pull.IsPatchBased() { 1712 s.resubmitPatch(w, r) 1713 return 1714 } else if pull.IsBranchBased() { 1715 s.resubmitBranch(w, r) 1716 return 1717 } else if pull.IsForkBased() { 1718 s.resubmitFork(w, r) 1719 return 1720 } 1721 } 1722} 1723 1724func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1725 user := s.oauth.GetMultiAccountUser(r) 1726 1727 pull, ok := r.Context().Value("pull").(*models.Pull) 1728 if !ok { 1729 log.Println("failed to get pull") 1730 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1731 return 1732 } 1733 1734 f, err := s.repoResolver.Resolve(r) 1735 if err != nil { 1736 log.Println("failed to get repo and knot", err) 1737 return 1738 } 1739 1740 if user.Active.Did != pull.OwnerDid { 1741 log.Println("unauthorized user") 1742 w.WriteHeader(http.StatusUnauthorized) 1743 return 1744 } 1745 1746 patch := r.FormValue("patch") 1747 1748 s.resubmitPullHelper(w, r, f, user, pull, patch, "", "") 1749} 1750 1751func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1752 user := s.oauth.GetMultiAccountUser(r) 1753 1754 pull, ok := r.Context().Value("pull").(*models.Pull) 1755 if !ok { 1756 log.Println("failed to get pull") 1757 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 1758 return 1759 } 1760 1761 f, err := s.repoResolver.Resolve(r) 1762 if err != nil { 1763 log.Println("failed to get repo and knot", err) 1764 return 1765 } 1766 1767 if user.Active.Did != pull.OwnerDid { 1768 log.Println("unauthorized user") 1769 w.WriteHeader(http.StatusUnauthorized) 1770 return 1771 } 1772 1773 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 1774 if !roles.IsPushAllowed() { 1775 log.Println("unauthorized user") 1776 w.WriteHeader(http.StatusUnauthorized) 1777 return 1778 } 1779 1780 scheme := "http" 1781 if !s.config.Core.Dev { 1782 scheme = "https" 1783 } 1784 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1785 xrpcc := &indigoxrpc.Client{ 1786 Host: host, 1787 } 1788 1789 repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1790 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch) 1791 if err != nil { 1792 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1793 log.Println("failed to call XRPC repo.compare", xrpcerr) 1794 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1795 return 1796 } 1797 log.Printf("compare request failed: %s", err) 1798 s.pages.Notice(w, "resubmit-error", err.Error()) 1799 return 1800 } 1801 1802 var comparison types.RepoFormatPatchResponse 1803 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 1804 log.Println("failed to decode XRPC compare response", err) 1805 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1806 return 1807 } 1808 1809 sourceRev := comparison.Rev2 1810 patch := comparison.FormatPatchRaw 1811 combined := comparison.CombinedPatchRaw 1812 1813 s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1814} 1815 1816func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 1817 user := s.oauth.GetMultiAccountUser(r) 1818 1819 pull, ok := r.Context().Value("pull").(*models.Pull) 1820 if !ok { 1821 log.Println("failed to get pull") 1822 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 1823 return 1824 } 1825 1826 f, err := s.repoResolver.Resolve(r) 1827 if err != nil { 1828 log.Println("failed to get repo and knot", err) 1829 return 1830 } 1831 1832 if user.Active.Did != pull.OwnerDid { 1833 log.Println("unauthorized user") 1834 w.WriteHeader(http.StatusUnauthorized) 1835 return 1836 } 1837 1838 forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 1839 if err != nil { 1840 log.Println("failed to get source repo", err) 1841 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1842 return 1843 } 1844 1845 // update the hidden tracking branch to latest 1846 client, err := s.oauth.ServiceClient( 1847 r, 1848 oauth.WithService(forkRepo.Knot), 1849 oauth.WithLxm(tangled.RepoHiddenRefNSID), 1850 oauth.WithDev(s.config.Core.Dev), 1851 ) 1852 if err != nil { 1853 log.Printf("failed to connect to knot server: %v", err) 1854 return 1855 } 1856 1857 resp, err := tangled.RepoHiddenRef( 1858 r.Context(), 1859 client, 1860 &tangled.RepoHiddenRef_Input{ 1861 ForkRef: pull.PullSource.Branch, 1862 RemoteRef: pull.TargetBranch, 1863 Repo: forkRepo.RepoAt().String(), 1864 }, 1865 ) 1866 if err := xrpcclient.HandleXrpcErr(err); err != nil { 1867 s.pages.Notice(w, "resubmit-error", err.Error()) 1868 return 1869 } 1870 if !resp.Success { 1871 log.Println("Failed to update tracking ref.", "err", resp.Error) 1872 s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.") 1873 return 1874 } 1875 1876 hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 1877 // extract patch by performing compare 1878 forkScheme := "http" 1879 if !s.config.Core.Dev { 1880 forkScheme = "https" 1881 } 1882 forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1883 forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name) 1884 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, hiddenRef, pull.PullSource.Branch) 1885 if err != nil { 1886 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1887 log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1888 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1889 return 1890 } 1891 log.Printf("failed to compare branches: %s", err) 1892 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1893 return 1894 } 1895 1896 var forkComparison types.RepoFormatPatchResponse 1897 if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 1898 log.Println("failed to decode XRPC compare response for fork", err) 1899 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1900 return 1901 } 1902 1903 // Use the fork comparison we already made 1904 comparison := forkComparison 1905 1906 sourceRev := comparison.Rev2 1907 patch := comparison.FormatPatchRaw 1908 combined := comparison.CombinedPatchRaw 1909 1910 s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1911} 1912 1913func (s *Pulls) resubmitPullHelper( 1914 w http.ResponseWriter, 1915 r *http.Request, 1916 repo *models.Repo, 1917 user *oauth.MultiAccountUser, 1918 pull *models.Pull, 1919 patch string, 1920 combined string, 1921 sourceRev string, 1922) { 1923 stack := r.Context().Value("stack").(models.Stack) 1924 if stack != nil { 1925 log.Println("resubmitting stacked PR") 1926 s.resubmitStackedPullHelper(w, r, repo, user, pull, patch) 1927 return 1928 } 1929 1930 if err := s.validator.ValidatePatch(&patch); err != nil { 1931 s.pages.Notice(w, "resubmit-error", err.Error()) 1932 return 1933 } 1934 1935 if patch == pull.LatestPatch() { 1936 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 1937 return 1938 } 1939 1940 // validate sourceRev if branch/fork based 1941 if pull.IsBranchBased() || pull.IsForkBased() { 1942 if sourceRev == pull.LatestSha() { 1943 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1944 return 1945 } 1946 } 1947 1948 pullAt := pull.AtUri() 1949 newRoundNumber := len(pull.Submissions) 1950 newPatch := patch 1951 newSourceRev := sourceRev 1952 combinedPatch := combined 1953 1954 client, err := s.oauth.AuthorizedClient(r) 1955 if err != nil { 1956 log.Println("failed to authorize client") 1957 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1958 return 1959 } 1960 1961 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Active.Did, pull.Rkey) 1962 if err != nil { 1963 // failed to get record 1964 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1965 return 1966 } 1967 1968 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 1969 if err != nil { 1970 log.Println("failed to upload patch blob", err) 1971 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1972 return 1973 } 1974 record := pull.AsRecord() 1975 record.Rounds = append(record.Rounds, &tangled.RepoPull_Round{ 1976 CreatedAt: time.Now().Format(time.RFC3339), 1977 PatchBlob: blob.Blob, 1978 }) 1979 record.CreatedAt = time.Now().Format(time.RFC3339) 1980 1981 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1982 Collection: tangled.RepoPullNSID, 1983 Repo: user.Active.Did, 1984 Rkey: pull.Rkey, 1985 SwapRecord: ex.Cid, 1986 Record: &lexutil.LexiconTypeDecoder{ 1987 Val: &record, 1988 }, 1989 }) 1990 if err != nil { 1991 log.Println("failed to update record", err) 1992 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1993 return 1994 } 1995 1996 err = db.ResubmitPull(s.db, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob) 1997 if err != nil { 1998 log.Println("failed to create pull request", err) 1999 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2000 return 2001 } 2002 2003 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 2004 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2005} 2006 2007func (s *Pulls) resubmitStackedPullHelper( 2008 w http.ResponseWriter, 2009 r *http.Request, 2010 repo *models.Repo, 2011 user *oauth.MultiAccountUser, 2012 pull *models.Pull, 2013 patch string, 2014) { 2015 targetBranch := pull.TargetBranch 2016 2017 origStack, _ := r.Context().Value("stack").(models.Stack) 2018 2019 formatPatches, err := patchutil.ExtractPatches(patch) 2020 if err != nil { 2021 s.logger.Error("Failed to extract patches", "err", err) 2022 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Failed to parse patches.") 2023 return 2024 } 2025 2026 // must have atleast 1 patch to begin with 2027 if len(formatPatches) == 0 { 2028 s.logger.Error("No patches found in the generated format-patch.") 2029 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request: No patches found in the generated patch.") 2030 return 2031 } 2032 2033 client, err := s.oauth.AuthorizedClient(r) 2034 if err != nil { 2035 s.logger.Error("failed to get authorized client", "err", err) 2036 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 2037 return 2038 } 2039 2040 // first upload all blobs 2041 blobs := make([]*lexutil.LexBlob, len(formatPatches)) 2042 for i, p := range formatPatches { 2043 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.Raw), ApplicationGzip) 2044 if err != nil { 2045 s.logger.Error("failed to upload patch blob", "err", err) 2046 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 2047 return 2048 } 2049 s.logger.Info("uploaded blob", "idx", i+1, "total", len(formatPatches)) 2050 blobs[i] = blob.Blob 2051 } 2052 2053 newStack, err := s.newStack(r.Context(), repo, user, targetBranch, pull.PullSource, formatPatches, blobs) 2054 if err != nil { 2055 log.Println("failed to create resubmitted stack", err) 2056 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2057 return 2058 } 2059 2060 // find the diff between the stacks, first, map them by changeId 2061 origById := make(map[string]*models.Pull) 2062 newById := make(map[string]*models.Pull) 2063 for _, p := range origStack { 2064 origById[p.LatestSubmission().ChangeId()] = p 2065 } 2066 for _, p := range newStack { 2067 newById[p.LatestSubmission().ChangeId()] = p 2068 } 2069 2070 // commits that got deleted: corresponding pull is closed 2071 // commits that got added: new pull is created 2072 // commits that got updated: corresponding pull is resubmitted & new round begins 2073 additions := make(map[string]*models.Pull) 2074 deletions := make(map[string]*models.Pull) 2075 updated := make(map[string]struct{}) 2076 2077 // pulls in orignal stack but not in new one 2078 for _, op := range origStack { 2079 if _, ok := newById[op.LatestSubmission().ChangeId()]; !ok { 2080 deletions[op.LatestSubmission().ChangeId()] = op 2081 } 2082 } 2083 2084 // pulls in new stack but not in original one 2085 for _, np := range newStack { 2086 if _, ok := origById[np.LatestSubmission().ChangeId()]; !ok { 2087 additions[np.LatestSubmission().ChangeId()] = np 2088 } 2089 } 2090 2091 // NOTE: this loop can be written in any of above blocks, 2092 // but is written separately in the interest of simpler code 2093 for _, np := range newStack { 2094 if op, ok := origById[np.LatestSubmission().ChangeId()]; ok { 2095 // pull exists in both stacks 2096 updated[op.LatestSubmission().ChangeId()] = struct{}{} 2097 } 2098 } 2099 2100 // NOTE: we can go through the newStack and update dependent relations and 2101 // rkeys now that we know which ones have been updated 2102 // update dependentOn relations for the entire stack 2103 var parentAt *syntax.ATURI 2104 for _, np := range newStack { 2105 if op, ok := origById[np.LatestSubmission().ChangeId()]; ok { 2106 // pull exists in both stacks 2107 np.Rkey = op.Rkey 2108 } 2109 np.DependentOn = parentAt 2110 x := np.AtUri() 2111 parentAt = &x 2112 } 2113 2114 tx, err := s.db.Begin() 2115 if err != nil { 2116 log.Println("failed to start transaction", err) 2117 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2118 return 2119 } 2120 defer tx.Rollback() 2121 2122 // pds updates to make 2123 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 2124 2125 // deleted pulls are marked as deleted in the DB 2126 for _, p := range deletions { 2127 // do not do delete already merged PRs 2128 if p.State == models.PullMerged { 2129 continue 2130 } 2131 2132 err := db.AbandonPulls(tx, orm.FilterEq("repo_at", p.RepoAt), orm.FilterEq("at_uri", p.AtUri())) 2133 if err != nil { 2134 log.Println("failed to delete pull", err, p.PullId) 2135 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2136 return 2137 } 2138 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2139 RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 2140 Collection: tangled.RepoPullNSID, 2141 Rkey: p.Rkey, 2142 }, 2143 }) 2144 } 2145 2146 // new pulls are created 2147 for _, p := range additions { 2148 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.LatestPatch()), ApplicationGzip) 2149 if err != nil { 2150 log.Println("failed to upload patch blob", err) 2151 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2152 return 2153 } 2154 p.Submissions[0].Blob = *blob.Blob 2155 2156 if err = db.PutPull(tx, p); err != nil { 2157 log.Println("failed to create pull", err, p.PullId) 2158 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2159 return 2160 } 2161 2162 record := p.AsRecord() 2163 record.Rounds = []*tangled.RepoPull_Round{ 2164 { 2165 CreatedAt: time.Now().Format(time.RFC3339), 2166 PatchBlob: blob.Blob, 2167 }, 2168 } 2169 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2170 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 2171 Collection: tangled.RepoPullNSID, 2172 Rkey: &p.Rkey, 2173 Value: &lexutil.LexiconTypeDecoder{ 2174 Val: &record, 2175 }, 2176 }, 2177 }) 2178 } 2179 2180 // updated pulls are, well, updated; to start a new round 2181 for id := range updated { 2182 op, _ := origById[id] 2183 np, _ := newById[id] 2184 2185 // do not update already merged PRs 2186 if op.State == models.PullMerged { 2187 continue 2188 } 2189 2190 // resubmit the new pull 2191 np.Rkey = op.Rkey 2192 pullAt := op.AtUri() 2193 newRoundNumber := len(op.Submissions) 2194 newPatch := np.LatestPatch() 2195 combinedPatch := np.LatestSubmission().Combined 2196 newSourceRev := np.LatestSha() 2197 2198 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(newPatch), ApplicationGzip) 2199 if err != nil { 2200 log.Println("failed to upload patch blob", err) 2201 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2202 return 2203 } 2204 2205 err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob) 2206 if err != nil { 2207 log.Println("failed to update pull", err, op.PullId) 2208 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2209 return 2210 } 2211 2212 record := np.AsRecord() 2213 record.Rounds = op.AsRecord().Rounds 2214 record.Rounds = append(record.Rounds, &tangled.RepoPull_Round{ 2215 CreatedAt: time.Now().Format(time.RFC3339), 2216 PatchBlob: blob.Blob, 2217 }) 2218 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2219 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 2220 Collection: tangled.RepoPullNSID, 2221 Rkey: op.Rkey, 2222 Value: &lexutil.LexiconTypeDecoder{ 2223 Val: &record, 2224 }, 2225 }, 2226 }) 2227 } 2228 2229 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2230 Repo: user.Active.Did, 2231 Writes: writes, 2232 }) 2233 if err != nil { 2234 log.Println("failed to create stacked pull request", err) 2235 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 2236 return 2237 } 2238 2239 err = tx.Commit() 2240 if err != nil { 2241 log.Println("failed to resubmit pull", err) 2242 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2243 return 2244 } 2245 2246 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 2247 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2248} 2249 2250func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 2251 user := s.oauth.GetMultiAccountUser(r) 2252 f, err := s.repoResolver.Resolve(r) 2253 if err != nil { 2254 log.Println("failed to resolve repo:", err) 2255 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2256 return 2257 } 2258 2259 pull, ok := r.Context().Value("pull").(*models.Pull) 2260 if !ok { 2261 log.Println("failed to get pull") 2262 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2263 return 2264 } 2265 2266 stack, ok := r.Context().Value("stack").(models.Stack) 2267 if !ok { 2268 log.Println("failed to get stack") 2269 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2270 return 2271 } 2272 2273 // combine patches of substack 2274 subStack := stack.Below(pull) 2275 // collect the portion of the stack that is mergeable 2276 pullsToMerge := subStack.Mergeable() 2277 2278 patch := pullsToMerge.CombinedPatch() 2279 2280 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 2281 if err != nil { 2282 log.Printf("resolving identity: %s", err) 2283 w.WriteHeader(http.StatusNotFound) 2284 return 2285 } 2286 2287 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 2288 if err != nil { 2289 log.Printf("failed to get primary email: %s", err) 2290 } 2291 2292 authorName := ident.Handle.String() 2293 mergeInput := &tangled.RepoMerge_Input{ 2294 Did: f.Did, 2295 Name: f.Name, 2296 Branch: pull.TargetBranch, 2297 Patch: patch, 2298 CommitMessage: &pull.Title, 2299 AuthorName: &authorName, 2300 } 2301 2302 if pull.Body != "" { 2303 mergeInput.CommitBody = &pull.Body 2304 } 2305 2306 if email.Address != "" { 2307 mergeInput.AuthorEmail = &email.Address 2308 } 2309 2310 client, err := s.oauth.ServiceClient( 2311 r, 2312 oauth.WithService(f.Knot), 2313 oauth.WithLxm(tangled.RepoMergeNSID), 2314 oauth.WithDev(s.config.Core.Dev), 2315 ) 2316 if err != nil { 2317 log.Printf("failed to connect to knot server: %v", err) 2318 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2319 return 2320 } 2321 2322 err = tangled.RepoMerge(r.Context(), client, mergeInput) 2323 if err := xrpcclient.HandleXrpcErr(err); err != nil { 2324 s.pages.Notice(w, "pull-merge-error", err.Error()) 2325 return 2326 } 2327 2328 tx, err := s.db.Begin() 2329 if err != nil { 2330 log.Println("failed to start transcation", err) 2331 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2332 return 2333 } 2334 defer tx.Rollback() 2335 2336 var atUris []syntax.ATURI 2337 for _, p := range pullsToMerge { 2338 atUris = append(atUris, p.AtUri()) 2339 p.State = models.PullMerged 2340 } 2341 err = db.MergePulls(tx, orm.FilterEq("repo_at", f.RepoAt()), orm.FilterIn("at_uri", atUris)) 2342 if err != nil { 2343 log.Printf("failed to update pull request status in database: %s", err) 2344 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2345 return 2346 } 2347 2348 err = tx.Commit() 2349 if err != nil { 2350 // TODO: this is unsound, we should also revert the merge from the knotserver here 2351 log.Printf("failed to update pull request status in database: %s", err) 2352 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2353 return 2354 } 2355 2356 // notify about the pull merge 2357 for _, p := range pullsToMerge { 2358 s.notifier.NewPullState(r.Context(), syntax.DID(user.Active.Did), p) 2359 } 2360 2361 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2362 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2363} 2364 2365func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { 2366 user := s.oauth.GetMultiAccountUser(r) 2367 2368 f, err := s.repoResolver.Resolve(r) 2369 if err != nil { 2370 log.Println("malformed middleware") 2371 return 2372 } 2373 2374 pull, ok := r.Context().Value("pull").(*models.Pull) 2375 if !ok { 2376 log.Println("failed to get pull") 2377 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2378 return 2379 } 2380 2381 // auth filter: only owner or collaborators can close 2382 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 2383 isOwner := roles.IsOwner() 2384 isCollaborator := roles.IsCollaborator() 2385 isPullAuthor := user.Active.Did == pull.OwnerDid 2386 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2387 if !isCloseAllowed { 2388 log.Println("failed to close pull") 2389 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 2390 return 2391 } 2392 2393 // Start a transaction 2394 tx, err := s.db.BeginTx(r.Context(), nil) 2395 if err != nil { 2396 log.Println("failed to start transaction", err) 2397 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2398 return 2399 } 2400 defer tx.Rollback() 2401 2402 // if this PR is stacked, then we want to close all PRs above this one on the stack 2403 stack := r.Context().Value("stack").(models.Stack) 2404 pullsToClose := stack.Above(pull) 2405 var atUris []syntax.ATURI 2406 for _, p := range pullsToClose { 2407 atUris = append(atUris, p.AtUri()) 2408 p.State = models.PullClosed 2409 } 2410 err = db.ClosePulls( 2411 tx, 2412 orm.FilterEq("repo_at", f.RepoAt()), 2413 orm.FilterIn("at_uri", atUris), 2414 ) 2415 if err != nil { 2416 log.Println("failed to close pulls", err) 2417 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2418 } 2419 2420 // Commit the transaction 2421 if err = tx.Commit(); err != nil { 2422 log.Println("failed to commit transaction", err) 2423 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2424 return 2425 } 2426 2427 for _, p := range pullsToClose { 2428 s.notifier.NewPullState(r.Context(), syntax.DID(user.Active.Did), p) 2429 } 2430 2431 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2432 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2433} 2434 2435func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { 2436 user := s.oauth.GetMultiAccountUser(r) 2437 2438 f, err := s.repoResolver.Resolve(r) 2439 if err != nil { 2440 log.Println("failed to resolve repo", err) 2441 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2442 return 2443 } 2444 2445 pull, ok := r.Context().Value("pull").(*models.Pull) 2446 if !ok { 2447 log.Println("failed to get pull") 2448 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2449 return 2450 } 2451 2452 // auth filter: only owner or collaborators can close 2453 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 2454 isOwner := roles.IsOwner() 2455 isCollaborator := roles.IsCollaborator() 2456 isPullAuthor := user.Active.Did == pull.OwnerDid 2457 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2458 if !isCloseAllowed { 2459 log.Println("failed to close pull") 2460 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 2461 return 2462 } 2463 2464 // Start a transaction 2465 tx, err := s.db.BeginTx(r.Context(), nil) 2466 if err != nil { 2467 log.Println("failed to start transaction", err) 2468 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2469 return 2470 } 2471 defer tx.Rollback() 2472 2473 // if this PR is stacked, then we want to reopen all PRs above this one on the stack 2474 stack := r.Context().Value("stack").(models.Stack) 2475 pullsToReopen := stack.Below(pull) 2476 var atUris []syntax.ATURI 2477 for _, p := range pullsToReopen { 2478 atUris = append(atUris, p.AtUri()) 2479 p.State = models.PullOpen 2480 } 2481 err = db.ReopenPulls( 2482 tx, 2483 orm.FilterEq("repo_at", f.RepoAt()), 2484 orm.FilterIn("at_uri", atUris), 2485 ) 2486 if err != nil { 2487 log.Println("failed to reopen pulls", err) 2488 s.pages.Notice(w, "pull-close", "Failed to reopen pull.") 2489 } 2490 2491 // Commit the transaction 2492 if err = tx.Commit(); err != nil { 2493 log.Println("failed to commit transaction", err) 2494 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2495 return 2496 } 2497 2498 for _, p := range pullsToReopen { 2499 s.notifier.NewPullState(r.Context(), syntax.DID(user.Active.Did), p) 2500 } 2501 2502 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2503 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2504} 2505 2506func (s *Pulls) newStack( 2507 ctx context.Context, 2508 repo *models.Repo, 2509 user *oauth.MultiAccountUser, 2510 targetBranch string, 2511 pullSource *models.PullSource, 2512 formatPatches []types.FormatPatch, 2513 blobs []*lexutil.LexBlob, 2514) (models.Stack, error) { 2515 var stack models.Stack 2516 var parentAtUri *syntax.ATURI 2517 for i, fp := range formatPatches { 2518 // all patches must have a jj change-id 2519 _, err := fp.ChangeId() 2520 if err != nil { 2521 return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.") 2522 } 2523 2524 title := fp.Title 2525 body := fp.Body 2526 rkey := tid.TID() 2527 2528 mentions, references := s.mentionsResolver.Resolve(ctx, body) 2529 2530 initialSubmission := models.PullSubmission{ 2531 Patch: fp.Raw, 2532 SourceRev: fp.SHA, 2533 Combined: fp.Raw, 2534 Blob: *blobs[i], 2535 } 2536 pull := models.Pull{ 2537 Title: title, 2538 Body: body, 2539 TargetBranch: targetBranch, 2540 OwnerDid: user.Active.Did, 2541 RepoAt: repo.RepoAt(), 2542 Rkey: rkey, 2543 Mentions: mentions, 2544 References: references, 2545 Submissions: []*models.PullSubmission{ 2546 &initialSubmission, 2547 }, 2548 PullSource: pullSource, 2549 Created: time.Now(), 2550 State: models.PullOpen, 2551 2552 DependentOn: parentAtUri, 2553 } 2554 2555 stack = append(stack, &pull) 2556 2557 parent := pull.AtUri() 2558 parentAtUri = &parent 2559 } 2560 2561 return stack, nil 2562} 2563 2564func gz(s string) io.Reader { 2565 var b bytes.Buffer 2566 w := gzip.NewWriter(&b) 2567 w.Write([]byte(s)) 2568 w.Close() 2569 return &b 2570} 2571 2572func ptrPullState(s models.PullState) *models.PullState { return &s }