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 CreatedAt: time.Now().Format(time.RFC3339), 1207 }, 1208 }, 1209 }) 1210 if err != nil { 1211 log.Println("failed to create pull request", err) 1212 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1213 return 1214 } 1215 1216 if err = tx.Commit(); err != nil { 1217 log.Println("failed to create pull request", err) 1218 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1219 return 1220 } 1221 1222 s.notifier.NewPull(r.Context(), pull) 1223 1224 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pullId)) 1225} 1226 1227func (s *Pulls) createStackedPullRequest( 1228 w http.ResponseWriter, 1229 r *http.Request, 1230 f *reporesolver.ResolvedRepo, 1231 user *oauth.User, 1232 targetBranch string, 1233 patch string, 1234 sourceRev string, 1235 pullSource *models.PullSource, 1236) { 1237 // run some necessary checks for stacked-prs first 1238 1239 // must be branch or fork based 1240 if sourceRev == "" { 1241 log.Println("stacked PR from patch-based pull") 1242 s.pages.Notice(w, "pull", "Stacking is only supported on branch and fork based pull-requests.") 1243 return 1244 } 1245 1246 formatPatches, err := patchutil.ExtractPatches(patch) 1247 if err != nil { 1248 log.Println("failed to extract patches", err) 1249 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 1250 return 1251 } 1252 1253 // must have atleast 1 patch to begin with 1254 if len(formatPatches) == 0 { 1255 log.Println("empty patches") 1256 s.pages.Notice(w, "pull", "No patches found in the generated format-patch.") 1257 return 1258 } 1259 1260 // build a stack out of this patch 1261 stackId := uuid.New() 1262 stack, err := newStack(f, user, targetBranch, patch, pullSource, stackId.String()) 1263 if err != nil { 1264 log.Println("failed to create stack", err) 1265 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) 1266 return 1267 } 1268 1269 client, err := s.oauth.AuthorizedClient(r) 1270 if err != nil { 1271 log.Println("failed to get authorized client", err) 1272 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1273 return 1274 } 1275 1276 // apply all record creations at once 1277 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1278 for _, p := range stack { 1279 record := p.AsRecord() 1280 write := comatproto.RepoApplyWrites_Input_Writes_Elem{ 1281 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1282 Collection: tangled.RepoPullNSID, 1283 Rkey: &p.Rkey, 1284 Value: &lexutil.LexiconTypeDecoder{ 1285 Val: &record, 1286 }, 1287 }, 1288 } 1289 writes = append(writes, &write) 1290 } 1291 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1292 Repo: user.Did, 1293 Writes: writes, 1294 }) 1295 if err != nil { 1296 log.Println("failed to create stacked pull request", err) 1297 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 1298 return 1299 } 1300 1301 // create all pulls at once 1302 tx, err := s.db.BeginTx(r.Context(), nil) 1303 if err != nil { 1304 log.Println("failed to start tx") 1305 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1306 return 1307 } 1308 defer tx.Rollback() 1309 1310 for _, p := range stack { 1311 err = db.NewPull(tx, p) 1312 if err != nil { 1313 log.Println("failed to create pull request", err) 1314 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1315 return 1316 } 1317 } 1318 1319 if err = tx.Commit(); err != nil { 1320 log.Println("failed to create pull request", err) 1321 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1322 return 1323 } 1324 1325 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", f.OwnerSlashRepo())) 1326} 1327 1328func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) { 1329 _, err := s.repoResolver.Resolve(r) 1330 if err != nil { 1331 log.Println("failed to get repo and knot", err) 1332 return 1333 } 1334 1335 patch := r.FormValue("patch") 1336 if patch == "" { 1337 s.pages.Notice(w, "patch-error", "Patch is required.") 1338 return 1339 } 1340 1341 if patch == "" || !patchutil.IsPatchValid(patch) { 1342 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1343 return 1344 } 1345 1346 if patchutil.IsFormatPatch(patch) { 1347 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.") 1348 } else { 1349 s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.") 1350 } 1351} 1352 1353func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 1354 user := s.oauth.GetUser(r) 1355 f, err := s.repoResolver.Resolve(r) 1356 if err != nil { 1357 log.Println("failed to get repo and knot", err) 1358 return 1359 } 1360 1361 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 1362 RepoInfo: f.RepoInfo(user), 1363 }) 1364} 1365 1366func (s *Pulls) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { 1367 user := s.oauth.GetUser(r) 1368 f, err := s.repoResolver.Resolve(r) 1369 if err != nil { 1370 log.Println("failed to get repo and knot", err) 1371 return 1372 } 1373 1374 scheme := "http" 1375 if !s.config.Core.Dev { 1376 scheme = "https" 1377 } 1378 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1379 xrpcc := &indigoxrpc.Client{ 1380 Host: host, 1381 } 1382 1383 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1384 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1385 if err != nil { 1386 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1387 log.Println("failed to call XRPC repo.branches", xrpcerr) 1388 s.pages.Error503(w) 1389 return 1390 } 1391 log.Println("failed to fetch branches", err) 1392 return 1393 } 1394 1395 var result types.RepoBranchesResponse 1396 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1397 log.Println("failed to decode XRPC response", err) 1398 s.pages.Error503(w) 1399 return 1400 } 1401 1402 branches := result.Branches 1403 sort.Slice(branches, func(i int, j int) bool { 1404 return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When) 1405 }) 1406 1407 withoutDefault := []types.Branch{} 1408 for _, b := range branches { 1409 if b.IsDefault { 1410 continue 1411 } 1412 withoutDefault = append(withoutDefault, b) 1413 } 1414 1415 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 1416 RepoInfo: f.RepoInfo(user), 1417 Branches: withoutDefault, 1418 }) 1419} 1420 1421func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 1422 user := s.oauth.GetUser(r) 1423 f, err := s.repoResolver.Resolve(r) 1424 if err != nil { 1425 log.Println("failed to get repo and knot", err) 1426 return 1427 } 1428 1429 forks, err := db.GetForksByDid(s.db, user.Did) 1430 if err != nil { 1431 log.Println("failed to get forks", err) 1432 return 1433 } 1434 1435 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ 1436 RepoInfo: f.RepoInfo(user), 1437 Forks: forks, 1438 Selected: r.URL.Query().Get("fork"), 1439 }) 1440} 1441 1442func (s *Pulls) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { 1443 user := s.oauth.GetUser(r) 1444 1445 f, err := s.repoResolver.Resolve(r) 1446 if err != nil { 1447 log.Println("failed to get repo and knot", err) 1448 return 1449 } 1450 1451 forkVal := r.URL.Query().Get("fork") 1452 repoString := strings.SplitN(forkVal, "/", 2) 1453 forkOwnerDid := repoString[0] 1454 forkName := repoString[1] 1455 // fork repo 1456 repo, err := db.GetRepo( 1457 s.db, 1458 db.FilterEq("did", forkOwnerDid), 1459 db.FilterEq("name", forkName), 1460 ) 1461 if err != nil { 1462 log.Println("failed to get repo", "did", forkOwnerDid, "name", forkName, "err", err) 1463 return 1464 } 1465 1466 sourceScheme := "http" 1467 if !s.config.Core.Dev { 1468 sourceScheme = "https" 1469 } 1470 sourceHost := fmt.Sprintf("%s://%s", sourceScheme, repo.Knot) 1471 sourceXrpcc := &indigoxrpc.Client{ 1472 Host: sourceHost, 1473 } 1474 1475 sourceRepo := fmt.Sprintf("%s/%s", forkOwnerDid, repo.Name) 1476 sourceXrpcBytes, err := tangled.RepoBranches(r.Context(), sourceXrpcc, "", 0, sourceRepo) 1477 if err != nil { 1478 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1479 log.Println("failed to call XRPC repo.branches for source", xrpcerr) 1480 s.pages.Error503(w) 1481 return 1482 } 1483 log.Println("failed to fetch source branches", err) 1484 return 1485 } 1486 1487 // Decode source branches 1488 var sourceBranches types.RepoBranchesResponse 1489 if err := json.Unmarshal(sourceXrpcBytes, &sourceBranches); err != nil { 1490 log.Println("failed to decode source branches XRPC response", err) 1491 s.pages.Error503(w) 1492 return 1493 } 1494 1495 targetScheme := "http" 1496 if !s.config.Core.Dev { 1497 targetScheme = "https" 1498 } 1499 targetHost := fmt.Sprintf("%s://%s", targetScheme, f.Knot) 1500 targetXrpcc := &indigoxrpc.Client{ 1501 Host: targetHost, 1502 } 1503 1504 targetRepo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1505 targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo) 1506 if err != nil { 1507 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1508 log.Println("failed to call XRPC repo.branches for target", xrpcerr) 1509 s.pages.Error503(w) 1510 return 1511 } 1512 log.Println("failed to fetch target branches", err) 1513 return 1514 } 1515 1516 // Decode target branches 1517 var targetBranches types.RepoBranchesResponse 1518 if err := json.Unmarshal(targetXrpcBytes, &targetBranches); err != nil { 1519 log.Println("failed to decode target branches XRPC response", err) 1520 s.pages.Error503(w) 1521 return 1522 } 1523 1524 sort.Slice(sourceBranches.Branches, func(i int, j int) bool { 1525 return sourceBranches.Branches[i].Commit.Committer.When.After(sourceBranches.Branches[j].Commit.Committer.When) 1526 }) 1527 1528 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1529 RepoInfo: f.RepoInfo(user), 1530 SourceBranches: sourceBranches.Branches, 1531 TargetBranches: targetBranches.Branches, 1532 }) 1533} 1534 1535func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { 1536 user := s.oauth.GetUser(r) 1537 f, err := s.repoResolver.Resolve(r) 1538 if err != nil { 1539 log.Println("failed to get repo and knot", err) 1540 return 1541 } 1542 1543 pull, ok := r.Context().Value("pull").(*models.Pull) 1544 if !ok { 1545 log.Println("failed to get pull") 1546 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1547 return 1548 } 1549 1550 switch r.Method { 1551 case http.MethodGet: 1552 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 1553 RepoInfo: f.RepoInfo(user), 1554 Pull: pull, 1555 }) 1556 return 1557 case http.MethodPost: 1558 if pull.IsPatchBased() { 1559 s.resubmitPatch(w, r) 1560 return 1561 } else if pull.IsBranchBased() { 1562 s.resubmitBranch(w, r) 1563 return 1564 } else if pull.IsForkBased() { 1565 s.resubmitFork(w, r) 1566 return 1567 } 1568 } 1569} 1570 1571func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1572 user := s.oauth.GetUser(r) 1573 1574 pull, ok := r.Context().Value("pull").(*models.Pull) 1575 if !ok { 1576 log.Println("failed to get pull") 1577 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1578 return 1579 } 1580 1581 f, err := s.repoResolver.Resolve(r) 1582 if err != nil { 1583 log.Println("failed to get repo and knot", err) 1584 return 1585 } 1586 1587 if user.Did != pull.OwnerDid { 1588 log.Println("unauthorized user") 1589 w.WriteHeader(http.StatusUnauthorized) 1590 return 1591 } 1592 1593 patch := r.FormValue("patch") 1594 1595 s.resubmitPullHelper(w, r, f, user, pull, patch, "") 1596} 1597 1598func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1599 user := s.oauth.GetUser(r) 1600 1601 pull, ok := r.Context().Value("pull").(*models.Pull) 1602 if !ok { 1603 log.Println("failed to get pull") 1604 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 1605 return 1606 } 1607 1608 f, err := s.repoResolver.Resolve(r) 1609 if err != nil { 1610 log.Println("failed to get repo and knot", err) 1611 return 1612 } 1613 1614 if user.Did != pull.OwnerDid { 1615 log.Println("unauthorized user") 1616 w.WriteHeader(http.StatusUnauthorized) 1617 return 1618 } 1619 1620 if !f.RepoInfo(user).Roles.IsPushAllowed() { 1621 log.Println("unauthorized user") 1622 w.WriteHeader(http.StatusUnauthorized) 1623 return 1624 } 1625 1626 scheme := "http" 1627 if !s.config.Core.Dev { 1628 scheme = "https" 1629 } 1630 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1631 xrpcc := &indigoxrpc.Client{ 1632 Host: host, 1633 } 1634 1635 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 1636 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch) 1637 if err != nil { 1638 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1639 log.Println("failed to call XRPC repo.compare", xrpcerr) 1640 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1641 return 1642 } 1643 log.Printf("compare request failed: %s", err) 1644 s.pages.Notice(w, "resubmit-error", err.Error()) 1645 return 1646 } 1647 1648 var comparison types.RepoFormatPatchResponse 1649 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 1650 log.Println("failed to decode XRPC compare response", err) 1651 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1652 return 1653 } 1654 1655 sourceRev := comparison.Rev2 1656 patch := comparison.Patch 1657 1658 s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) 1659} 1660 1661func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 1662 user := s.oauth.GetUser(r) 1663 1664 pull, ok := r.Context().Value("pull").(*models.Pull) 1665 if !ok { 1666 log.Println("failed to get pull") 1667 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 1668 return 1669 } 1670 1671 f, err := s.repoResolver.Resolve(r) 1672 if err != nil { 1673 log.Println("failed to get repo and knot", err) 1674 return 1675 } 1676 1677 if user.Did != pull.OwnerDid { 1678 log.Println("unauthorized user") 1679 w.WriteHeader(http.StatusUnauthorized) 1680 return 1681 } 1682 1683 forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 1684 if err != nil { 1685 log.Println("failed to get source repo", err) 1686 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1687 return 1688 } 1689 1690 // extract patch by performing compare 1691 forkScheme := "http" 1692 if !s.config.Core.Dev { 1693 forkScheme = "https" 1694 } 1695 forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1696 forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name) 1697 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, pull.TargetBranch, pull.PullSource.Branch) 1698 if err != nil { 1699 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1700 log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1701 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1702 return 1703 } 1704 log.Printf("failed to compare branches: %s", err) 1705 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1706 return 1707 } 1708 1709 var forkComparison types.RepoFormatPatchResponse 1710 if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 1711 log.Println("failed to decode XRPC compare response for fork", err) 1712 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1713 return 1714 } 1715 1716 // update the hidden tracking branch to latest 1717 client, err := s.oauth.ServiceClient( 1718 r, 1719 oauth.WithService(forkRepo.Knot), 1720 oauth.WithLxm(tangled.RepoHiddenRefNSID), 1721 oauth.WithDev(s.config.Core.Dev), 1722 ) 1723 if err != nil { 1724 log.Printf("failed to connect to knot server: %v", err) 1725 return 1726 } 1727 1728 resp, err := tangled.RepoHiddenRef( 1729 r.Context(), 1730 client, 1731 &tangled.RepoHiddenRef_Input{ 1732 ForkRef: pull.PullSource.Branch, 1733 RemoteRef: pull.TargetBranch, 1734 Repo: forkRepo.RepoAt().String(), 1735 }, 1736 ) 1737 if err := xrpcclient.HandleXrpcErr(err); err != nil { 1738 s.pages.Notice(w, "resubmit-error", err.Error()) 1739 return 1740 } 1741 if !resp.Success { 1742 log.Println("Failed to update tracking ref.", "err", resp.Error) 1743 s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.") 1744 return 1745 } 1746 1747 // Use the fork comparison we already made 1748 comparison := forkComparison 1749 1750 sourceRev := comparison.Rev2 1751 patch := comparison.Patch 1752 1753 s.resubmitPullHelper(w, r, f, user, pull, patch, sourceRev) 1754} 1755 1756// validate a resubmission against a pull request 1757func validateResubmittedPatch(pull *models.Pull, patch string) error { 1758 if patch == "" { 1759 return fmt.Errorf("Patch is empty.") 1760 } 1761 1762 if patch == pull.LatestPatch() { 1763 return fmt.Errorf("Patch is identical to previous submission.") 1764 } 1765 1766 if !patchutil.IsPatchValid(patch) { 1767 return fmt.Errorf("Invalid patch format. Please provide a valid diff.") 1768 } 1769 1770 return nil 1771} 1772 1773func (s *Pulls) resubmitPullHelper( 1774 w http.ResponseWriter, 1775 r *http.Request, 1776 f *reporesolver.ResolvedRepo, 1777 user *oauth.User, 1778 pull *models.Pull, 1779 patch string, 1780 sourceRev string, 1781) { 1782 if pull.IsStacked() { 1783 log.Println("resubmitting stacked PR") 1784 s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId) 1785 return 1786 } 1787 1788 if err := validateResubmittedPatch(pull, patch); err != nil { 1789 s.pages.Notice(w, "resubmit-error", err.Error()) 1790 return 1791 } 1792 1793 // validate sourceRev if branch/fork based 1794 if pull.IsBranchBased() || pull.IsForkBased() { 1795 if sourceRev == pull.Submissions[pull.LastRoundNumber()].SourceRev { 1796 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1797 return 1798 } 1799 } 1800 1801 tx, err := s.db.BeginTx(r.Context(), nil) 1802 if err != nil { 1803 log.Println("failed to start tx") 1804 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1805 return 1806 } 1807 defer tx.Rollback() 1808 1809 err = db.ResubmitPull(tx, pull, patch, sourceRev) 1810 if err != nil { 1811 log.Println("failed to create pull request", err) 1812 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1813 return 1814 } 1815 client, err := s.oauth.AuthorizedClient(r) 1816 if err != nil { 1817 log.Println("failed to authorize client") 1818 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1819 return 1820 } 1821 1822 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1823 if err != nil { 1824 // failed to get record 1825 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1826 return 1827 } 1828 1829 var recordPullSource *tangled.RepoPull_Source 1830 if pull.IsBranchBased() { 1831 recordPullSource = &tangled.RepoPull_Source{ 1832 Branch: pull.PullSource.Branch, 1833 Sha: sourceRev, 1834 } 1835 } 1836 if pull.IsForkBased() { 1837 repoAt := pull.PullSource.RepoAt.String() 1838 recordPullSource = &tangled.RepoPull_Source{ 1839 Branch: pull.PullSource.Branch, 1840 Repo: &repoAt, 1841 Sha: sourceRev, 1842 } 1843 } 1844 1845 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1846 Collection: tangled.RepoPullNSID, 1847 Repo: user.Did, 1848 Rkey: pull.Rkey, 1849 SwapRecord: ex.Cid, 1850 Record: &lexutil.LexiconTypeDecoder{ 1851 Val: &tangled.RepoPull{ 1852 Title: pull.Title, 1853 Target: &tangled.RepoPull_Target{ 1854 Repo: string(f.RepoAt()), 1855 Branch: pull.TargetBranch, 1856 }, 1857 Patch: patch, // new patch 1858 Source: recordPullSource, 1859 CreatedAt: time.Now().Format(time.RFC3339), 1860 }, 1861 }, 1862 }) 1863 if err != nil { 1864 log.Println("failed to update record", err) 1865 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1866 return 1867 } 1868 1869 if err = tx.Commit(); err != nil { 1870 log.Println("failed to commit transaction", err) 1871 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1872 return 1873 } 1874 1875 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1876} 1877 1878func (s *Pulls) resubmitStackedPullHelper( 1879 w http.ResponseWriter, 1880 r *http.Request, 1881 f *reporesolver.ResolvedRepo, 1882 user *oauth.User, 1883 pull *models.Pull, 1884 patch string, 1885 stackId string, 1886) { 1887 targetBranch := pull.TargetBranch 1888 1889 origStack, _ := r.Context().Value("stack").(models.Stack) 1890 newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId) 1891 if err != nil { 1892 log.Println("failed to create resubmitted stack", err) 1893 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1894 return 1895 } 1896 1897 // find the diff between the stacks, first, map them by changeId 1898 origById := make(map[string]*models.Pull) 1899 newById := make(map[string]*models.Pull) 1900 for _, p := range origStack { 1901 origById[p.ChangeId] = p 1902 } 1903 for _, p := range newStack { 1904 newById[p.ChangeId] = p 1905 } 1906 1907 // commits that got deleted: corresponding pull is closed 1908 // commits that got added: new pull is created 1909 // commits that got updated: corresponding pull is resubmitted & new round begins 1910 // 1911 // for commits that were unchanged: no changes, parent-change-id is updated as necessary 1912 additions := make(map[string]*models.Pull) 1913 deletions := make(map[string]*models.Pull) 1914 unchanged := make(map[string]struct{}) 1915 updated := make(map[string]struct{}) 1916 1917 // pulls in orignal stack but not in new one 1918 for _, op := range origStack { 1919 if _, ok := newById[op.ChangeId]; !ok { 1920 deletions[op.ChangeId] = op 1921 } 1922 } 1923 1924 // pulls in new stack but not in original one 1925 for _, np := range newStack { 1926 if _, ok := origById[np.ChangeId]; !ok { 1927 additions[np.ChangeId] = np 1928 } 1929 } 1930 1931 // NOTE: this loop can be written in any of above blocks, 1932 // but is written separately in the interest of simpler code 1933 for _, np := range newStack { 1934 if op, ok := origById[np.ChangeId]; ok { 1935 // pull exists in both stacks 1936 // TODO: can we avoid reparse? 1937 origFiles, origHeaderStr, _ := gitdiff.Parse(strings.NewReader(op.LatestPatch())) 1938 newFiles, newHeaderStr, _ := gitdiff.Parse(strings.NewReader(np.LatestPatch())) 1939 1940 origHeader, _ := gitdiff.ParsePatchHeader(origHeaderStr) 1941 newHeader, _ := gitdiff.ParsePatchHeader(newHeaderStr) 1942 1943 patchutil.SortPatch(newFiles) 1944 patchutil.SortPatch(origFiles) 1945 1946 // text content of patch may be identical, but a jj rebase might have forwarded it 1947 // 1948 // we still need to update the hash in submission.Patch and submission.SourceRev 1949 if patchutil.Equal(newFiles, origFiles) && 1950 origHeader.Title == newHeader.Title && 1951 origHeader.Body == newHeader.Body { 1952 unchanged[op.ChangeId] = struct{}{} 1953 } else { 1954 updated[op.ChangeId] = struct{}{} 1955 } 1956 } 1957 } 1958 1959 tx, err := s.db.Begin() 1960 if err != nil { 1961 log.Println("failed to start transaction", err) 1962 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1963 return 1964 } 1965 defer tx.Rollback() 1966 1967 // pds updates to make 1968 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1969 1970 // deleted pulls are marked as deleted in the DB 1971 for _, p := range deletions { 1972 // do not do delete already merged PRs 1973 if p.State == models.PullMerged { 1974 continue 1975 } 1976 1977 err := db.DeletePull(tx, p.RepoAt, p.PullId) 1978 if err != nil { 1979 log.Println("failed to delete pull", err, p.PullId) 1980 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1981 return 1982 } 1983 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1984 RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 1985 Collection: tangled.RepoPullNSID, 1986 Rkey: p.Rkey, 1987 }, 1988 }) 1989 } 1990 1991 // new pulls are created 1992 for _, p := range additions { 1993 err := db.NewPull(tx, p) 1994 if err != nil { 1995 log.Println("failed to create pull", err, p.PullId) 1996 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1997 return 1998 } 1999 2000 record := p.AsRecord() 2001 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2002 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 2003 Collection: tangled.RepoPullNSID, 2004 Rkey: &p.Rkey, 2005 Value: &lexutil.LexiconTypeDecoder{ 2006 Val: &record, 2007 }, 2008 }, 2009 }) 2010 } 2011 2012 // updated pulls are, well, updated; to start a new round 2013 for id := range updated { 2014 op, _ := origById[id] 2015 np, _ := newById[id] 2016 2017 // do not update already merged PRs 2018 if op.State == models.PullMerged { 2019 continue 2020 } 2021 2022 submission := np.Submissions[np.LastRoundNumber()] 2023 2024 // resubmit the old pull 2025 err := db.ResubmitPull(tx, op, submission.Patch, submission.SourceRev) 2026 2027 if err != nil { 2028 log.Println("failed to update pull", err, op.PullId) 2029 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2030 return 2031 } 2032 2033 record := op.AsRecord() 2034 record.Patch = submission.Patch 2035 2036 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2037 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 2038 Collection: tangled.RepoPullNSID, 2039 Rkey: op.Rkey, 2040 Value: &lexutil.LexiconTypeDecoder{ 2041 Val: &record, 2042 }, 2043 }, 2044 }) 2045 } 2046 2047 // unchanged pulls are edited without starting a new round 2048 // 2049 // update source-revs & patches without advancing rounds 2050 for changeId := range unchanged { 2051 op, _ := origById[changeId] 2052 np, _ := newById[changeId] 2053 2054 origSubmission := op.Submissions[op.LastRoundNumber()] 2055 newSubmission := np.Submissions[np.LastRoundNumber()] 2056 2057 log.Println("moving unchanged change id : ", changeId) 2058 2059 err := db.UpdatePull( 2060 tx, 2061 newSubmission.Patch, 2062 newSubmission.SourceRev, 2063 db.FilterEq("id", origSubmission.ID), 2064 ) 2065 2066 if err != nil { 2067 log.Println("failed to update pull", err, op.PullId) 2068 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2069 return 2070 } 2071 2072 record := op.AsRecord() 2073 record.Patch = newSubmission.Patch 2074 2075 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2076 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 2077 Collection: tangled.RepoPullNSID, 2078 Rkey: op.Rkey, 2079 Value: &lexutil.LexiconTypeDecoder{ 2080 Val: &record, 2081 }, 2082 }, 2083 }) 2084 } 2085 2086 // update parent-change-id relations for the entire stack 2087 for _, p := range newStack { 2088 err := db.SetPullParentChangeId( 2089 tx, 2090 p.ParentChangeId, 2091 // these should be enough filters to be unique per-stack 2092 db.FilterEq("repo_at", p.RepoAt.String()), 2093 db.FilterEq("owner_did", p.OwnerDid), 2094 db.FilterEq("change_id", p.ChangeId), 2095 ) 2096 2097 if err != nil { 2098 log.Println("failed to update pull", err, p.PullId) 2099 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2100 return 2101 } 2102 } 2103 2104 err = tx.Commit() 2105 if err != nil { 2106 log.Println("failed to resubmit pull", err) 2107 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2108 return 2109 } 2110 2111 client, err := s.oauth.AuthorizedClient(r) 2112 if err != nil { 2113 log.Println("failed to authorize client") 2114 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2115 return 2116 } 2117 2118 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2119 Repo: user.Did, 2120 Writes: writes, 2121 }) 2122 if err != nil { 2123 log.Println("failed to create stacked pull request", err) 2124 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 2125 return 2126 } 2127 2128 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2129} 2130 2131func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 2132 f, err := s.repoResolver.Resolve(r) 2133 if err != nil { 2134 log.Println("failed to resolve repo:", err) 2135 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2136 return 2137 } 2138 2139 pull, ok := r.Context().Value("pull").(*models.Pull) 2140 if !ok { 2141 log.Println("failed to get pull") 2142 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2143 return 2144 } 2145 2146 var pullsToMerge models.Stack 2147 pullsToMerge = append(pullsToMerge, pull) 2148 if pull.IsStacked() { 2149 stack, ok := r.Context().Value("stack").(models.Stack) 2150 if !ok { 2151 log.Println("failed to get stack") 2152 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2153 return 2154 } 2155 2156 // combine patches of substack 2157 subStack := stack.StrictlyBelow(pull) 2158 // collect the portion of the stack that is mergeable 2159 mergeable := subStack.Mergeable() 2160 // add to total patch 2161 pullsToMerge = append(pullsToMerge, mergeable...) 2162 } 2163 2164 patch := pullsToMerge.CombinedPatch() 2165 2166 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 2167 if err != nil { 2168 log.Printf("resolving identity: %s", err) 2169 w.WriteHeader(http.StatusNotFound) 2170 return 2171 } 2172 2173 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 2174 if err != nil { 2175 log.Printf("failed to get primary email: %s", err) 2176 } 2177 2178 authorName := ident.Handle.String() 2179 mergeInput := &tangled.RepoMerge_Input{ 2180 Did: f.OwnerDid(), 2181 Name: f.Name, 2182 Branch: pull.TargetBranch, 2183 Patch: patch, 2184 CommitMessage: &pull.Title, 2185 AuthorName: &authorName, 2186 } 2187 2188 if pull.Body != "" { 2189 mergeInput.CommitBody = &pull.Body 2190 } 2191 2192 if email.Address != "" { 2193 mergeInput.AuthorEmail = &email.Address 2194 } 2195 2196 client, err := s.oauth.ServiceClient( 2197 r, 2198 oauth.WithService(f.Knot), 2199 oauth.WithLxm(tangled.RepoMergeNSID), 2200 oauth.WithDev(s.config.Core.Dev), 2201 ) 2202 if err != nil { 2203 log.Printf("failed to connect to knot server: %v", err) 2204 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2205 return 2206 } 2207 2208 err = tangled.RepoMerge(r.Context(), client, mergeInput) 2209 if err := xrpcclient.HandleXrpcErr(err); err != nil { 2210 s.pages.Notice(w, "pull-merge-error", err.Error()) 2211 return 2212 } 2213 2214 tx, err := s.db.Begin() 2215 if err != nil { 2216 log.Println("failed to start transcation", err) 2217 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2218 return 2219 } 2220 defer tx.Rollback() 2221 2222 for _, p := range pullsToMerge { 2223 err := db.MergePull(tx, f.RepoAt(), p.PullId) 2224 if err != nil { 2225 log.Printf("failed to update pull request status in database: %s", err) 2226 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2227 return 2228 } 2229 } 2230 2231 err = tx.Commit() 2232 if err != nil { 2233 // TODO: this is unsound, we should also revert the merge from the knotserver here 2234 log.Printf("failed to update pull request status in database: %s", err) 2235 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2236 return 2237 } 2238 2239 // notify about the pull merge 2240 for _, p := range pullsToMerge { 2241 s.notifier.NewPullMerged(r.Context(), p) 2242 } 2243 2244 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2245} 2246 2247func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { 2248 user := s.oauth.GetUser(r) 2249 2250 f, err := s.repoResolver.Resolve(r) 2251 if err != nil { 2252 log.Println("malformed middleware") 2253 return 2254 } 2255 2256 pull, ok := r.Context().Value("pull").(*models.Pull) 2257 if !ok { 2258 log.Println("failed to get pull") 2259 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2260 return 2261 } 2262 2263 // auth filter: only owner or collaborators can close 2264 roles := f.RolesInRepo(user) 2265 isOwner := roles.IsOwner() 2266 isCollaborator := roles.IsCollaborator() 2267 isPullAuthor := user.Did == pull.OwnerDid 2268 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2269 if !isCloseAllowed { 2270 log.Println("failed to close pull") 2271 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 2272 return 2273 } 2274 2275 // Start a transaction 2276 tx, err := s.db.BeginTx(r.Context(), nil) 2277 if err != nil { 2278 log.Println("failed to start transaction", err) 2279 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2280 return 2281 } 2282 defer tx.Rollback() 2283 2284 var pullsToClose []*models.Pull 2285 pullsToClose = append(pullsToClose, pull) 2286 2287 // if this PR is stacked, then we want to close all PRs below this one on the stack 2288 if pull.IsStacked() { 2289 stack := r.Context().Value("stack").(models.Stack) 2290 subStack := stack.StrictlyBelow(pull) 2291 pullsToClose = append(pullsToClose, subStack...) 2292 } 2293 2294 for _, p := range pullsToClose { 2295 // Close the pull in the database 2296 err = db.ClosePull(tx, f.RepoAt(), p.PullId) 2297 if err != nil { 2298 log.Println("failed to close pull", err) 2299 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2300 return 2301 } 2302 } 2303 2304 // Commit the transaction 2305 if err = tx.Commit(); err != nil { 2306 log.Println("failed to commit transaction", err) 2307 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2308 return 2309 } 2310 2311 for _, p := range pullsToClose { 2312 s.notifier.NewPullClosed(r.Context(), p) 2313 } 2314 2315 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2316} 2317 2318func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { 2319 user := s.oauth.GetUser(r) 2320 2321 f, err := s.repoResolver.Resolve(r) 2322 if err != nil { 2323 log.Println("failed to resolve repo", err) 2324 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2325 return 2326 } 2327 2328 pull, ok := r.Context().Value("pull").(*models.Pull) 2329 if !ok { 2330 log.Println("failed to get pull") 2331 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2332 return 2333 } 2334 2335 // auth filter: only owner or collaborators can close 2336 roles := f.RolesInRepo(user) 2337 isOwner := roles.IsOwner() 2338 isCollaborator := roles.IsCollaborator() 2339 isPullAuthor := user.Did == pull.OwnerDid 2340 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2341 if !isCloseAllowed { 2342 log.Println("failed to close pull") 2343 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 2344 return 2345 } 2346 2347 // Start a transaction 2348 tx, err := s.db.BeginTx(r.Context(), nil) 2349 if err != nil { 2350 log.Println("failed to start transaction", err) 2351 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2352 return 2353 } 2354 defer tx.Rollback() 2355 2356 var pullsToReopen []*models.Pull 2357 pullsToReopen = append(pullsToReopen, pull) 2358 2359 // if this PR is stacked, then we want to reopen all PRs above this one on the stack 2360 if pull.IsStacked() { 2361 stack := r.Context().Value("stack").(models.Stack) 2362 subStack := stack.StrictlyAbove(pull) 2363 pullsToReopen = append(pullsToReopen, subStack...) 2364 } 2365 2366 for _, p := range pullsToReopen { 2367 // Close the pull in the database 2368 err = db.ReopenPull(tx, f.RepoAt(), p.PullId) 2369 if err != nil { 2370 log.Println("failed to close pull", err) 2371 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2372 return 2373 } 2374 } 2375 2376 // Commit the transaction 2377 if err = tx.Commit(); err != nil { 2378 log.Println("failed to commit transaction", err) 2379 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2380 return 2381 } 2382 2383 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2384} 2385 2386func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2387 formatPatches, err := patchutil.ExtractPatches(patch) 2388 if err != nil { 2389 return nil, fmt.Errorf("Failed to extract patches: %v", err) 2390 } 2391 2392 // must have atleast 1 patch to begin with 2393 if len(formatPatches) == 0 { 2394 return nil, fmt.Errorf("No patches found in the generated format-patch.") 2395 } 2396 2397 // the stack is identified by a UUID 2398 var stack models.Stack 2399 parentChangeId := "" 2400 for _, fp := range formatPatches { 2401 // all patches must have a jj change-id 2402 changeId, err := fp.ChangeId() 2403 if err != nil { 2404 return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.") 2405 } 2406 2407 title := fp.Title 2408 body := fp.Body 2409 rkey := tid.TID() 2410 2411 initialSubmission := models.PullSubmission{ 2412 Patch: fp.Raw, 2413 SourceRev: fp.SHA, 2414 } 2415 pull := models.Pull{ 2416 Title: title, 2417 Body: body, 2418 TargetBranch: targetBranch, 2419 OwnerDid: user.Did, 2420 RepoAt: f.RepoAt(), 2421 Rkey: rkey, 2422 Submissions: []*models.PullSubmission{ 2423 &initialSubmission, 2424 }, 2425 PullSource: pullSource, 2426 Created: time.Now(), 2427 2428 StackId: stackId, 2429 ChangeId: changeId, 2430 ParentChangeId: parentChangeId, 2431 } 2432 2433 stack = append(stack, &pull) 2434 2435 parentChangeId = changeId 2436 } 2437 2438 return stack, nil 2439}