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