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