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