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