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