forked from tangled.org/core
Monorepo for Tangled — https://tangled.org
1package pulls 2 3import ( 4 "database/sql" 5 "encoding/json" 6 "errors" 7 "fmt" 8 "log" 9 "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.LatestSha() 419 420 if pull.IsStacked() && stack != nil { 421 top := stack[0] 422 latestSourceRev = top.LatestSha() 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.LatestSha() { 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 pull.Submissions = append(pull.Submissions, &models.PullSubmission{ 1829 Patch: patch, 1830 SourceRev: sourceRev, 1831 }) 1832 err = db.ResubmitPull(tx, pull) 1833 if err != nil { 1834 log.Println("failed to create pull request", err) 1835 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1836 return 1837 } 1838 client, err := s.oauth.AuthorizedClient(r) 1839 if err != nil { 1840 log.Println("failed to authorize client") 1841 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1842 return 1843 } 1844 1845 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1846 if err != nil { 1847 // failed to get record 1848 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1849 return 1850 } 1851 1852 var recordPullSource *tangled.RepoPull_Source 1853 if pull.IsBranchBased() { 1854 recordPullSource = &tangled.RepoPull_Source{ 1855 Branch: pull.PullSource.Branch, 1856 Sha: sourceRev, 1857 } 1858 } 1859 if pull.IsForkBased() { 1860 repoAt := pull.PullSource.RepoAt.String() 1861 recordPullSource = &tangled.RepoPull_Source{ 1862 Branch: pull.PullSource.Branch, 1863 Repo: &repoAt, 1864 Sha: sourceRev, 1865 } 1866 } 1867 1868 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1869 Collection: tangled.RepoPullNSID, 1870 Repo: user.Did, 1871 Rkey: pull.Rkey, 1872 SwapRecord: ex.Cid, 1873 Record: &lexutil.LexiconTypeDecoder{ 1874 Val: &tangled.RepoPull{ 1875 Title: pull.Title, 1876 Target: &tangled.RepoPull_Target{ 1877 Repo: string(f.RepoAt()), 1878 Branch: pull.TargetBranch, 1879 }, 1880 Patch: patch, // new patch 1881 Source: recordPullSource, 1882 CreatedAt: time.Now().Format(time.RFC3339), 1883 }, 1884 }, 1885 }) 1886 if err != nil { 1887 log.Println("failed to update record", err) 1888 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1889 return 1890 } 1891 1892 if err = tx.Commit(); err != nil { 1893 log.Println("failed to commit transaction", err) 1894 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1895 return 1896 } 1897 1898 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 1899} 1900 1901func (s *Pulls) resubmitStackedPullHelper( 1902 w http.ResponseWriter, 1903 r *http.Request, 1904 f *reporesolver.ResolvedRepo, 1905 user *oauth.User, 1906 pull *models.Pull, 1907 patch string, 1908 stackId string, 1909) { 1910 targetBranch := pull.TargetBranch 1911 1912 origStack, _ := r.Context().Value("stack").(models.Stack) 1913 newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId) 1914 if err != nil { 1915 log.Println("failed to create resubmitted stack", err) 1916 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1917 return 1918 } 1919 1920 // find the diff between the stacks, first, map them by changeId 1921 origById := make(map[string]*models.Pull) 1922 newById := make(map[string]*models.Pull) 1923 for _, p := range origStack { 1924 origById[p.ChangeId] = p 1925 } 1926 for _, p := range newStack { 1927 newById[p.ChangeId] = p 1928 } 1929 1930 // commits that got deleted: corresponding pull is closed 1931 // commits that got added: new pull is created 1932 // commits that got updated: corresponding pull is resubmitted & new round begins 1933 additions := make(map[string]*models.Pull) 1934 deletions := make(map[string]*models.Pull) 1935 updated := make(map[string]struct{}) 1936 1937 // pulls in orignal stack but not in new one 1938 for _, op := range origStack { 1939 if _, ok := newById[op.ChangeId]; !ok { 1940 deletions[op.ChangeId] = op 1941 } 1942 } 1943 1944 // pulls in new stack but not in original one 1945 for _, np := range newStack { 1946 if _, ok := origById[np.ChangeId]; !ok { 1947 additions[np.ChangeId] = np 1948 } 1949 } 1950 1951 // NOTE: this loop can be written in any of above blocks, 1952 // but is written separately in the interest of simpler code 1953 for _, np := range newStack { 1954 if op, ok := origById[np.ChangeId]; ok { 1955 // pull exists in both stacks 1956 updated[op.ChangeId] = struct{}{} 1957 } 1958 } 1959 1960 tx, err := s.db.Begin() 1961 if err != nil { 1962 log.Println("failed to start transaction", err) 1963 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1964 return 1965 } 1966 defer tx.Rollback() 1967 1968 // pds updates to make 1969 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1970 1971 // deleted pulls are marked as deleted in the DB 1972 for _, p := range deletions { 1973 // do not do delete already merged PRs 1974 if p.State == models.PullMerged { 1975 continue 1976 } 1977 1978 err := db.DeletePull(tx, p.RepoAt, p.PullId) 1979 if err != nil { 1980 log.Println("failed to delete pull", err, p.PullId) 1981 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1982 return 1983 } 1984 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1985 RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 1986 Collection: tangled.RepoPullNSID, 1987 Rkey: p.Rkey, 1988 }, 1989 }) 1990 } 1991 1992 // new pulls are created 1993 for _, p := range additions { 1994 err := db.NewPull(tx, p) 1995 if err != nil { 1996 log.Println("failed to create pull", err, p.PullId) 1997 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1998 return 1999 } 2000 2001 record := p.AsRecord() 2002 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2003 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 2004 Collection: tangled.RepoPullNSID, 2005 Rkey: &p.Rkey, 2006 Value: &lexutil.LexiconTypeDecoder{ 2007 Val: &record, 2008 }, 2009 }, 2010 }) 2011 } 2012 2013 // updated pulls are, well, updated; to start a new round 2014 for id := range updated { 2015 op, _ := origById[id] 2016 np, _ := newById[id] 2017 2018 // do not update already merged PRs 2019 if op.State == models.PullMerged { 2020 continue 2021 } 2022 2023 // resubmit the old pull 2024 err := db.ResubmitPull(tx, np) 2025 2026 if err != nil { 2027 log.Println("failed to update pull", err, op.PullId) 2028 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2029 return 2030 } 2031 2032 record := np.AsRecord() 2033 2034 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2035 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 2036 Collection: tangled.RepoPullNSID, 2037 Rkey: op.Rkey, 2038 Value: &lexutil.LexiconTypeDecoder{ 2039 Val: &record, 2040 }, 2041 }, 2042 }) 2043 } 2044 2045 // update parent-change-id relations for the entire stack 2046 for _, p := range newStack { 2047 err := db.SetPullParentChangeId( 2048 tx, 2049 p.ParentChangeId, 2050 // these should be enough filters to be unique per-stack 2051 db.FilterEq("repo_at", p.RepoAt.String()), 2052 db.FilterEq("owner_did", p.OwnerDid), 2053 db.FilterEq("change_id", p.ChangeId), 2054 ) 2055 2056 if err != nil { 2057 log.Println("failed to update pull", err, p.PullId) 2058 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2059 return 2060 } 2061 } 2062 2063 err = tx.Commit() 2064 if err != nil { 2065 log.Println("failed to resubmit pull", err) 2066 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2067 return 2068 } 2069 2070 client, err := s.oauth.AuthorizedClient(r) 2071 if err != nil { 2072 log.Println("failed to authorize client") 2073 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2074 return 2075 } 2076 2077 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2078 Repo: user.Did, 2079 Writes: writes, 2080 }) 2081 if err != nil { 2082 log.Println("failed to create stacked pull request", err) 2083 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 2084 return 2085 } 2086 2087 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2088} 2089 2090func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 2091 f, err := s.repoResolver.Resolve(r) 2092 if err != nil { 2093 log.Println("failed to resolve repo:", err) 2094 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2095 return 2096 } 2097 2098 pull, ok := r.Context().Value("pull").(*models.Pull) 2099 if !ok { 2100 log.Println("failed to get pull") 2101 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2102 return 2103 } 2104 2105 var pullsToMerge models.Stack 2106 pullsToMerge = append(pullsToMerge, pull) 2107 if pull.IsStacked() { 2108 stack, ok := r.Context().Value("stack").(models.Stack) 2109 if !ok { 2110 log.Println("failed to get stack") 2111 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2112 return 2113 } 2114 2115 // combine patches of substack 2116 subStack := stack.StrictlyBelow(pull) 2117 // collect the portion of the stack that is mergeable 2118 mergeable := subStack.Mergeable() 2119 // add to total patch 2120 pullsToMerge = append(pullsToMerge, mergeable...) 2121 } 2122 2123 patch := pullsToMerge.CombinedPatch() 2124 2125 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 2126 if err != nil { 2127 log.Printf("resolving identity: %s", err) 2128 w.WriteHeader(http.StatusNotFound) 2129 return 2130 } 2131 2132 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 2133 if err != nil { 2134 log.Printf("failed to get primary email: %s", err) 2135 } 2136 2137 authorName := ident.Handle.String() 2138 mergeInput := &tangled.RepoMerge_Input{ 2139 Did: f.OwnerDid(), 2140 Name: f.Name, 2141 Branch: pull.TargetBranch, 2142 Patch: patch, 2143 CommitMessage: &pull.Title, 2144 AuthorName: &authorName, 2145 } 2146 2147 if pull.Body != "" { 2148 mergeInput.CommitBody = &pull.Body 2149 } 2150 2151 if email.Address != "" { 2152 mergeInput.AuthorEmail = &email.Address 2153 } 2154 2155 client, err := s.oauth.ServiceClient( 2156 r, 2157 oauth.WithService(f.Knot), 2158 oauth.WithLxm(tangled.RepoMergeNSID), 2159 oauth.WithDev(s.config.Core.Dev), 2160 ) 2161 if err != nil { 2162 log.Printf("failed to connect to knot server: %v", err) 2163 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2164 return 2165 } 2166 2167 err = tangled.RepoMerge(r.Context(), client, mergeInput) 2168 if err := xrpcclient.HandleXrpcErr(err); err != nil { 2169 s.pages.Notice(w, "pull-merge-error", err.Error()) 2170 return 2171 } 2172 2173 tx, err := s.db.Begin() 2174 if err != nil { 2175 log.Println("failed to start transcation", err) 2176 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2177 return 2178 } 2179 defer tx.Rollback() 2180 2181 for _, p := range pullsToMerge { 2182 err := db.MergePull(tx, f.RepoAt(), p.PullId) 2183 if err != nil { 2184 log.Printf("failed to update pull request status in database: %s", err) 2185 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2186 return 2187 } 2188 } 2189 2190 err = tx.Commit() 2191 if err != nil { 2192 // TODO: this is unsound, we should also revert the merge from the knotserver here 2193 log.Printf("failed to update pull request status in database: %s", err) 2194 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2195 return 2196 } 2197 2198 // notify about the pull merge 2199 for _, p := range pullsToMerge { 2200 s.notifier.NewPullMerged(r.Context(), p) 2201 } 2202 2203 s.pages.HxLocation(w, fmt.Sprintf("/@%s/%s/pulls/%d", f.OwnerHandle(), f.Name, pull.PullId)) 2204} 2205 2206func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { 2207 user := s.oauth.GetUser(r) 2208 2209 f, err := s.repoResolver.Resolve(r) 2210 if err != nil { 2211 log.Println("malformed middleware") 2212 return 2213 } 2214 2215 pull, ok := r.Context().Value("pull").(*models.Pull) 2216 if !ok { 2217 log.Println("failed to get pull") 2218 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2219 return 2220 } 2221 2222 // auth filter: only owner or collaborators can close 2223 roles := f.RolesInRepo(user) 2224 isOwner := roles.IsOwner() 2225 isCollaborator := roles.IsCollaborator() 2226 isPullAuthor := user.Did == pull.OwnerDid 2227 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2228 if !isCloseAllowed { 2229 log.Println("failed to close pull") 2230 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 2231 return 2232 } 2233 2234 // Start a transaction 2235 tx, err := s.db.BeginTx(r.Context(), nil) 2236 if err != nil { 2237 log.Println("failed to start transaction", err) 2238 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2239 return 2240 } 2241 defer tx.Rollback() 2242 2243 var pullsToClose []*models.Pull 2244 pullsToClose = append(pullsToClose, pull) 2245 2246 // if this PR is stacked, then we want to close all PRs below this one on the stack 2247 if pull.IsStacked() { 2248 stack := r.Context().Value("stack").(models.Stack) 2249 subStack := stack.StrictlyBelow(pull) 2250 pullsToClose = append(pullsToClose, subStack...) 2251 } 2252 2253 for _, p := range pullsToClose { 2254 // Close the pull in the database 2255 err = db.ClosePull(tx, f.RepoAt(), p.PullId) 2256 if err != nil { 2257 log.Println("failed to close pull", err) 2258 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2259 return 2260 } 2261 } 2262 2263 // Commit the transaction 2264 if err = tx.Commit(); err != nil { 2265 log.Println("failed to commit transaction", err) 2266 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2267 return 2268 } 2269 2270 for _, p := range pullsToClose { 2271 s.notifier.NewPullClosed(r.Context(), p) 2272 } 2273 2274 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2275} 2276 2277func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { 2278 user := s.oauth.GetUser(r) 2279 2280 f, err := s.repoResolver.Resolve(r) 2281 if err != nil { 2282 log.Println("failed to resolve repo", err) 2283 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2284 return 2285 } 2286 2287 pull, ok := r.Context().Value("pull").(*models.Pull) 2288 if !ok { 2289 log.Println("failed to get pull") 2290 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2291 return 2292 } 2293 2294 // auth filter: only owner or collaborators can close 2295 roles := f.RolesInRepo(user) 2296 isOwner := roles.IsOwner() 2297 isCollaborator := roles.IsCollaborator() 2298 isPullAuthor := user.Did == pull.OwnerDid 2299 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2300 if !isCloseAllowed { 2301 log.Println("failed to close pull") 2302 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 2303 return 2304 } 2305 2306 // Start a transaction 2307 tx, err := s.db.BeginTx(r.Context(), nil) 2308 if err != nil { 2309 log.Println("failed to start transaction", err) 2310 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2311 return 2312 } 2313 defer tx.Rollback() 2314 2315 var pullsToReopen []*models.Pull 2316 pullsToReopen = append(pullsToReopen, pull) 2317 2318 // if this PR is stacked, then we want to reopen all PRs above this one on the stack 2319 if pull.IsStacked() { 2320 stack := r.Context().Value("stack").(models.Stack) 2321 subStack := stack.StrictlyAbove(pull) 2322 pullsToReopen = append(pullsToReopen, subStack...) 2323 } 2324 2325 for _, p := range pullsToReopen { 2326 // Close the pull in the database 2327 err = db.ReopenPull(tx, f.RepoAt(), p.PullId) 2328 if err != nil { 2329 log.Println("failed to close pull", err) 2330 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2331 return 2332 } 2333 } 2334 2335 // Commit the transaction 2336 if err = tx.Commit(); err != nil { 2337 log.Println("failed to commit transaction", err) 2338 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2339 return 2340 } 2341 2342 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", f.OwnerSlashRepo(), pull.PullId)) 2343} 2344 2345func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2346 formatPatches, err := patchutil.ExtractPatches(patch) 2347 if err != nil { 2348 return nil, fmt.Errorf("Failed to extract patches: %v", err) 2349 } 2350 2351 // must have atleast 1 patch to begin with 2352 if len(formatPatches) == 0 { 2353 return nil, fmt.Errorf("No patches found in the generated format-patch.") 2354 } 2355 2356 // the stack is identified by a UUID 2357 var stack models.Stack 2358 parentChangeId := "" 2359 for _, fp := range formatPatches { 2360 // all patches must have a jj change-id 2361 changeId, err := fp.ChangeId() 2362 if err != nil { 2363 return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.") 2364 } 2365 2366 title := fp.Title 2367 body := fp.Body 2368 rkey := tid.TID() 2369 2370 initialSubmission := models.PullSubmission{ 2371 Patch: fp.Raw, 2372 SourceRev: fp.SHA, 2373 } 2374 pull := models.Pull{ 2375 Title: title, 2376 Body: body, 2377 TargetBranch: targetBranch, 2378 OwnerDid: user.Did, 2379 RepoAt: f.RepoAt(), 2380 Rkey: rkey, 2381 Submissions: []*models.PullSubmission{ 2382 &initialSubmission, 2383 }, 2384 PullSource: pullSource, 2385 Created: time.Now(), 2386 2387 StackId: stackId, 2388 ChangeId: changeId, 2389 ParentChangeId: parentChangeId, 2390 } 2391 2392 stack = append(stack, &pull) 2393 2394 parentChangeId = changeId 2395 } 2396 2397 return stack, nil 2398}