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