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