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