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