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