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