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