this repo has no description
1package pulls 2 3import ( 4 "database/sql" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "log" 9 "log/slog" 10 "net/http" 11 "slices" 12 "sort" 13 "strconv" 14 "strings" 15 "time" 16 17 "tangled.org/core/api/tangled" 18 "tangled.org/core/appview/config" 19 "tangled.org/core/appview/db" 20 "tangled.org/core/appview/models" 21 "tangled.org/core/appview/notify" 22 "tangled.org/core/appview/oauth" 23 "tangled.org/core/appview/pages" 24 "tangled.org/core/appview/pages/markup" 25 "tangled.org/core/appview/reporesolver" 26 "tangled.org/core/appview/xrpcclient" 27 "tangled.org/core/idresolver" 28 "tangled.org/core/patchutil" 29 "tangled.org/core/rbac" 30 "tangled.org/core/tid" 31 "tangled.org/core/types" 32 33 comatproto "github.com/bluesky-social/indigo/api/atproto" 34 lexutil "github.com/bluesky-social/indigo/lex/util" 35 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 36 "github.com/go-chi/chi/v5" 37 "github.com/google/uuid" 38) 39 40type Pulls struct { 41 oauth *oauth.OAuth 42 repoResolver *reporesolver.RepoResolver 43 pages *pages.Pages 44 idResolver *idresolver.Resolver 45 db *db.DB 46 config *config.Config 47 notifier notify.Notifier 48 enforcer *rbac.Enforcer 49 logger *slog.Logger 50} 51 52func New( 53 oauth *oauth.OAuth, 54 repoResolver *reporesolver.RepoResolver, 55 pages *pages.Pages, 56 resolver *idresolver.Resolver, 57 db *db.DB, 58 config *config.Config, 59 notifier notify.Notifier, 60 enforcer *rbac.Enforcer, 61 logger *slog.Logger, 62) *Pulls { 63 return &Pulls{ 64 oauth: oauth, 65 repoResolver: repoResolver, 66 pages: pages, 67 idResolver: resolver, 68 db: db, 69 config: config, 70 notifier: notifier, 71 enforcer: enforcer, 72 logger: logger, 73 } 74} 75 76// htmx fragment 77func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) { 78 switch r.Method { 79 case http.MethodGet: 80 user := s.oauth.GetUser(r) 81 f, err := s.repoResolver.Resolve(r) 82 if err != nil { 83 log.Println("failed to get repo and knot", err) 84 return 85 } 86 87 pull, ok := r.Context().Value("pull").(*models.Pull) 88 if !ok { 89 log.Println("failed to get pull") 90 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 91 return 92 } 93 94 // can be nil if this pull is not stacked 95 stack, _ := r.Context().Value("stack").(models.Stack) 96 97 roundNumberStr := chi.URLParam(r, "round") 98 roundNumber, err := strconv.Atoi(roundNumberStr) 99 if err != nil { 100 roundNumber = pull.LastRoundNumber() 101 } 102 if roundNumber >= len(pull.Submissions) { 103 http.Error(w, "bad round id", http.StatusBadRequest) 104 log.Println("failed to parse round id", err) 105 return 106 } 107 108 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 109 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 110 resubmitResult := pages.Unknown 111 if user.Did == pull.OwnerDid { 112 resubmitResult = s.resubmitCheck(r, f, pull, stack) 113 } 114 115 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 116 LoggedInUser: user, 117 RepoInfo: f.RepoInfo(user), 118 Pull: pull, 119 RoundNumber: roundNumber, 120 MergeCheck: mergeCheckResponse, 121 ResubmitCheck: resubmitResult, 122 BranchDeleteStatus: branchDeleteStatus, 123 Stack: stack, 124 }) 125 return 126 } 127} 128 129func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 130 user := s.oauth.GetUser(r) 131 f, err := s.repoResolver.Resolve(r) 132 if err != nil { 133 log.Println("failed to get repo and knot", err) 134 return 135 } 136 137 pull, ok := r.Context().Value("pull").(*models.Pull) 138 if !ok { 139 log.Println("failed to get pull") 140 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 141 return 142 } 143 144 // can be nil if this pull is not stacked 145 stack, _ := r.Context().Value("stack").(models.Stack) 146 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) 147 148 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 149 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 150 resubmitResult := pages.Unknown 151 if user != nil && user.Did == pull.OwnerDid { 152 resubmitResult = s.resubmitCheck(r, f, pull, stack) 153 } 154 155 repoInfo := f.RepoInfo(user) 156 157 m := make(map[string]models.Pipeline) 158 159 var shas []string 160 for _, s := range pull.Submissions { 161 shas = append(shas, s.SourceRev) 162 } 163 for _, p := range stack { 164 shas = append(shas, p.LatestSha()) 165 } 166 for _, p := range abandonedPulls { 167 shas = append(shas, p.LatestSha()) 168 } 169 170 ps, err := db.GetPipelineStatuses( 171 s.db, 172 db.FilterEq("repo_owner", repoInfo.OwnerDid), 173 db.FilterEq("repo_name", repoInfo.Name), 174 db.FilterEq("knot", repoInfo.Knot), 175 db.FilterIn("sha", shas), 176 ) 177 if err != nil { 178 log.Printf("failed to fetch pipeline statuses: %s", err) 179 // non-fatal 180 } 181 182 for _, p := range ps { 183 m[p.Sha] = p 184 } 185 186 reactionMap, err := db.GetReactionMap(s.db, 20, pull.PullAt()) 187 if err != nil { 188 log.Println("failed to get pull reactions") 189 s.pages.Notice(w, "pulls", "Failed to load pull. Try again later.") 190 } 191 192 userReactions := map[models.ReactionKind]bool{} 193 if user != nil { 194 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.PullAt()) 195 } 196 197 labelDefs, err := db.GetLabelDefinitions( 198 s.db, 199 db.FilterIn("at_uri", f.Repo.Labels), 200 db.FilterContains("scope", tangled.RepoPullNSID), 201 ) 202 if err != nil { 203 log.Println("failed to fetch labels", err) 204 s.pages.Error503(w) 205 return 206 } 207 208 defs := make(map[string]*models.LabelDefinition) 209 for _, l := range labelDefs { 210 defs[l.AtUri().String()] = &l 211 } 212 213 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 214 LoggedInUser: user, 215 RepoInfo: repoInfo, 216 Pull: pull, 217 Stack: stack, 218 AbandonedPulls: abandonedPulls, 219 BranchDeleteStatus: branchDeleteStatus, 220 MergeCheck: mergeCheckResponse, 221 ResubmitCheck: resubmitResult, 222 Pipelines: m, 223 224 OrderedReactionKinds: models.OrderedReactionKinds, 225 Reactions: reactionMap, 226 UserReacted: userReactions, 227 228 LabelDefs: defs, 229 }) 230} 231 232func (s *Pulls) mergeCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 233 if pull.State == models.PullMerged { 234 return types.MergeCheckResponse{} 235 } 236 237 scheme := "https" 238 if s.config.Core.Dev { 239 scheme = "http" 240 } 241 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 242 243 xrpcc := indigoxrpc.Client{ 244 Host: host, 245 } 246 247 patch := pull.LatestPatch() 248 if pull.IsStacked() { 249 // combine patches of substack 250 subStack := stack.Below(pull) 251 // collect the portion of the stack that is mergeable 252 mergeable := subStack.Mergeable() 253 // combine each patch 254 patch = mergeable.CombinedPatch() 255 } 256 257 resp, xe := tangled.RepoMergeCheck( 258 r.Context(), 259 &xrpcc, 260 &tangled.RepoMergeCheck_Input{ 261 Did: f.OwnerDid(), 262 Name: f.Name, 263 Branch: pull.TargetBranch, 264 Patch: patch, 265 }, 266 ) 267 if err := xrpcclient.HandleXrpcErr(xe); err != nil { 268 log.Println("failed to check for mergeability", "err", err) 269 return types.MergeCheckResponse{ 270 Error: fmt.Sprintf("failed to check merge status: %s", err.Error()), 271 } 272 } 273 274 // convert xrpc response to internal types 275 conflicts := make([]types.ConflictInfo, len(resp.Conflicts)) 276 for i, conflict := range resp.Conflicts { 277 conflicts[i] = types.ConflictInfo{ 278 Filename: conflict.Filename, 279 Reason: conflict.Reason, 280 } 281 } 282 283 result := types.MergeCheckResponse{ 284 IsConflicted: resp.Is_conflicted, 285 Conflicts: conflicts, 286 } 287 288 if resp.Message != nil { 289 result.Message = *resp.Message 290 } 291 292 if resp.Error != nil { 293 result.Error = *resp.Error 294 } 295 296 return result 297} 298 299func (s *Pulls) branchDeleteStatus(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull) *models.BranchDeleteStatus { 300 if pull.State != models.PullMerged { 301 return nil 302 } 303 304 user := s.oauth.GetUser(r) 305 if user == nil { 306 return nil 307 } 308 309 var branch string 310 var repo *models.Repo 311 // check if the branch exists 312 // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 313 if pull.IsBranchBased() { 314 branch = pull.PullSource.Branch 315 repo = &f.Repo 316 } else if pull.IsForkBased() { 317 branch = pull.PullSource.Branch 318 repo = pull.PullSource.Repo 319 } else { 320 return nil 321 } 322 323 // deleted fork 324 if repo == nil { 325 return nil 326 } 327 328 // user can only delete branch if they are a collaborator in the repo that the branch belongs to 329 perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.DidSlashRepo()) 330 if !slices.Contains(perms, "repo:push") { 331 return nil 332 } 333 334 scheme := "http" 335 if !s.config.Core.Dev { 336 scheme = "https" 337 } 338 host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 339 xrpcc := &indigoxrpc.Client{ 340 Host: host, 341 } 342 343 resp, err := tangled.RepoBranch(r.Context(), xrpcc, branch, fmt.Sprintf("%s/%s", repo.Did, repo.Name)) 344 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 345 return nil 346 } 347 348 return &models.BranchDeleteStatus{ 349 Repo: repo, 350 Branch: resp.Name, 351 } 352} 353 354func (s *Pulls) resubmitCheck(r *http.Request, f *reporesolver.ResolvedRepo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 355 if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 356 return pages.Unknown 357 } 358 359 var knot, ownerDid, repoName string 360 361 if pull.PullSource.RepoAt != nil { 362 // fork-based pulls 363 sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 364 if err != nil { 365 log.Println("failed to get source repo", err) 366 return pages.Unknown 367 } 368 369 knot = sourceRepo.Knot 370 ownerDid = sourceRepo.Did 371 repoName = sourceRepo.Name 372 } else { 373 // pulls within the same repo 374 knot = f.Knot 375 ownerDid = f.OwnerDid() 376 repoName = f.Name 377 } 378 379 scheme := "http" 380 if !s.config.Core.Dev { 381 scheme = "https" 382 } 383 host := fmt.Sprintf("%s://%s", scheme, knot) 384 xrpcc := &indigoxrpc.Client{ 385 Host: host, 386 } 387 388 repo := fmt.Sprintf("%s/%s", ownerDid, repoName) 389 branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, repo) 390 if err != nil { 391 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 392 log.Println("failed to call XRPC repo.branches", xrpcerr) 393 return pages.Unknown 394 } 395 log.Println("failed to reach knotserver", err) 396 return pages.Unknown 397 } 398 399 targetBranch := branchResp 400 401 latestSourceRev := pull.LatestSha() 402 403 if pull.IsStacked() && stack != nil { 404 top := stack[0] 405 latestSourceRev = top.LatestSha() 406 } 407 408 if latestSourceRev != targetBranch.Hash { 409 return pages.ShouldResubmit 410 } 411 412 return pages.ShouldNotResubmit 413} 414 415func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 416 user := s.oauth.GetUser(r) 417 f, err := s.repoResolver.Resolve(r) 418 if err != nil { 419 log.Println("failed to get repo and knot", err) 420 return 421 } 422 423 var diffOpts types.DiffOpts 424 if d := r.URL.Query().Get("diff"); d == "split" { 425 diffOpts.Split = true 426 } 427 428 pull, ok := r.Context().Value("pull").(*models.Pull) 429 if !ok { 430 log.Println("failed to get pull") 431 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 432 return 433 } 434 435 stack, _ := r.Context().Value("stack").(models.Stack) 436 437 roundId := chi.URLParam(r, "round") 438 roundIdInt, err := strconv.Atoi(roundId) 439 if err != nil || roundIdInt >= len(pull.Submissions) { 440 http.Error(w, "bad round id", http.StatusBadRequest) 441 log.Println("failed to parse round id", err) 442 return 443 } 444 445 patch := pull.Submissions[roundIdInt].CombinedPatch() 446 diff := patchutil.AsNiceDiff(patch, pull.TargetBranch) 447 448 s.pages.RepoPullPatchPage(w, pages.RepoPullPatchParams{ 449 LoggedInUser: user, 450 RepoInfo: f.RepoInfo(user), 451 Pull: pull, 452 Stack: stack, 453 Round: roundIdInt, 454 Submission: pull.Submissions[roundIdInt], 455 Diff: &diff, 456 DiffOpts: diffOpts, 457 }) 458 459} 460 461func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 462 user := s.oauth.GetUser(r) 463 464 f, err := s.repoResolver.Resolve(r) 465 if err != nil { 466 log.Println("failed to get repo and knot", err) 467 return 468 } 469 470 var diffOpts types.DiffOpts 471 if d := r.URL.Query().Get("diff"); d == "split" { 472 diffOpts.Split = true 473 } 474 475 pull, ok := r.Context().Value("pull").(*models.Pull) 476 if !ok { 477 log.Println("failed to get pull") 478 s.pages.Notice(w, "pull-error", "Failed to get pull.") 479 return 480 } 481 482 roundId := chi.URLParam(r, "round") 483 roundIdInt, err := strconv.Atoi(roundId) 484 if err != nil || roundIdInt >= len(pull.Submissions) { 485 http.Error(w, "bad round id", http.StatusBadRequest) 486 log.Println("failed to parse round id", err) 487 return 488 } 489 490 if roundIdInt == 0 { 491 http.Error(w, "bad round id", http.StatusBadRequest) 492 log.Println("cannot interdiff initial submission") 493 return 494 } 495 496 currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 497 if err != nil { 498 log.Println("failed to interdiff; current patch malformed") 499 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 500 return 501 } 502 503 previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 504 if err != nil { 505 log.Println("failed to interdiff; previous patch malformed") 506 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 507 return 508 } 509 510 interdiff := patchutil.Interdiff(previousPatch, currentPatch) 511 512 s.pages.RepoPullInterdiffPage(w, pages.RepoPullInterdiffParams{ 513 LoggedInUser: s.oauth.GetUser(r), 514 RepoInfo: f.RepoInfo(user), 515 Pull: pull, 516 Round: roundIdInt, 517 Interdiff: interdiff, 518 DiffOpts: diffOpts, 519 }) 520} 521 522func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 523 pull, ok := r.Context().Value("pull").(*models.Pull) 524 if !ok { 525 log.Println("failed to get pull") 526 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 527 return 528 } 529 530 roundId := chi.URLParam(r, "round") 531 roundIdInt, err := strconv.Atoi(roundId) 532 if err != nil || roundIdInt >= len(pull.Submissions) { 533 http.Error(w, "bad round id", http.StatusBadRequest) 534 log.Println("failed to parse round id", err) 535 return 536 } 537 538 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 539 w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 540} 541 542func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 543 user := s.oauth.GetUser(r) 544 params := r.URL.Query() 545 546 state := models.PullOpen 547 switch params.Get("state") { 548 case "closed": 549 state = models.PullClosed 550 case "merged": 551 state = models.PullMerged 552 } 553 554 f, err := s.repoResolver.Resolve(r) 555 if err != nil { 556 log.Println("failed to get repo and knot", err) 557 return 558 } 559 560 pulls, err := db.GetPulls( 561 s.db, 562 db.FilterEq("repo_at", f.RepoAt()), 563 db.FilterEq("state", state), 564 ) 565 if err != nil { 566 log.Println("failed to get pulls", err) 567 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 568 return 569 } 570 571 for _, p := range pulls { 572 var pullSourceRepo *models.Repo 573 if p.PullSource != nil { 574 if p.PullSource.RepoAt != nil { 575 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) 576 if err != nil { 577 log.Printf("failed to get repo by at uri: %v", err) 578 continue 579 } else { 580 p.PullSource.Repo = pullSourceRepo 581 } 582 } 583 } 584 } 585 586 // we want to group all stacked PRs into just one list 587 stacks := make(map[string]models.Stack) 588 var shas []string 589 n := 0 590 for _, p := range pulls { 591 // store the sha for later 592 shas = append(shas, p.LatestSha()) 593 // this PR is stacked 594 if p.StackId != "" { 595 // we have already seen this PR stack 596 if _, seen := stacks[p.StackId]; seen { 597 stacks[p.StackId] = append(stacks[p.StackId], p) 598 // skip this PR 599 } else { 600 stacks[p.StackId] = nil 601 pulls[n] = p 602 n++ 603 } 604 } else { 605 pulls[n] = p 606 n++ 607 } 608 } 609 pulls = pulls[:n] 610 611 repoInfo := f.RepoInfo(user) 612 ps, err := db.GetPipelineStatuses( 613 s.db, 614 db.FilterEq("repo_owner", repoInfo.OwnerDid), 615 db.FilterEq("repo_name", repoInfo.Name), 616 db.FilterEq("knot", repoInfo.Knot), 617 db.FilterIn("sha", shas), 618 ) 619 if err != nil { 620 log.Printf("failed to fetch pipeline statuses: %s", err) 621 // non-fatal 622 } 623 m := make(map[string]models.Pipeline) 624 for _, p := range ps { 625 m[p.Sha] = p 626 } 627 628 labelDefs, err := db.GetLabelDefinitions( 629 s.db, 630 db.FilterIn("at_uri", f.Repo.Labels), 631 db.FilterContains("scope", tangled.RepoPullNSID), 632 ) 633 if err != nil { 634 log.Println("failed to fetch labels", err) 635 s.pages.Error503(w) 636 return 637 } 638 639 defs := make(map[string]*models.LabelDefinition) 640 for _, l := range labelDefs { 641 defs[l.AtUri().String()] = &l 642 } 643 644 s.pages.RepoPulls(w, pages.RepoPullsParams{ 645 LoggedInUser: s.oauth.GetUser(r), 646 RepoInfo: f.RepoInfo(user), 647 Pulls: pulls, 648 LabelDefs: defs, 649 FilteringBy: state, 650 Stacks: stacks, 651 Pipelines: m, 652 }) 653} 654 655func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 656 user := s.oauth.GetUser(r) 657 f, err := s.repoResolver.Resolve(r) 658 if err != nil { 659 log.Println("failed to get repo and knot", err) 660 return 661 } 662 663 pull, ok := r.Context().Value("pull").(*models.Pull) 664 if !ok { 665 log.Println("failed to get pull") 666 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 667 return 668 } 669 670 roundNumberStr := chi.URLParam(r, "round") 671 roundNumber, err := strconv.Atoi(roundNumberStr) 672 if err != nil || roundNumber >= len(pull.Submissions) { 673 http.Error(w, "bad round id", http.StatusBadRequest) 674 log.Println("failed to parse round id", err) 675 return 676 } 677 678 switch r.Method { 679 case http.MethodGet: 680 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ 681 LoggedInUser: user, 682 RepoInfo: f.RepoInfo(user), 683 Pull: pull, 684 RoundNumber: roundNumber, 685 }) 686 return 687 case http.MethodPost: 688 body := r.FormValue("body") 689 if body == "" { 690 s.pages.Notice(w, "pull", "Comment body is required") 691 return 692 } 693 694 // Start a transaction 695 tx, err := s.db.BeginTx(r.Context(), nil) 696 if err != nil { 697 log.Println("failed to start transaction", err) 698 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 699 return 700 } 701 defer tx.Rollback() 702 703 createdAt := time.Now().Format(time.RFC3339) 704 705 client, err := s.oauth.AuthorizedClient(r) 706 if err != nil { 707 log.Println("failed to get authorized client", err) 708 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 709 return 710 } 711 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 712 Collection: tangled.RepoPullCommentNSID, 713 Repo: user.Did, 714 Rkey: tid.TID(), 715 Record: &lexutil.LexiconTypeDecoder{ 716 Val: &tangled.RepoPullComment{ 717 Pull: pull.PullAt().String(), 718 Body: body, 719 CreatedAt: createdAt, 720 }, 721 }, 722 }) 723 if err != nil { 724 log.Println("failed to create pull comment", err) 725 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 726 return 727 } 728 729 comment := &models.PullComment{ 730 OwnerDid: user.Did, 731 RepoAt: f.RepoAt().String(), 732 PullId: pull.PullId, 733 Body: body, 734 CommentAt: atResp.Uri, 735 SubmissionId: pull.Submissions[roundNumber].ID, 736 } 737 738 // Create the pull comment in the database with the commentAt field 739 commentId, err := db.NewPullComment(tx, comment) 740 if err != nil { 741 log.Println("failed to create pull comment", err) 742 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 743 return 744 } 745 746 // Commit the transaction 747 if err = tx.Commit(); err != nil { 748 log.Println("failed to commit transaction", err) 749 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 750 return 751 } 752 753 s.notifier.NewPullComment(r.Context(), comment) 754 755 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", f.OwnerSlashRepo(), pull.PullId, commentId)) 756 return 757 } 758} 759 760func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) { 761 user := s.oauth.GetUser(r) 762 f, err := s.repoResolver.Resolve(r) 763 if err != nil { 764 log.Println("failed to get repo and knot", err) 765 return 766 } 767 768 switch r.Method { 769 case http.MethodGet: 770 scheme := "http" 771 if !s.config.Core.Dev { 772 scheme = "https" 773 } 774 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 775 xrpcc := &indigoxrpc.Client{ 776 Host: host, 777 } 778 779 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 780 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 781 if err != nil { 782 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 783 log.Println("failed to call XRPC repo.branches", xrpcerr) 784 s.pages.Error503(w) 785 return 786 } 787 log.Println("failed to fetch branches", err) 788 return 789 } 790 791 var result types.RepoBranchesResponse 792 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 793 log.Println("failed to decode XRPC response", err) 794 s.pages.Error503(w) 795 return 796 } 797 798 // can be one of "patch", "branch" or "fork" 799 strategy := r.URL.Query().Get("strategy") 800 // ignored if strategy is "patch" 801 sourceBranch := r.URL.Query().Get("sourceBranch") 802 targetBranch := r.URL.Query().Get("targetBranch") 803 804 s.pages.RepoNewPull(w, pages.RepoNewPullParams{ 805 LoggedInUser: user, 806 RepoInfo: f.RepoInfo(user), 807 Branches: result.Branches, 808 Strategy: strategy, 809 SourceBranch: sourceBranch, 810 TargetBranch: targetBranch, 811 Title: r.URL.Query().Get("title"), 812 Body: r.URL.Query().Get("body"), 813 }) 814 815 case http.MethodPost: 816 title := r.FormValue("title") 817 body := r.FormValue("body") 818 targetBranch := r.FormValue("targetBranch") 819 fromFork := r.FormValue("fork") 820 sourceBranch := r.FormValue("sourceBranch") 821 patch := r.FormValue("patch") 822 823 if targetBranch == "" { 824 s.pages.Notice(w, "pull", "Target branch is required.") 825 return 826 } 827 828 // Determine PR type based on input parameters 829 isPushAllowed := f.RepoInfo(user).Roles.IsPushAllowed() 830 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 831 isForkBased := fromFork != "" && sourceBranch != "" 832 isPatchBased := patch != "" && !isBranchBased && !isForkBased 833 isStacked := r.FormValue("isStacked") == "on" 834 835 if isPatchBased && !patchutil.IsFormatPatch(patch) { 836 if title == "" { 837 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 838 return 839 } 840 sanitizer := markup.NewSanitizer() 841 if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 842 s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 843 return 844 } 845 } 846 847 // Validate we have at least one valid PR creation method 848 if !isBranchBased && !isPatchBased && !isForkBased { 849 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 850 return 851 } 852 853 // Can't mix branch-based and patch-based approaches 854 if isBranchBased && patch != "" { 855 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 856 return 857 } 858 859 // us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 860 // if err != nil { 861 // log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 862 // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 863 // return 864 // } 865 866 // TODO: make capabilities an xrpc call 867 caps := struct { 868 PullRequests struct { 869 FormatPatch bool 870 BranchSubmissions bool 871 ForkSubmissions bool 872 PatchSubmissions bool 873 } 874 }{ 875 PullRequests: struct { 876 FormatPatch bool 877 BranchSubmissions bool 878 ForkSubmissions bool 879 PatchSubmissions bool 880 }{ 881 FormatPatch: true, 882 BranchSubmissions: true, 883 ForkSubmissions: true, 884 PatchSubmissions: true, 885 }, 886 } 887 888 // caps, err := us.Capabilities() 889 // if err != nil { 890 // log.Println("error fetching knot caps", f.Knot, err) 891 // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 892 // return 893 // } 894 895 if !caps.PullRequests.FormatPatch { 896 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") 897 return 898 } 899 900 // Handle the PR creation based on the type 901 if isBranchBased { 902 if !caps.PullRequests.BranchSubmissions { 903 s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") 904 return 905 } 906 s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch, isStacked) 907 } else if isForkBased { 908 if !caps.PullRequests.ForkSubmissions { 909 s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") 910 return 911 } 912 s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch, isStacked) 913 } else if isPatchBased { 914 if !caps.PullRequests.PatchSubmissions { 915 s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") 916 return 917 } 918 s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch, isStacked) 919 } 920 return 921 } 922} 923 924func (s *Pulls) handleBranchBasedPull( 925 w http.ResponseWriter, 926 r *http.Request, 927 f *reporesolver.ResolvedRepo, 928 user *oauth.User, 929 title, 930 body, 931 targetBranch, 932 sourceBranch string, 933 isStacked bool, 934) { 935 scheme := "http" 936 if !s.config.Core.Dev { 937 scheme = "https" 938 } 939 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 940 xrpcc := &indigoxrpc.Client{ 941 Host: host, 942 } 943 944 repo := fmt.Sprintf("%s/%s", f.OwnerDid(), f.Name) 945 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, targetBranch, sourceBranch) 946 if err != nil { 947 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 948 log.Println("failed to call XRPC repo.compare", xrpcerr) 949 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 950 return 951 } 952 log.Println("failed to compare", err) 953 s.pages.Notice(w, "pull", err.Error()) 954 return 955 } 956 957 var comparison types.RepoFormatPatchResponse 958 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 959 log.Println("failed to decode XRPC compare response", err) 960 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 961 return 962 } 963 964 sourceRev := comparison.Rev2 965 patch := comparison.FormatPatchRaw 966 combined := comparison.CombinedPatchRaw 967 968 if !patchutil.IsPatchValid(patch) { 969 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 970 return 971 } 972 973 pullSource := &models.PullSource{ 974 Branch: sourceBranch, 975 } 976 recordPullSource := &tangled.RepoPull_Source{ 977 Branch: sourceBranch, 978 Sha: comparison.Rev2, 979 } 980 981 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 982} 983 984func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 985 if !patchutil.IsPatchValid(patch) { 986 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 987 return 988 } 989 990 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 991} 992 993func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 994 repoString := strings.SplitN(forkRepo, "/", 2) 995 forkOwnerDid := repoString[0] 996 repoName := repoString[1] 997 fork, err := db.GetForkByDid(s.db, forkOwnerDid, repoName) 998 if errors.Is(err, sql.ErrNoRows) { 999 s.pages.Notice(w, "pull", "No such fork.") 1000 return 1001 } else if err != nil { 1002 log.Println("failed to fetch fork:", err) 1003 s.pages.Notice(w, "pull", "Failed to fetch fork.") 1004 return 1005 } 1006 1007 client, err := s.oauth.ServiceClient( 1008 r, 1009 oauth.WithService(fork.Knot), 1010 oauth.WithLxm(tangled.RepoHiddenRefNSID), 1011 oauth.WithDev(s.config.Core.Dev), 1012 ) 1013 1014 resp, err := tangled.RepoHiddenRef( 1015 r.Context(), 1016 client, 1017 &tangled.RepoHiddenRef_Input{ 1018 ForkRef: sourceBranch, 1019 RemoteRef: targetBranch, 1020 Repo: fork.RepoAt().String(), 1021 }, 1022 ) 1023 if err := xrpcclient.HandleXrpcErr(err); err != nil { 1024 s.pages.Notice(w, "pull", err.Error()) 1025 return 1026 } 1027 1028 if !resp.Success { 1029 errorMsg := "Failed to create pull request" 1030 if resp.Error != nil { 1031 errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error) 1032 } 1033 s.pages.Notice(w, "pull", errorMsg) 1034 return 1035 } 1036 1037 hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 1038 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking 1039 // the targetBranch on the target repository. This code is a bit confusing, but here's an example: 1040 // hiddenRef: hidden/feature-1/main (on repo-fork) 1041 // targetBranch: main (on repo-1) 1042 // sourceBranch: feature-1 (on repo-fork) 1043 forkScheme := "http" 1044 if !s.config.Core.Dev { 1045 forkScheme = "https" 1046 } 1047 forkHost := fmt.Sprintf("%s://%s", forkScheme, fork.Knot) 1048 forkXrpcc := &indigoxrpc.Client{ 1049 Host: forkHost, 1050 } 1051 1052 forkRepoId := fmt.Sprintf("%s/%s", fork.Did, fork.Name) 1053 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, forkRepoId, hiddenRef, sourceBranch) 1054 if err != nil { 1055 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1056 log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1057 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1058 return 1059 } 1060 log.Println("failed to compare across branches", err) 1061 s.pages.Notice(w, "pull", err.Error()) 1062 return 1063 } 1064 1065 var comparison types.RepoFormatPatchResponse 1066 if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 1067 log.Println("failed to decode XRPC compare response for fork", err) 1068 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1069 return 1070 } 1071 1072 sourceRev := comparison.Rev2 1073 patch := comparison.FormatPatchRaw 1074 combined := comparison.CombinedPatchRaw 1075 1076 if !patchutil.IsPatchValid(patch) { 1077 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1078 return 1079 } 1080 1081 forkAtUri := fork.RepoAt() 1082 forkAtUriStr := forkAtUri.String() 1083 1084 pullSource := &models.PullSource{ 1085 Branch: sourceBranch, 1086 RepoAt: &forkAtUri, 1087 } 1088 recordPullSource := &tangled.RepoPull_Source{ 1089 Branch: sourceBranch, 1090 Repo: &forkAtUriStr, 1091 Sha: sourceRev, 1092 } 1093 1094 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1095} 1096 1097func (s *Pulls) createPullRequest( 1098 w http.ResponseWriter, 1099 r *http.Request, 1100 f *reporesolver.ResolvedRepo, 1101 user *oauth.User, 1102 title, body, targetBranch string, 1103 patch string, 1104 combined string, 1105 sourceRev string, 1106 pullSource *models.PullSource, 1107 recordPullSource *tangled.RepoPull_Source, 1108 isStacked bool, 1109) { 1110 if isStacked { 1111 // creates a series of PRs, each linking to the previous, identified by jj's change-id 1112 s.createStackedPullRequest( 1113 w, 1114 r, 1115 f, 1116 user, 1117 targetBranch, 1118 patch, 1119 sourceRev, 1120 pullSource, 1121 ) 1122 return 1123 } 1124 1125 client, err := s.oauth.AuthorizedClient(r) 1126 if err != nil { 1127 log.Println("failed to get authorized client", err) 1128 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1129 return 1130 } 1131 1132 tx, err := s.db.BeginTx(r.Context(), nil) 1133 if err != nil { 1134 log.Println("failed to start tx") 1135 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1136 return 1137 } 1138 defer tx.Rollback() 1139 1140 // We've already checked earlier if it's diff-based and title is empty, 1141 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1142 if title == "" || body == "" { 1143 formatPatches, err := patchutil.ExtractPatches(patch) 1144 if err != nil { 1145 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 1146 return 1147 } 1148 if len(formatPatches) == 0 { 1149 s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.") 1150 return 1151 } 1152 1153 if title == "" { 1154 title = formatPatches[0].Title 1155 } 1156 if body == "" { 1157 body = formatPatches[0].Body 1158 } 1159 } 1160 1161 rkey := tid.TID() 1162 initialSubmission := models.PullSubmission{ 1163 Patch: patch, 1164 Combined: combined, 1165 SourceRev: sourceRev, 1166 } 1167 pull := &models.Pull{ 1168 Title: title, 1169 Body: body, 1170 TargetBranch: targetBranch, 1171 OwnerDid: user.Did, 1172 RepoAt: f.RepoAt(), 1173 Rkey: rkey, 1174 Submissions: []*models.PullSubmission{ 1175 &initialSubmission, 1176 }, 1177 PullSource: pullSource, 1178 } 1179 err = db.NewPull(tx, pull) 1180 if err != nil { 1181 log.Println("failed to create pull request", err) 1182 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1183 return 1184 } 1185 pullId, err := db.NextPullId(tx, f.RepoAt()) 1186 if err != nil { 1187 log.Println("failed to get pull id", err) 1188 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1189 return 1190 } 1191 1192 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1193 Collection: tangled.RepoPullNSID, 1194 Repo: user.Did, 1195 Rkey: rkey, 1196 Record: &lexutil.LexiconTypeDecoder{ 1197 Val: &tangled.RepoPull{ 1198 Title: title, 1199 Target: &tangled.RepoPull_Target{ 1200 Repo: string(f.RepoAt()), 1201 Branch: targetBranch, 1202 }, 1203 Patch: patch, 1204 Source: recordPullSource, 1205 CreatedAt: time.Now().Format(time.RFC3339), 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.FormatPatchRaw 1656 combined := comparison.CombinedPatchRaw 1657 1658 s.resubmitPullHelper(w, r, f, user, pull, patch, combined, 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.FormatPatchRaw 1752 combined := comparison.CombinedPatchRaw 1753 1754 s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1755} 1756 1757// validate a resubmission against a pull request 1758func validateResubmittedPatch(pull *models.Pull, patch string) error { 1759 if patch == "" { 1760 return fmt.Errorf("Patch is empty.") 1761 } 1762 1763 if patch == pull.LatestPatch() { 1764 return fmt.Errorf("Patch is identical to previous submission.") 1765 } 1766 1767 if !patchutil.IsPatchValid(patch) { 1768 return fmt.Errorf("Invalid patch format. Please provide a valid diff.") 1769 } 1770 1771 return nil 1772} 1773 1774func (s *Pulls) resubmitPullHelper( 1775 w http.ResponseWriter, 1776 r *http.Request, 1777 f *reporesolver.ResolvedRepo, 1778 user *oauth.User, 1779 pull *models.Pull, 1780 patch string, 1781 combined string, 1782 sourceRev string, 1783) { 1784 if pull.IsStacked() { 1785 log.Println("resubmitting stacked PR") 1786 s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId) 1787 return 1788 } 1789 1790 if err := validateResubmittedPatch(pull, patch); err != nil { 1791 s.pages.Notice(w, "resubmit-error", err.Error()) 1792 return 1793 } 1794 1795 // validate sourceRev if branch/fork based 1796 if pull.IsBranchBased() || pull.IsForkBased() { 1797 if sourceRev == pull.LatestSha() { 1798 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1799 return 1800 } 1801 } 1802 1803 tx, err := s.db.BeginTx(r.Context(), nil) 1804 if err != nil { 1805 log.Println("failed to start tx") 1806 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1807 return 1808 } 1809 defer tx.Rollback() 1810 1811 pullAt := pull.PullAt() 1812 newRoundNumber := len(pull.Submissions) 1813 newPatch := patch 1814 newSourceRev := sourceRev 1815 combinedPatch := combined 1816 err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 1817 if err != nil { 1818 log.Println("failed to create pull request", err) 1819 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1820 return 1821 } 1822 client, err := s.oauth.AuthorizedClient(r) 1823 if err != nil { 1824 log.Println("failed to authorize client") 1825 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1826 return 1827 } 1828 1829 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1830 if err != nil { 1831 // failed to get record 1832 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1833 return 1834 } 1835 1836 var recordPullSource *tangled.RepoPull_Source 1837 if pull.IsBranchBased() { 1838 recordPullSource = &tangled.RepoPull_Source{ 1839 Branch: pull.PullSource.Branch, 1840 Sha: sourceRev, 1841 } 1842 } 1843 if pull.IsForkBased() { 1844 repoAt := pull.PullSource.RepoAt.String() 1845 recordPullSource = &tangled.RepoPull_Source{ 1846 Branch: pull.PullSource.Branch, 1847 Repo: &repoAt, 1848 Sha: sourceRev, 1849 } 1850 } 1851 1852 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1853 Collection: tangled.RepoPullNSID, 1854 Repo: user.Did, 1855 Rkey: pull.Rkey, 1856 SwapRecord: ex.Cid, 1857 Record: &lexutil.LexiconTypeDecoder{ 1858 Val: &tangled.RepoPull{ 1859 Title: pull.Title, 1860 Target: &tangled.RepoPull_Target{ 1861 Repo: string(f.RepoAt()), 1862 Branch: pull.TargetBranch, 1863 }, 1864 Patch: patch, // new patch 1865 Source: recordPullSource, 1866 CreatedAt: time.Now().Format(time.RFC3339), 1867 }, 1868 }, 1869 }) 1870 if err != nil { 1871 log.Println("failed to update record", err) 1872 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1873 return 1874 } 1875 1876 if err = tx.Commit(); err != nil { 1877 log.Println("failed to commit transaction", err) 1878 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1879 return 1880 } 1881 1882 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1883} 1884 1885func (s *Pulls) resubmitStackedPullHelper( 1886 w http.ResponseWriter, 1887 r *http.Request, 1888 f *reporesolver.ResolvedRepo, 1889 user *oauth.User, 1890 pull *models.Pull, 1891 patch string, 1892 stackId string, 1893) { 1894 targetBranch := pull.TargetBranch 1895 1896 origStack, _ := r.Context().Value("stack").(models.Stack) 1897 newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId) 1898 if err != nil { 1899 log.Println("failed to create resubmitted stack", err) 1900 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1901 return 1902 } 1903 1904 // find the diff between the stacks, first, map them by changeId 1905 origById := make(map[string]*models.Pull) 1906 newById := make(map[string]*models.Pull) 1907 for _, p := range origStack { 1908 origById[p.ChangeId] = p 1909 } 1910 for _, p := range newStack { 1911 newById[p.ChangeId] = p 1912 } 1913 1914 // commits that got deleted: corresponding pull is closed 1915 // commits that got added: new pull is created 1916 // commits that got updated: corresponding pull is resubmitted & new round begins 1917 additions := make(map[string]*models.Pull) 1918 deletions := make(map[string]*models.Pull) 1919 updated := make(map[string]struct{}) 1920 1921 // pulls in orignal stack but not in new one 1922 for _, op := range origStack { 1923 if _, ok := newById[op.ChangeId]; !ok { 1924 deletions[op.ChangeId] = op 1925 } 1926 } 1927 1928 // pulls in new stack but not in original one 1929 for _, np := range newStack { 1930 if _, ok := origById[np.ChangeId]; !ok { 1931 additions[np.ChangeId] = np 1932 } 1933 } 1934 1935 // NOTE: this loop can be written in any of above blocks, 1936 // but is written separately in the interest of simpler code 1937 for _, np := range newStack { 1938 if op, ok := origById[np.ChangeId]; ok { 1939 // pull exists in both stacks 1940 updated[op.ChangeId] = struct{}{} 1941 } 1942 } 1943 1944 tx, err := s.db.Begin() 1945 if err != nil { 1946 log.Println("failed to start transaction", err) 1947 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1948 return 1949 } 1950 defer tx.Rollback() 1951 1952 // pds updates to make 1953 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1954 1955 // deleted pulls are marked as deleted in the DB 1956 for _, p := range deletions { 1957 // do not do delete already merged PRs 1958 if p.State == models.PullMerged { 1959 continue 1960 } 1961 1962 err := db.DeletePull(tx, p.RepoAt, p.PullId) 1963 if err != nil { 1964 log.Println("failed to delete pull", err, p.PullId) 1965 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1966 return 1967 } 1968 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1969 RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 1970 Collection: tangled.RepoPullNSID, 1971 Rkey: p.Rkey, 1972 }, 1973 }) 1974 } 1975 1976 // new pulls are created 1977 for _, p := range additions { 1978 err := db.NewPull(tx, p) 1979 if err != nil { 1980 log.Println("failed to create pull", err, p.PullId) 1981 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1982 return 1983 } 1984 1985 record := p.AsRecord() 1986 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1987 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1988 Collection: tangled.RepoPullNSID, 1989 Rkey: &p.Rkey, 1990 Value: &lexutil.LexiconTypeDecoder{ 1991 Val: &record, 1992 }, 1993 }, 1994 }) 1995 } 1996 1997 // updated pulls are, well, updated; to start a new round 1998 for id := range updated { 1999 op, _ := origById[id] 2000 np, _ := newById[id] 2001 2002 // do not update already merged PRs 2003 if op.State == models.PullMerged { 2004 continue 2005 } 2006 2007 // resubmit the new pull 2008 pullAt := op.PullAt() 2009 newRoundNumber := len(op.Submissions) 2010 newPatch := np.LatestPatch() 2011 combinedPatch := np.LatestSubmission().Combined 2012 newSourceRev := np.LatestSha() 2013 err := db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 2014 if err != nil { 2015 log.Println("failed to update pull", err, op.PullId) 2016 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2017 return 2018 } 2019 2020 record := np.AsRecord() 2021 2022 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2023 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 2024 Collection: tangled.RepoPullNSID, 2025 Rkey: op.Rkey, 2026 Value: &lexutil.LexiconTypeDecoder{ 2027 Val: &record, 2028 }, 2029 }, 2030 }) 2031 } 2032 2033 // update parent-change-id relations for the entire stack 2034 for _, p := range newStack { 2035 err := db.SetPullParentChangeId( 2036 tx, 2037 p.ParentChangeId, 2038 // these should be enough filters to be unique per-stack 2039 db.FilterEq("repo_at", p.RepoAt.String()), 2040 db.FilterEq("owner_did", p.OwnerDid), 2041 db.FilterEq("change_id", p.ChangeId), 2042 ) 2043 2044 if err != nil { 2045 log.Println("failed to update pull", err, p.PullId) 2046 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2047 return 2048 } 2049 } 2050 2051 err = tx.Commit() 2052 if err != nil { 2053 log.Println("failed to resubmit pull", err) 2054 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2055 return 2056 } 2057 2058 client, err := s.oauth.AuthorizedClient(r) 2059 if err != nil { 2060 log.Println("failed to authorize client") 2061 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2062 return 2063 } 2064 2065 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2066 Repo: user.Did, 2067 Writes: writes, 2068 }) 2069 if err != nil { 2070 log.Println("failed to create stacked pull request", err) 2071 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 2072 return 2073 } 2074 2075 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2076} 2077 2078func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 2079 f, err := s.repoResolver.Resolve(r) 2080 if err != nil { 2081 log.Println("failed to resolve repo:", err) 2082 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2083 return 2084 } 2085 2086 pull, ok := r.Context().Value("pull").(*models.Pull) 2087 if !ok { 2088 log.Println("failed to get pull") 2089 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2090 return 2091 } 2092 2093 var pullsToMerge models.Stack 2094 pullsToMerge = append(pullsToMerge, pull) 2095 if pull.IsStacked() { 2096 stack, ok := r.Context().Value("stack").(models.Stack) 2097 if !ok { 2098 log.Println("failed to get stack") 2099 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2100 return 2101 } 2102 2103 // combine patches of substack 2104 subStack := stack.StrictlyBelow(pull) 2105 // collect the portion of the stack that is mergeable 2106 mergeable := subStack.Mergeable() 2107 // add to total patch 2108 pullsToMerge = append(pullsToMerge, mergeable...) 2109 } 2110 2111 patch := pullsToMerge.CombinedPatch() 2112 2113 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 2114 if err != nil { 2115 log.Printf("resolving identity: %s", err) 2116 w.WriteHeader(http.StatusNotFound) 2117 return 2118 } 2119 2120 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 2121 if err != nil { 2122 log.Printf("failed to get primary email: %s", err) 2123 } 2124 2125 authorName := ident.Handle.String() 2126 mergeInput := &tangled.RepoMerge_Input{ 2127 Did: f.OwnerDid(), 2128 Name: f.Name, 2129 Branch: pull.TargetBranch, 2130 Patch: patch, 2131 CommitMessage: &pull.Title, 2132 AuthorName: &authorName, 2133 } 2134 2135 if pull.Body != "" { 2136 mergeInput.CommitBody = &pull.Body 2137 } 2138 2139 if email.Address != "" { 2140 mergeInput.AuthorEmail = &email.Address 2141 } 2142 2143 client, err := s.oauth.ServiceClient( 2144 r, 2145 oauth.WithService(f.Knot), 2146 oauth.WithLxm(tangled.RepoMergeNSID), 2147 oauth.WithDev(s.config.Core.Dev), 2148 ) 2149 if err != nil { 2150 log.Printf("failed to connect to knot server: %v", err) 2151 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2152 return 2153 } 2154 2155 err = tangled.RepoMerge(r.Context(), client, mergeInput) 2156 if err := xrpcclient.HandleXrpcErr(err); err != nil { 2157 s.pages.Notice(w, "pull-merge-error", err.Error()) 2158 return 2159 } 2160 2161 tx, err := s.db.Begin() 2162 if err != nil { 2163 log.Println("failed to start transcation", err) 2164 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2165 return 2166 } 2167 defer tx.Rollback() 2168 2169 for _, p := range pullsToMerge { 2170 err := db.MergePull(tx, f.RepoAt(), p.PullId) 2171 if err != nil { 2172 log.Printf("failed to update pull request status in database: %s", err) 2173 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2174 return 2175 } 2176 } 2177 2178 err = tx.Commit() 2179 if err != nil { 2180 // TODO: this is unsound, we should also revert the merge from the knotserver here 2181 log.Printf("failed to update pull request status in database: %s", err) 2182 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2183 return 2184 } 2185 2186 // notify about the pull merge 2187 for _, p := range pullsToMerge { 2188 s.notifier.NewPullMerged(r.Context(), p) 2189 } 2190 2191 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2192} 2193 2194func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { 2195 user := s.oauth.GetUser(r) 2196 2197 f, err := s.repoResolver.Resolve(r) 2198 if err != nil { 2199 log.Println("malformed middleware") 2200 return 2201 } 2202 2203 pull, ok := r.Context().Value("pull").(*models.Pull) 2204 if !ok { 2205 log.Println("failed to get pull") 2206 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2207 return 2208 } 2209 2210 // auth filter: only owner or collaborators can close 2211 roles := f.RolesInRepo(user) 2212 isOwner := roles.IsOwner() 2213 isCollaborator := roles.IsCollaborator() 2214 isPullAuthor := user.Did == pull.OwnerDid 2215 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2216 if !isCloseAllowed { 2217 log.Println("failed to close pull") 2218 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 2219 return 2220 } 2221 2222 // Start a transaction 2223 tx, err := s.db.BeginTx(r.Context(), nil) 2224 if err != nil { 2225 log.Println("failed to start transaction", err) 2226 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2227 return 2228 } 2229 defer tx.Rollback() 2230 2231 var pullsToClose []*models.Pull 2232 pullsToClose = append(pullsToClose, pull) 2233 2234 // if this PR is stacked, then we want to close all PRs below this one on the stack 2235 if pull.IsStacked() { 2236 stack := r.Context().Value("stack").(models.Stack) 2237 subStack := stack.StrictlyBelow(pull) 2238 pullsToClose = append(pullsToClose, subStack...) 2239 } 2240 2241 for _, p := range pullsToClose { 2242 // Close the pull in the database 2243 err = db.ClosePull(tx, f.RepoAt(), p.PullId) 2244 if err != nil { 2245 log.Println("failed to close pull", err) 2246 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2247 return 2248 } 2249 } 2250 2251 // Commit the transaction 2252 if err = tx.Commit(); err != nil { 2253 log.Println("failed to commit transaction", err) 2254 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2255 return 2256 } 2257 2258 for _, p := range pullsToClose { 2259 s.notifier.NewPullClosed(r.Context(), p) 2260 } 2261 2262 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2263} 2264 2265func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { 2266 user := s.oauth.GetUser(r) 2267 2268 f, err := s.repoResolver.Resolve(r) 2269 if err != nil { 2270 log.Println("failed to resolve repo", err) 2271 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2272 return 2273 } 2274 2275 pull, ok := r.Context().Value("pull").(*models.Pull) 2276 if !ok { 2277 log.Println("failed to get pull") 2278 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2279 return 2280 } 2281 2282 // auth filter: only owner or collaborators can close 2283 roles := f.RolesInRepo(user) 2284 isOwner := roles.IsOwner() 2285 isCollaborator := roles.IsCollaborator() 2286 isPullAuthor := user.Did == pull.OwnerDid 2287 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2288 if !isCloseAllowed { 2289 log.Println("failed to close pull") 2290 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 2291 return 2292 } 2293 2294 // Start a transaction 2295 tx, err := s.db.BeginTx(r.Context(), nil) 2296 if err != nil { 2297 log.Println("failed to start transaction", err) 2298 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2299 return 2300 } 2301 defer tx.Rollback() 2302 2303 var pullsToReopen []*models.Pull 2304 pullsToReopen = append(pullsToReopen, pull) 2305 2306 // if this PR is stacked, then we want to reopen all PRs above this one on the stack 2307 if pull.IsStacked() { 2308 stack := r.Context().Value("stack").(models.Stack) 2309 subStack := stack.StrictlyAbove(pull) 2310 pullsToReopen = append(pullsToReopen, subStack...) 2311 } 2312 2313 for _, p := range pullsToReopen { 2314 // Close the pull in the database 2315 err = db.ReopenPull(tx, f.RepoAt(), p.PullId) 2316 if err != nil { 2317 log.Println("failed to close pull", err) 2318 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2319 return 2320 } 2321 } 2322 2323 // Commit the transaction 2324 if err = tx.Commit(); err != nil { 2325 log.Println("failed to commit transaction", err) 2326 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2327 return 2328 } 2329 2330 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2331} 2332 2333func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2334 formatPatches, err := patchutil.ExtractPatches(patch) 2335 if err != nil { 2336 return nil, fmt.Errorf("Failed to extract patches: %v", err) 2337 } 2338 2339 // must have atleast 1 patch to begin with 2340 if len(formatPatches) == 0 { 2341 return nil, fmt.Errorf("No patches found in the generated format-patch.") 2342 } 2343 2344 // the stack is identified by a UUID 2345 var stack models.Stack 2346 parentChangeId := "" 2347 for _, fp := range formatPatches { 2348 // all patches must have a jj change-id 2349 changeId, err := fp.ChangeId() 2350 if err != nil { 2351 return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.") 2352 } 2353 2354 title := fp.Title 2355 body := fp.Body 2356 rkey := tid.TID() 2357 2358 initialSubmission := models.PullSubmission{ 2359 Patch: fp.Raw, 2360 SourceRev: fp.SHA, 2361 Combined: fp.Raw, 2362 } 2363 pull := models.Pull{ 2364 Title: title, 2365 Body: body, 2366 TargetBranch: targetBranch, 2367 OwnerDid: user.Did, 2368 RepoAt: f.RepoAt(), 2369 Rkey: rkey, 2370 Submissions: []*models.PullSubmission{ 2371 &initialSubmission, 2372 }, 2373 PullSource: pullSource, 2374 Created: time.Now(), 2375 2376 StackId: stackId, 2377 ChangeId: changeId, 2378 ParentChangeId: parentChangeId, 2379 } 2380 2381 stack = append(stack, &pull) 2382 2383 parentChangeId = changeId 2384 } 2385 2386 return stack, nil 2387}