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