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