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, pull, stack) 119 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 120 resubmitResult := pages.Unknown 121 if user.Did == pull.OwnerDid { 122 resubmitResult = s.resubmitCheck(r, f, 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, pull, stack) 159 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 160 resubmitResult := pages.Unknown 161 if user != nil && user.Did == pull.OwnerDid { 162 resubmitResult = s.resubmitCheck(r, f, 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 *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.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, 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.Did 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 ps, err := db.GetPipelineStatuses( 649 s.db, 650 len(shas), 651 db.FilterEq("repo_owner", f.Did), 652 db.FilterEq("repo_name", f.Name), 653 db.FilterEq("knot", f.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 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo) 804 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId)) 805 return 806 } 807} 808 809func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) { 810 user := s.oauth.GetUser(r) 811 f, err := s.repoResolver.Resolve(r) 812 if err != nil { 813 log.Println("failed to get repo and knot", err) 814 return 815 } 816 817 switch r.Method { 818 case http.MethodGet: 819 scheme := "http" 820 if !s.config.Core.Dev { 821 scheme = "https" 822 } 823 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 824 xrpcc := &indigoxrpc.Client{ 825 Host: host, 826 } 827 828 repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 829 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 830 if err != nil { 831 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 832 log.Println("failed to call XRPC repo.branches", xrpcerr) 833 s.pages.Error503(w) 834 return 835 } 836 log.Println("failed to fetch branches", err) 837 return 838 } 839 840 var result types.RepoBranchesResponse 841 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 842 log.Println("failed to decode XRPC response", err) 843 s.pages.Error503(w) 844 return 845 } 846 847 // can be one of "patch", "branch" or "fork" 848 strategy := r.URL.Query().Get("strategy") 849 // ignored if strategy is "patch" 850 sourceBranch := r.URL.Query().Get("sourceBranch") 851 targetBranch := r.URL.Query().Get("targetBranch") 852 853 s.pages.RepoNewPull(w, pages.RepoNewPullParams{ 854 LoggedInUser: user, 855 RepoInfo: f.RepoInfo(user), 856 Branches: result.Branches, 857 Strategy: strategy, 858 SourceBranch: sourceBranch, 859 TargetBranch: targetBranch, 860 Title: r.URL.Query().Get("title"), 861 Body: r.URL.Query().Get("body"), 862 }) 863 864 case http.MethodPost: 865 title := r.FormValue("title") 866 body := r.FormValue("body") 867 targetBranch := r.FormValue("targetBranch") 868 fromFork := r.FormValue("fork") 869 sourceBranch := r.FormValue("sourceBranch") 870 patch := r.FormValue("patch") 871 872 if targetBranch == "" { 873 s.pages.Notice(w, "pull", "Target branch is required.") 874 return 875 } 876 877 // Determine PR type based on input parameters 878 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 879 isPushAllowed := roles.IsPushAllowed() 880 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 881 isForkBased := fromFork != "" && sourceBranch != "" 882 isPatchBased := patch != "" && !isBranchBased && !isForkBased 883 isStacked := r.FormValue("isStacked") == "on" 884 885 if isPatchBased && !patchutil.IsFormatPatch(patch) { 886 if title == "" { 887 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 888 return 889 } 890 sanitizer := markup.NewSanitizer() 891 if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 892 s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 893 return 894 } 895 } 896 897 // Validate we have at least one valid PR creation method 898 if !isBranchBased && !isPatchBased && !isForkBased { 899 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 900 return 901 } 902 903 // Can't mix branch-based and patch-based approaches 904 if isBranchBased && patch != "" { 905 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 906 return 907 } 908 909 // us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 910 // if err != nil { 911 // log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 912 // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 913 // return 914 // } 915 916 // TODO: make capabilities an xrpc call 917 caps := struct { 918 PullRequests struct { 919 FormatPatch bool 920 BranchSubmissions bool 921 ForkSubmissions bool 922 PatchSubmissions bool 923 } 924 }{ 925 PullRequests: struct { 926 FormatPatch bool 927 BranchSubmissions bool 928 ForkSubmissions bool 929 PatchSubmissions bool 930 }{ 931 FormatPatch: true, 932 BranchSubmissions: true, 933 ForkSubmissions: true, 934 PatchSubmissions: true, 935 }, 936 } 937 938 // caps, err := us.Capabilities() 939 // if err != nil { 940 // log.Println("error fetching knot caps", f.Knot, err) 941 // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 942 // return 943 // } 944 945 if !caps.PullRequests.FormatPatch { 946 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") 947 return 948 } 949 950 // Handle the PR creation based on the type 951 if isBranchBased { 952 if !caps.PullRequests.BranchSubmissions { 953 s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") 954 return 955 } 956 s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch, isStacked) 957 } else if isForkBased { 958 if !caps.PullRequests.ForkSubmissions { 959 s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") 960 return 961 } 962 s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch, isStacked) 963 } else if isPatchBased { 964 if !caps.PullRequests.PatchSubmissions { 965 s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") 966 return 967 } 968 s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch, isStacked) 969 } 970 return 971 } 972} 973 974func (s *Pulls) handleBranchBasedPull( 975 w http.ResponseWriter, 976 r *http.Request, 977 f *reporesolver.ResolvedRepo, 978 user *oauth.User, 979 title, 980 body, 981 targetBranch, 982 sourceBranch string, 983 isStacked bool, 984) { 985 scheme := "http" 986 if !s.config.Core.Dev { 987 scheme = "https" 988 } 989 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 990 xrpcc := &indigoxrpc.Client{ 991 Host: host, 992 } 993 994 repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 995 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, targetBranch, sourceBranch) 996 if err != nil { 997 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 998 log.Println("failed to call XRPC repo.compare", xrpcerr) 999 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1000 return 1001 } 1002 log.Println("failed to compare", err) 1003 s.pages.Notice(w, "pull", err.Error()) 1004 return 1005 } 1006 1007 var comparison types.RepoFormatPatchResponse 1008 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 1009 log.Println("failed to decode XRPC compare response", err) 1010 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1011 return 1012 } 1013 1014 sourceRev := comparison.Rev2 1015 patch := comparison.FormatPatchRaw 1016 combined := comparison.CombinedPatchRaw 1017 1018 if err := s.validator.ValidatePatch(&patch); err != nil { 1019 s.logger.Error("failed to validate patch", "err", err) 1020 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1021 return 1022 } 1023 1024 pullSource := &models.PullSource{ 1025 Branch: sourceBranch, 1026 } 1027 recordPullSource := &tangled.RepoPull_Source{ 1028 Branch: sourceBranch, 1029 Sha: comparison.Rev2, 1030 } 1031 1032 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1033} 1034 1035func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, title, body, targetBranch, patch string, isStacked bool) { 1036 if err := s.validator.ValidatePatch(&patch); err != nil { 1037 s.logger.Error("patch validation failed", "err", err) 1038 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1039 return 1040 } 1041 1042 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 1043} 1044 1045func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, f *reporesolver.ResolvedRepo, user *oauth.User, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 1046 repoString := strings.SplitN(forkRepo, "/", 2) 1047 forkOwnerDid := repoString[0] 1048 repoName := repoString[1] 1049 fork, err := db.GetForkByDid(s.db, forkOwnerDid, repoName) 1050 if errors.Is(err, sql.ErrNoRows) { 1051 s.pages.Notice(w, "pull", "No such fork.") 1052 return 1053 } else if err != nil { 1054 log.Println("failed to fetch fork:", err) 1055 s.pages.Notice(w, "pull", "Failed to fetch fork.") 1056 return 1057 } 1058 1059 client, err := s.oauth.ServiceClient( 1060 r, 1061 oauth.WithService(fork.Knot), 1062 oauth.WithLxm(tangled.RepoHiddenRefNSID), 1063 oauth.WithDev(s.config.Core.Dev), 1064 ) 1065 1066 resp, err := tangled.RepoHiddenRef( 1067 r.Context(), 1068 client, 1069 &tangled.RepoHiddenRef_Input{ 1070 ForkRef: sourceBranch, 1071 RemoteRef: targetBranch, 1072 Repo: fork.RepoAt().String(), 1073 }, 1074 ) 1075 if err := xrpcclient.HandleXrpcErr(err); err != nil { 1076 s.pages.Notice(w, "pull", err.Error()) 1077 return 1078 } 1079 1080 if !resp.Success { 1081 errorMsg := "Failed to create pull request" 1082 if resp.Error != nil { 1083 errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error) 1084 } 1085 s.pages.Notice(w, "pull", errorMsg) 1086 return 1087 } 1088 1089 hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 1090 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking 1091 // the targetBranch on the target repository. This code is a bit confusing, but here's an example: 1092 // hiddenRef: hidden/feature-1/main (on repo-fork) 1093 // targetBranch: main (on repo-1) 1094 // sourceBranch: feature-1 (on repo-fork) 1095 forkScheme := "http" 1096 if !s.config.Core.Dev { 1097 forkScheme = "https" 1098 } 1099 forkHost := fmt.Sprintf("%s://%s", forkScheme, fork.Knot) 1100 forkXrpcc := &indigoxrpc.Client{ 1101 Host: forkHost, 1102 } 1103 1104 forkRepoId := fmt.Sprintf("%s/%s", fork.Did, fork.Name) 1105 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, forkRepoId, hiddenRef, sourceBranch) 1106 if err != nil { 1107 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1108 log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1109 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1110 return 1111 } 1112 log.Println("failed to compare across branches", err) 1113 s.pages.Notice(w, "pull", err.Error()) 1114 return 1115 } 1116 1117 var comparison types.RepoFormatPatchResponse 1118 if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 1119 log.Println("failed to decode XRPC compare response for fork", err) 1120 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1121 return 1122 } 1123 1124 sourceRev := comparison.Rev2 1125 patch := comparison.FormatPatchRaw 1126 combined := comparison.CombinedPatchRaw 1127 1128 if err := s.validator.ValidatePatch(&patch); err != nil { 1129 s.logger.Error("failed to validate patch", "err", err) 1130 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1131 return 1132 } 1133 1134 forkAtUri := fork.RepoAt() 1135 forkAtUriStr := forkAtUri.String() 1136 1137 pullSource := &models.PullSource{ 1138 Branch: sourceBranch, 1139 RepoAt: &forkAtUri, 1140 } 1141 recordPullSource := &tangled.RepoPull_Source{ 1142 Branch: sourceBranch, 1143 Repo: &forkAtUriStr, 1144 Sha: sourceRev, 1145 } 1146 1147 s.createPullRequest(w, r, f, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1148} 1149 1150func (s *Pulls) createPullRequest( 1151 w http.ResponseWriter, 1152 r *http.Request, 1153 f *reporesolver.ResolvedRepo, 1154 user *oauth.User, 1155 title, body, targetBranch string, 1156 patch string, 1157 combined string, 1158 sourceRev string, 1159 pullSource *models.PullSource, 1160 recordPullSource *tangled.RepoPull_Source, 1161 isStacked bool, 1162) { 1163 if isStacked { 1164 // creates a series of PRs, each linking to the previous, identified by jj's change-id 1165 s.createStackedPullRequest( 1166 w, 1167 r, 1168 f, 1169 user, 1170 targetBranch, 1171 patch, 1172 sourceRev, 1173 pullSource, 1174 ) 1175 return 1176 } 1177 1178 client, err := s.oauth.AuthorizedClient(r) 1179 if err != nil { 1180 log.Println("failed to get authorized client", err) 1181 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1182 return 1183 } 1184 1185 tx, err := s.db.BeginTx(r.Context(), nil) 1186 if err != nil { 1187 log.Println("failed to start tx") 1188 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1189 return 1190 } 1191 defer tx.Rollback() 1192 1193 // We've already checked earlier if it's diff-based and title is empty, 1194 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1195 if title == "" || body == "" { 1196 formatPatches, err := patchutil.ExtractPatches(patch) 1197 if err != nil { 1198 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 1199 return 1200 } 1201 if len(formatPatches) == 0 { 1202 s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.") 1203 return 1204 } 1205 1206 if title == "" { 1207 title = formatPatches[0].Title 1208 } 1209 if body == "" { 1210 body = formatPatches[0].Body 1211 } 1212 } 1213 1214 rkey := tid.TID() 1215 initialSubmission := models.PullSubmission{ 1216 Patch: patch, 1217 Combined: combined, 1218 SourceRev: sourceRev, 1219 } 1220 pull := &models.Pull{ 1221 Title: title, 1222 Body: body, 1223 TargetBranch: targetBranch, 1224 OwnerDid: user.Did, 1225 RepoAt: f.RepoAt(), 1226 Rkey: rkey, 1227 Submissions: []*models.PullSubmission{ 1228 &initialSubmission, 1229 }, 1230 PullSource: pullSource, 1231 } 1232 err = db.NewPull(tx, pull) 1233 if err != nil { 1234 log.Println("failed to create pull request", err) 1235 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1236 return 1237 } 1238 pullId, err := db.NextPullId(tx, f.RepoAt()) 1239 if err != nil { 1240 log.Println("failed to get pull id", err) 1241 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1242 return 1243 } 1244 1245 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1246 Collection: tangled.RepoPullNSID, 1247 Repo: user.Did, 1248 Rkey: rkey, 1249 Record: &lexutil.LexiconTypeDecoder{ 1250 Val: &tangled.RepoPull{ 1251 Title: title, 1252 Target: &tangled.RepoPull_Target{ 1253 Repo: string(f.RepoAt()), 1254 Branch: targetBranch, 1255 }, 1256 Patch: patch, 1257 Source: recordPullSource, 1258 CreatedAt: time.Now().Format(time.RFC3339), 1259 }, 1260 }, 1261 }) 1262 if err != nil { 1263 log.Println("failed to create pull request", err) 1264 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1265 return 1266 } 1267 1268 if err = tx.Commit(); err != nil { 1269 log.Println("failed to create pull request", err) 1270 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1271 return 1272 } 1273 1274 s.notifier.NewPull(r.Context(), pull) 1275 1276 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo) 1277 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId)) 1278} 1279 1280func (s *Pulls) createStackedPullRequest( 1281 w http.ResponseWriter, 1282 r *http.Request, 1283 f *reporesolver.ResolvedRepo, 1284 user *oauth.User, 1285 targetBranch string, 1286 patch string, 1287 sourceRev string, 1288 pullSource *models.PullSource, 1289) { 1290 // run some necessary checks for stacked-prs first 1291 1292 // must be branch or fork based 1293 if sourceRev == "" { 1294 log.Println("stacked PR from patch-based pull") 1295 s.pages.Notice(w, "pull", "Stacking is only supported on branch and fork based pull-requests.") 1296 return 1297 } 1298 1299 formatPatches, err := patchutil.ExtractPatches(patch) 1300 if err != nil { 1301 log.Println("failed to extract patches", err) 1302 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 1303 return 1304 } 1305 1306 // must have atleast 1 patch to begin with 1307 if len(formatPatches) == 0 { 1308 log.Println("empty patches") 1309 s.pages.Notice(w, "pull", "No patches found in the generated format-patch.") 1310 return 1311 } 1312 1313 // build a stack out of this patch 1314 stackId := uuid.New() 1315 stack, err := newStack(f, user, targetBranch, patch, pullSource, stackId.String()) 1316 if err != nil { 1317 log.Println("failed to create stack", err) 1318 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) 1319 return 1320 } 1321 1322 client, err := s.oauth.AuthorizedClient(r) 1323 if err != nil { 1324 log.Println("failed to get authorized client", err) 1325 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1326 return 1327 } 1328 1329 // apply all record creations at once 1330 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1331 for _, p := range stack { 1332 record := p.AsRecord() 1333 write := comatproto.RepoApplyWrites_Input_Writes_Elem{ 1334 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1335 Collection: tangled.RepoPullNSID, 1336 Rkey: &p.Rkey, 1337 Value: &lexutil.LexiconTypeDecoder{ 1338 Val: &record, 1339 }, 1340 }, 1341 } 1342 writes = append(writes, &write) 1343 } 1344 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1345 Repo: user.Did, 1346 Writes: writes, 1347 }) 1348 if err != nil { 1349 log.Println("failed to create stacked pull request", err) 1350 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 1351 return 1352 } 1353 1354 // create all pulls at once 1355 tx, err := s.db.BeginTx(r.Context(), nil) 1356 if err != nil { 1357 log.Println("failed to start tx") 1358 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1359 return 1360 } 1361 defer tx.Rollback() 1362 1363 for _, p := range stack { 1364 err = db.NewPull(tx, p) 1365 if err != nil { 1366 log.Println("failed to create pull request", err) 1367 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1368 return 1369 } 1370 } 1371 1372 if err = tx.Commit(); err != nil { 1373 log.Println("failed to create pull request", err) 1374 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1375 return 1376 } 1377 1378 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo) 1379 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo)) 1380} 1381 1382func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) { 1383 _, err := s.repoResolver.Resolve(r) 1384 if err != nil { 1385 log.Println("failed to get repo and knot", err) 1386 return 1387 } 1388 1389 patch := r.FormValue("patch") 1390 if patch == "" { 1391 s.pages.Notice(w, "patch-error", "Patch is required.") 1392 return 1393 } 1394 1395 if err := s.validator.ValidatePatch(&patch); err != nil { 1396 s.logger.Error("faield to validate patch", "err", err) 1397 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1398 return 1399 } 1400 1401 if patchutil.IsFormatPatch(patch) { 1402 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.") 1403 } else { 1404 s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.") 1405 } 1406} 1407 1408func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 1409 user := s.oauth.GetUser(r) 1410 f, err := s.repoResolver.Resolve(r) 1411 if err != nil { 1412 log.Println("failed to get repo and knot", err) 1413 return 1414 } 1415 1416 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 1417 RepoInfo: f.RepoInfo(user), 1418 }) 1419} 1420 1421func (s *Pulls) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { 1422 user := s.oauth.GetUser(r) 1423 f, err := s.repoResolver.Resolve(r) 1424 if err != nil { 1425 log.Println("failed to get repo and knot", err) 1426 return 1427 } 1428 1429 scheme := "http" 1430 if !s.config.Core.Dev { 1431 scheme = "https" 1432 } 1433 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1434 xrpcc := &indigoxrpc.Client{ 1435 Host: host, 1436 } 1437 1438 repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1439 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1440 if err != nil { 1441 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1442 log.Println("failed to call XRPC repo.branches", xrpcerr) 1443 s.pages.Error503(w) 1444 return 1445 } 1446 log.Println("failed to fetch branches", err) 1447 return 1448 } 1449 1450 var result types.RepoBranchesResponse 1451 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1452 log.Println("failed to decode XRPC response", err) 1453 s.pages.Error503(w) 1454 return 1455 } 1456 1457 branches := result.Branches 1458 sort.Slice(branches, func(i int, j int) bool { 1459 return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When) 1460 }) 1461 1462 withoutDefault := []types.Branch{} 1463 for _, b := range branches { 1464 if b.IsDefault { 1465 continue 1466 } 1467 withoutDefault = append(withoutDefault, b) 1468 } 1469 1470 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 1471 RepoInfo: f.RepoInfo(user), 1472 Branches: withoutDefault, 1473 }) 1474} 1475 1476func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 1477 user := s.oauth.GetUser(r) 1478 f, err := s.repoResolver.Resolve(r) 1479 if err != nil { 1480 log.Println("failed to get repo and knot", err) 1481 return 1482 } 1483 1484 forks, err := db.GetForksByDid(s.db, user.Did) 1485 if err != nil { 1486 log.Println("failed to get forks", err) 1487 return 1488 } 1489 1490 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ 1491 RepoInfo: f.RepoInfo(user), 1492 Forks: forks, 1493 Selected: r.URL.Query().Get("fork"), 1494 }) 1495} 1496 1497func (s *Pulls) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { 1498 user := s.oauth.GetUser(r) 1499 1500 f, err := s.repoResolver.Resolve(r) 1501 if err != nil { 1502 log.Println("failed to get repo and knot", err) 1503 return 1504 } 1505 1506 forkVal := r.URL.Query().Get("fork") 1507 repoString := strings.SplitN(forkVal, "/", 2) 1508 forkOwnerDid := repoString[0] 1509 forkName := repoString[1] 1510 // fork repo 1511 repo, err := db.GetRepo( 1512 s.db, 1513 db.FilterEq("did", forkOwnerDid), 1514 db.FilterEq("name", forkName), 1515 ) 1516 if err != nil { 1517 log.Println("failed to get repo", "did", forkOwnerDid, "name", forkName, "err", err) 1518 return 1519 } 1520 1521 sourceScheme := "http" 1522 if !s.config.Core.Dev { 1523 sourceScheme = "https" 1524 } 1525 sourceHost := fmt.Sprintf("%s://%s", sourceScheme, repo.Knot) 1526 sourceXrpcc := &indigoxrpc.Client{ 1527 Host: sourceHost, 1528 } 1529 1530 sourceRepo := fmt.Sprintf("%s/%s", forkOwnerDid, repo.Name) 1531 sourceXrpcBytes, err := tangled.RepoBranches(r.Context(), sourceXrpcc, "", 0, sourceRepo) 1532 if err != nil { 1533 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1534 log.Println("failed to call XRPC repo.branches for source", xrpcerr) 1535 s.pages.Error503(w) 1536 return 1537 } 1538 log.Println("failed to fetch source branches", err) 1539 return 1540 } 1541 1542 // Decode source branches 1543 var sourceBranches types.RepoBranchesResponse 1544 if err := json.Unmarshal(sourceXrpcBytes, &sourceBranches); err != nil { 1545 log.Println("failed to decode source branches XRPC response", err) 1546 s.pages.Error503(w) 1547 return 1548 } 1549 1550 targetScheme := "http" 1551 if !s.config.Core.Dev { 1552 targetScheme = "https" 1553 } 1554 targetHost := fmt.Sprintf("%s://%s", targetScheme, f.Knot) 1555 targetXrpcc := &indigoxrpc.Client{ 1556 Host: targetHost, 1557 } 1558 1559 targetRepo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1560 targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo) 1561 if err != nil { 1562 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1563 log.Println("failed to call XRPC repo.branches for target", xrpcerr) 1564 s.pages.Error503(w) 1565 return 1566 } 1567 log.Println("failed to fetch target branches", err) 1568 return 1569 } 1570 1571 // Decode target branches 1572 var targetBranches types.RepoBranchesResponse 1573 if err := json.Unmarshal(targetXrpcBytes, &targetBranches); err != nil { 1574 log.Println("failed to decode target branches XRPC response", err) 1575 s.pages.Error503(w) 1576 return 1577 } 1578 1579 sort.Slice(sourceBranches.Branches, func(i int, j int) bool { 1580 return sourceBranches.Branches[i].Commit.Committer.When.After(sourceBranches.Branches[j].Commit.Committer.When) 1581 }) 1582 1583 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1584 RepoInfo: f.RepoInfo(user), 1585 SourceBranches: sourceBranches.Branches, 1586 TargetBranches: targetBranches.Branches, 1587 }) 1588} 1589 1590func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { 1591 user := s.oauth.GetUser(r) 1592 f, err := s.repoResolver.Resolve(r) 1593 if err != nil { 1594 log.Println("failed to get repo and knot", err) 1595 return 1596 } 1597 1598 pull, ok := r.Context().Value("pull").(*models.Pull) 1599 if !ok { 1600 log.Println("failed to get pull") 1601 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1602 return 1603 } 1604 1605 switch r.Method { 1606 case http.MethodGet: 1607 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 1608 RepoInfo: f.RepoInfo(user), 1609 Pull: pull, 1610 }) 1611 return 1612 case http.MethodPost: 1613 if pull.IsPatchBased() { 1614 s.resubmitPatch(w, r) 1615 return 1616 } else if pull.IsBranchBased() { 1617 s.resubmitBranch(w, r) 1618 return 1619 } else if pull.IsForkBased() { 1620 s.resubmitFork(w, r) 1621 return 1622 } 1623 } 1624} 1625 1626func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1627 user := s.oauth.GetUser(r) 1628 1629 pull, ok := r.Context().Value("pull").(*models.Pull) 1630 if !ok { 1631 log.Println("failed to get pull") 1632 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1633 return 1634 } 1635 1636 f, err := s.repoResolver.Resolve(r) 1637 if err != nil { 1638 log.Println("failed to get repo and knot", err) 1639 return 1640 } 1641 1642 if user.Did != pull.OwnerDid { 1643 log.Println("unauthorized user") 1644 w.WriteHeader(http.StatusUnauthorized) 1645 return 1646 } 1647 1648 patch := r.FormValue("patch") 1649 1650 s.resubmitPullHelper(w, r, f, user, pull, patch, "", "") 1651} 1652 1653func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1654 user := s.oauth.GetUser(r) 1655 1656 pull, ok := r.Context().Value("pull").(*models.Pull) 1657 if !ok { 1658 log.Println("failed to get pull") 1659 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 1660 return 1661 } 1662 1663 f, err := s.repoResolver.Resolve(r) 1664 if err != nil { 1665 log.Println("failed to get repo and knot", err) 1666 return 1667 } 1668 1669 if user.Did != pull.OwnerDid { 1670 log.Println("unauthorized user") 1671 w.WriteHeader(http.StatusUnauthorized) 1672 return 1673 } 1674 1675 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 1676 if !roles.IsPushAllowed() { 1677 log.Println("unauthorized user") 1678 w.WriteHeader(http.StatusUnauthorized) 1679 return 1680 } 1681 1682 scheme := "http" 1683 if !s.config.Core.Dev { 1684 scheme = "https" 1685 } 1686 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1687 xrpcc := &indigoxrpc.Client{ 1688 Host: host, 1689 } 1690 1691 repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1692 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch) 1693 if err != nil { 1694 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1695 log.Println("failed to call XRPC repo.compare", xrpcerr) 1696 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1697 return 1698 } 1699 log.Printf("compare request failed: %s", err) 1700 s.pages.Notice(w, "resubmit-error", err.Error()) 1701 return 1702 } 1703 1704 var comparison types.RepoFormatPatchResponse 1705 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 1706 log.Println("failed to decode XRPC compare response", err) 1707 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1708 return 1709 } 1710 1711 sourceRev := comparison.Rev2 1712 patch := comparison.FormatPatchRaw 1713 combined := comparison.CombinedPatchRaw 1714 1715 s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1716} 1717 1718func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 1719 user := s.oauth.GetUser(r) 1720 1721 pull, ok := r.Context().Value("pull").(*models.Pull) 1722 if !ok { 1723 log.Println("failed to get pull") 1724 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 1725 return 1726 } 1727 1728 f, err := s.repoResolver.Resolve(r) 1729 if err != nil { 1730 log.Println("failed to get repo and knot", err) 1731 return 1732 } 1733 1734 if user.Did != pull.OwnerDid { 1735 log.Println("unauthorized user") 1736 w.WriteHeader(http.StatusUnauthorized) 1737 return 1738 } 1739 1740 forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 1741 if err != nil { 1742 log.Println("failed to get source repo", err) 1743 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1744 return 1745 } 1746 1747 // update the hidden tracking branch to latest 1748 client, err := s.oauth.ServiceClient( 1749 r, 1750 oauth.WithService(forkRepo.Knot), 1751 oauth.WithLxm(tangled.RepoHiddenRefNSID), 1752 oauth.WithDev(s.config.Core.Dev), 1753 ) 1754 if err != nil { 1755 log.Printf("failed to connect to knot server: %v", err) 1756 return 1757 } 1758 1759 resp, err := tangled.RepoHiddenRef( 1760 r.Context(), 1761 client, 1762 &tangled.RepoHiddenRef_Input{ 1763 ForkRef: pull.PullSource.Branch, 1764 RemoteRef: pull.TargetBranch, 1765 Repo: forkRepo.RepoAt().String(), 1766 }, 1767 ) 1768 if err := xrpcclient.HandleXrpcErr(err); err != nil { 1769 s.pages.Notice(w, "resubmit-error", err.Error()) 1770 return 1771 } 1772 if !resp.Success { 1773 log.Println("Failed to update tracking ref.", "err", resp.Error) 1774 s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.") 1775 return 1776 } 1777 1778 hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 1779 // extract patch by performing compare 1780 forkScheme := "http" 1781 if !s.config.Core.Dev { 1782 forkScheme = "https" 1783 } 1784 forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1785 forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name) 1786 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, hiddenRef, pull.PullSource.Branch) 1787 if err != nil { 1788 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1789 log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1790 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1791 return 1792 } 1793 log.Printf("failed to compare branches: %s", err) 1794 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1795 return 1796 } 1797 1798 var forkComparison types.RepoFormatPatchResponse 1799 if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 1800 log.Println("failed to decode XRPC compare response for fork", err) 1801 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1802 return 1803 } 1804 1805 // Use the fork comparison we already made 1806 comparison := forkComparison 1807 1808 sourceRev := comparison.Rev2 1809 patch := comparison.FormatPatchRaw 1810 combined := comparison.CombinedPatchRaw 1811 1812 s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1813} 1814 1815func (s *Pulls) resubmitPullHelper( 1816 w http.ResponseWriter, 1817 r *http.Request, 1818 f *reporesolver.ResolvedRepo, 1819 user *oauth.User, 1820 pull *models.Pull, 1821 patch string, 1822 combined string, 1823 sourceRev string, 1824) { 1825 if pull.IsStacked() { 1826 log.Println("resubmitting stacked PR") 1827 s.resubmitStackedPullHelper(w, r, f, user, pull, patch, pull.StackId) 1828 return 1829 } 1830 1831 if err := s.validator.ValidatePatch(&patch); err != nil { 1832 s.pages.Notice(w, "resubmit-error", err.Error()) 1833 return 1834 } 1835 1836 if patch == pull.LatestPatch() { 1837 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 1838 return 1839 } 1840 1841 // validate sourceRev if branch/fork based 1842 if pull.IsBranchBased() || pull.IsForkBased() { 1843 if sourceRev == pull.LatestSha() { 1844 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1845 return 1846 } 1847 } 1848 1849 tx, err := s.db.BeginTx(r.Context(), nil) 1850 if err != nil { 1851 log.Println("failed to start tx") 1852 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1853 return 1854 } 1855 defer tx.Rollback() 1856 1857 pullAt := pull.AtUri() 1858 newRoundNumber := len(pull.Submissions) 1859 newPatch := patch 1860 newSourceRev := sourceRev 1861 combinedPatch := combined 1862 err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 1863 if err != nil { 1864 log.Println("failed to create pull request", err) 1865 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1866 return 1867 } 1868 client, err := s.oauth.AuthorizedClient(r) 1869 if err != nil { 1870 log.Println("failed to authorize client") 1871 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1872 return 1873 } 1874 1875 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Did, pull.Rkey) 1876 if err != nil { 1877 // failed to get record 1878 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1879 return 1880 } 1881 1882 var recordPullSource *tangled.RepoPull_Source 1883 if pull.IsBranchBased() { 1884 recordPullSource = &tangled.RepoPull_Source{ 1885 Branch: pull.PullSource.Branch, 1886 Sha: sourceRev, 1887 } 1888 } 1889 if pull.IsForkBased() { 1890 repoAt := pull.PullSource.RepoAt.String() 1891 recordPullSource = &tangled.RepoPull_Source{ 1892 Branch: pull.PullSource.Branch, 1893 Repo: &repoAt, 1894 Sha: sourceRev, 1895 } 1896 } 1897 1898 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1899 Collection: tangled.RepoPullNSID, 1900 Repo: user.Did, 1901 Rkey: pull.Rkey, 1902 SwapRecord: ex.Cid, 1903 Record: &lexutil.LexiconTypeDecoder{ 1904 Val: &tangled.RepoPull{ 1905 Title: pull.Title, 1906 Target: &tangled.RepoPull_Target{ 1907 Repo: string(f.RepoAt()), 1908 Branch: pull.TargetBranch, 1909 }, 1910 Patch: patch, // new patch 1911 Source: recordPullSource, 1912 CreatedAt: time.Now().Format(time.RFC3339), 1913 }, 1914 }, 1915 }) 1916 if err != nil { 1917 log.Println("failed to update record", err) 1918 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1919 return 1920 } 1921 1922 if err = tx.Commit(); err != nil { 1923 log.Println("failed to commit transaction", err) 1924 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1925 return 1926 } 1927 1928 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo) 1929 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 1930} 1931 1932func (s *Pulls) resubmitStackedPullHelper( 1933 w http.ResponseWriter, 1934 r *http.Request, 1935 f *reporesolver.ResolvedRepo, 1936 user *oauth.User, 1937 pull *models.Pull, 1938 patch string, 1939 stackId string, 1940) { 1941 targetBranch := pull.TargetBranch 1942 1943 origStack, _ := r.Context().Value("stack").(models.Stack) 1944 newStack, err := newStack(f, user, targetBranch, patch, pull.PullSource, stackId) 1945 if err != nil { 1946 log.Println("failed to create resubmitted stack", err) 1947 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 1948 return 1949 } 1950 1951 // find the diff between the stacks, first, map them by changeId 1952 origById := make(map[string]*models.Pull) 1953 newById := make(map[string]*models.Pull) 1954 for _, p := range origStack { 1955 origById[p.ChangeId] = p 1956 } 1957 for _, p := range newStack { 1958 newById[p.ChangeId] = p 1959 } 1960 1961 // commits that got deleted: corresponding pull is closed 1962 // commits that got added: new pull is created 1963 // commits that got updated: corresponding pull is resubmitted & new round begins 1964 additions := make(map[string]*models.Pull) 1965 deletions := make(map[string]*models.Pull) 1966 updated := make(map[string]struct{}) 1967 1968 // pulls in orignal stack but not in new one 1969 for _, op := range origStack { 1970 if _, ok := newById[op.ChangeId]; !ok { 1971 deletions[op.ChangeId] = op 1972 } 1973 } 1974 1975 // pulls in new stack but not in original one 1976 for _, np := range newStack { 1977 if _, ok := origById[np.ChangeId]; !ok { 1978 additions[np.ChangeId] = np 1979 } 1980 } 1981 1982 // NOTE: this loop can be written in any of above blocks, 1983 // but is written separately in the interest of simpler code 1984 for _, np := range newStack { 1985 if op, ok := origById[np.ChangeId]; ok { 1986 // pull exists in both stacks 1987 updated[op.ChangeId] = struct{}{} 1988 } 1989 } 1990 1991 tx, err := s.db.Begin() 1992 if err != nil { 1993 log.Println("failed to start transaction", err) 1994 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 1995 return 1996 } 1997 defer tx.Rollback() 1998 1999 // pds updates to make 2000 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 2001 2002 // deleted pulls are marked as deleted in the DB 2003 for _, p := range deletions { 2004 // do not do delete already merged PRs 2005 if p.State == models.PullMerged { 2006 continue 2007 } 2008 2009 err := db.DeletePull(tx, p.RepoAt, p.PullId) 2010 if err != nil { 2011 log.Println("failed to delete pull", err, p.PullId) 2012 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2013 return 2014 } 2015 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2016 RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 2017 Collection: tangled.RepoPullNSID, 2018 Rkey: p.Rkey, 2019 }, 2020 }) 2021 } 2022 2023 // new pulls are created 2024 for _, p := range additions { 2025 err := db.NewPull(tx, p) 2026 if err != nil { 2027 log.Println("failed to create pull", err, p.PullId) 2028 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2029 return 2030 } 2031 2032 record := p.AsRecord() 2033 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2034 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 2035 Collection: tangled.RepoPullNSID, 2036 Rkey: &p.Rkey, 2037 Value: &lexutil.LexiconTypeDecoder{ 2038 Val: &record, 2039 }, 2040 }, 2041 }) 2042 } 2043 2044 // updated pulls are, well, updated; to start a new round 2045 for id := range updated { 2046 op, _ := origById[id] 2047 np, _ := newById[id] 2048 2049 // do not update already merged PRs 2050 if op.State == models.PullMerged { 2051 continue 2052 } 2053 2054 // resubmit the new pull 2055 pullAt := op.AtUri() 2056 newRoundNumber := len(op.Submissions) 2057 newPatch := np.LatestPatch() 2058 combinedPatch := np.LatestSubmission().Combined 2059 newSourceRev := np.LatestSha() 2060 err := db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 2061 if err != nil { 2062 log.Println("failed to update pull", err, op.PullId) 2063 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2064 return 2065 } 2066 2067 record := np.AsRecord() 2068 2069 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2070 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 2071 Collection: tangled.RepoPullNSID, 2072 Rkey: op.Rkey, 2073 Value: &lexutil.LexiconTypeDecoder{ 2074 Val: &record, 2075 }, 2076 }, 2077 }) 2078 } 2079 2080 // update parent-change-id relations for the entire stack 2081 for _, p := range newStack { 2082 err := db.SetPullParentChangeId( 2083 tx, 2084 p.ParentChangeId, 2085 // these should be enough filters to be unique per-stack 2086 db.FilterEq("repo_at", p.RepoAt.String()), 2087 db.FilterEq("owner_did", p.OwnerDid), 2088 db.FilterEq("change_id", p.ChangeId), 2089 ) 2090 2091 if err != nil { 2092 log.Println("failed to update pull", err, p.PullId) 2093 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2094 return 2095 } 2096 } 2097 2098 err = tx.Commit() 2099 if err != nil { 2100 log.Println("failed to resubmit pull", err) 2101 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2102 return 2103 } 2104 2105 client, err := s.oauth.AuthorizedClient(r) 2106 if err != nil { 2107 log.Println("failed to authorize client") 2108 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2109 return 2110 } 2111 2112 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2113 Repo: user.Did, 2114 Writes: writes, 2115 }) 2116 if err != nil { 2117 log.Println("failed to create stacked pull request", err) 2118 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 2119 return 2120 } 2121 2122 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo) 2123 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2124} 2125 2126func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 2127 user := s.oauth.GetUser(r) 2128 f, err := s.repoResolver.Resolve(r) 2129 if err != nil { 2130 log.Println("failed to resolve repo:", err) 2131 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2132 return 2133 } 2134 2135 pull, ok := r.Context().Value("pull").(*models.Pull) 2136 if !ok { 2137 log.Println("failed to get pull") 2138 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2139 return 2140 } 2141 2142 var pullsToMerge models.Stack 2143 pullsToMerge = append(pullsToMerge, pull) 2144 if pull.IsStacked() { 2145 stack, ok := r.Context().Value("stack").(models.Stack) 2146 if !ok { 2147 log.Println("failed to get stack") 2148 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2149 return 2150 } 2151 2152 // combine patches of substack 2153 subStack := stack.StrictlyBelow(pull) 2154 // collect the portion of the stack that is mergeable 2155 mergeable := subStack.Mergeable() 2156 // add to total patch 2157 pullsToMerge = append(pullsToMerge, mergeable...) 2158 } 2159 2160 patch := pullsToMerge.CombinedPatch() 2161 2162 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 2163 if err != nil { 2164 log.Printf("resolving identity: %s", err) 2165 w.WriteHeader(http.StatusNotFound) 2166 return 2167 } 2168 2169 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 2170 if err != nil { 2171 log.Printf("failed to get primary email: %s", err) 2172 } 2173 2174 authorName := ident.Handle.String() 2175 mergeInput := &tangled.RepoMerge_Input{ 2176 Did: f.Did, 2177 Name: f.Name, 2178 Branch: pull.TargetBranch, 2179 Patch: patch, 2180 CommitMessage: &pull.Title, 2181 AuthorName: &authorName, 2182 } 2183 2184 if pull.Body != "" { 2185 mergeInput.CommitBody = &pull.Body 2186 } 2187 2188 if email.Address != "" { 2189 mergeInput.AuthorEmail = &email.Address 2190 } 2191 2192 client, err := s.oauth.ServiceClient( 2193 r, 2194 oauth.WithService(f.Knot), 2195 oauth.WithLxm(tangled.RepoMergeNSID), 2196 oauth.WithDev(s.config.Core.Dev), 2197 ) 2198 if err != nil { 2199 log.Printf("failed to connect to knot server: %v", err) 2200 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2201 return 2202 } 2203 2204 err = tangled.RepoMerge(r.Context(), client, mergeInput) 2205 if err := xrpcclient.HandleXrpcErr(err); err != nil { 2206 s.pages.Notice(w, "pull-merge-error", err.Error()) 2207 return 2208 } 2209 2210 tx, err := s.db.Begin() 2211 if err != nil { 2212 log.Println("failed to start transcation", err) 2213 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2214 return 2215 } 2216 defer tx.Rollback() 2217 2218 for _, p := range pullsToMerge { 2219 err := db.MergePull(tx, f.RepoAt(), p.PullId) 2220 if err != nil { 2221 log.Printf("failed to update pull request status in database: %s", err) 2222 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2223 return 2224 } 2225 p.State = models.PullMerged 2226 } 2227 2228 err = tx.Commit() 2229 if err != nil { 2230 // TODO: this is unsound, we should also revert the merge from the knotserver here 2231 log.Printf("failed to update pull request status in database: %s", err) 2232 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2233 return 2234 } 2235 2236 // notify about the pull merge 2237 for _, p := range pullsToMerge { 2238 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2239 } 2240 2241 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo) 2242 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2243} 2244 2245func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { 2246 user := s.oauth.GetUser(r) 2247 2248 f, err := s.repoResolver.Resolve(r) 2249 if err != nil { 2250 log.Println("malformed middleware") 2251 return 2252 } 2253 2254 pull, ok := r.Context().Value("pull").(*models.Pull) 2255 if !ok { 2256 log.Println("failed to get pull") 2257 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2258 return 2259 } 2260 2261 // auth filter: only owner or collaborators can close 2262 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 2263 isOwner := roles.IsOwner() 2264 isCollaborator := roles.IsCollaborator() 2265 isPullAuthor := user.Did == pull.OwnerDid 2266 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2267 if !isCloseAllowed { 2268 log.Println("failed to close pull") 2269 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 2270 return 2271 } 2272 2273 // Start a transaction 2274 tx, err := s.db.BeginTx(r.Context(), nil) 2275 if err != nil { 2276 log.Println("failed to start transaction", err) 2277 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2278 return 2279 } 2280 defer tx.Rollback() 2281 2282 var pullsToClose []*models.Pull 2283 pullsToClose = append(pullsToClose, pull) 2284 2285 // if this PR is stacked, then we want to close all PRs below this one on the stack 2286 if pull.IsStacked() { 2287 stack := r.Context().Value("stack").(models.Stack) 2288 subStack := stack.StrictlyBelow(pull) 2289 pullsToClose = append(pullsToClose, subStack...) 2290 } 2291 2292 for _, p := range pullsToClose { 2293 // Close the pull in the database 2294 err = db.ClosePull(tx, f.RepoAt(), p.PullId) 2295 if err != nil { 2296 log.Println("failed to close pull", err) 2297 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2298 return 2299 } 2300 p.State = models.PullClosed 2301 } 2302 2303 // Commit the transaction 2304 if err = tx.Commit(); err != nil { 2305 log.Println("failed to commit transaction", err) 2306 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2307 return 2308 } 2309 2310 for _, p := range pullsToClose { 2311 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2312 } 2313 2314 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo) 2315 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2316} 2317 2318func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { 2319 user := s.oauth.GetUser(r) 2320 2321 f, err := s.repoResolver.Resolve(r) 2322 if err != nil { 2323 log.Println("failed to resolve repo", err) 2324 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2325 return 2326 } 2327 2328 pull, ok := r.Context().Value("pull").(*models.Pull) 2329 if !ok { 2330 log.Println("failed to get pull") 2331 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2332 return 2333 } 2334 2335 // auth filter: only owner or collaborators can close 2336 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.DidSlashRepo())} 2337 isOwner := roles.IsOwner() 2338 isCollaborator := roles.IsCollaborator() 2339 isPullAuthor := user.Did == pull.OwnerDid 2340 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2341 if !isCloseAllowed { 2342 log.Println("failed to close pull") 2343 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 2344 return 2345 } 2346 2347 // Start a transaction 2348 tx, err := s.db.BeginTx(r.Context(), nil) 2349 if err != nil { 2350 log.Println("failed to start transaction", err) 2351 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2352 return 2353 } 2354 defer tx.Rollback() 2355 2356 var pullsToReopen []*models.Pull 2357 pullsToReopen = append(pullsToReopen, pull) 2358 2359 // if this PR is stacked, then we want to reopen all PRs above this one on the stack 2360 if pull.IsStacked() { 2361 stack := r.Context().Value("stack").(models.Stack) 2362 subStack := stack.StrictlyAbove(pull) 2363 pullsToReopen = append(pullsToReopen, subStack...) 2364 } 2365 2366 for _, p := range pullsToReopen { 2367 // Close the pull in the database 2368 err = db.ReopenPull(tx, f.RepoAt(), p.PullId) 2369 if err != nil { 2370 log.Println("failed to close pull", err) 2371 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2372 return 2373 } 2374 p.State = models.PullOpen 2375 } 2376 2377 // Commit the transaction 2378 if err = tx.Commit(); err != nil { 2379 log.Println("failed to commit transaction", err) 2380 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2381 return 2382 } 2383 2384 for _, p := range pullsToReopen { 2385 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2386 } 2387 2388 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, &f.Repo) 2389 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2390} 2391 2392func newStack(f *reporesolver.ResolvedRepo, user *oauth.User, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2393 formatPatches, err := patchutil.ExtractPatches(patch) 2394 if err != nil { 2395 return nil, fmt.Errorf("Failed to extract patches: %v", err) 2396 } 2397 2398 // must have atleast 1 patch to begin with 2399 if len(formatPatches) == 0 { 2400 return nil, fmt.Errorf("No patches found in the generated format-patch.") 2401 } 2402 2403 // the stack is identified by a UUID 2404 var stack models.Stack 2405 parentChangeId := "" 2406 for _, fp := range formatPatches { 2407 // all patches must have a jj change-id 2408 changeId, err := fp.ChangeId() 2409 if err != nil { 2410 return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.") 2411 } 2412 2413 title := fp.Title 2414 body := fp.Body 2415 rkey := tid.TID() 2416 2417 initialSubmission := models.PullSubmission{ 2418 Patch: fp.Raw, 2419 SourceRev: fp.SHA, 2420 Combined: fp.Raw, 2421 } 2422 pull := models.Pull{ 2423 Title: title, 2424 Body: body, 2425 TargetBranch: targetBranch, 2426 OwnerDid: user.Did, 2427 RepoAt: f.RepoAt(), 2428 Rkey: rkey, 2429 Submissions: []*models.PullSubmission{ 2430 &initialSubmission, 2431 }, 2432 PullSource: pullSource, 2433 Created: time.Now(), 2434 2435 StackId: stackId, 2436 ChangeId: changeId, 2437 ParentChangeId: parentChangeId, 2438 } 2439 2440 stack = append(stack, &pull) 2441 2442 parentChangeId = changeId 2443 } 2444 2445 return stack, nil 2446}