this repo has no description
at sl/shared-stacks 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 43 comatproto "github.com/bluesky-social/indigo/api/atproto" 44 "github.com/bluesky-social/indigo/atproto/syntax" 45 lexutil "github.com/bluesky-social/indigo/lex/util" 46 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 47 "github.com/go-chi/chi/v5" 48 "github.com/google/uuid" 49) 50 51type Pulls struct { 52 oauth *oauth.OAuth 53 repoResolver *reporesolver.RepoResolver 54 pages *pages.Pages 55 idResolver *idresolver.Resolver 56 mentionsResolver *mentions.Resolver 57 db *db.DB 58 config *config.Config 59 notifier notify.Notifier 60 enforcer *rbac.Enforcer 61 logger *slog.Logger 62 validator *validator.Validator 63 indexer *pulls_indexer.Indexer 64} 65 66func New( 67 oauth *oauth.OAuth, 68 repoResolver *reporesolver.RepoResolver, 69 pages *pages.Pages, 70 resolver *idresolver.Resolver, 71 mentionsResolver *mentions.Resolver, 72 db *db.DB, 73 config *config.Config, 74 notifier notify.Notifier, 75 enforcer *rbac.Enforcer, 76 validator *validator.Validator, 77 indexer *pulls_indexer.Indexer, 78 logger *slog.Logger, 79) *Pulls { 80 return &Pulls{ 81 oauth: oauth, 82 repoResolver: repoResolver, 83 pages: pages, 84 idResolver: resolver, 85 mentionsResolver: mentionsResolver, 86 db: db, 87 config: config, 88 notifier: notifier, 89 enforcer: enforcer, 90 logger: logger, 91 validator: validator, 92 indexer: indexer, 93 } 94} 95 96// htmx fragment 97func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) { 98 switch r.Method { 99 case http.MethodGet: 100 user := s.oauth.GetMultiAccountUser(r) 101 f, err := s.repoResolver.Resolve(r) 102 if err != nil { 103 log.Println("failed to get repo and knot", err) 104 return 105 } 106 107 pull, ok := r.Context().Value("pull").(*models.Pull) 108 if !ok { 109 log.Println("failed to get pull") 110 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 111 return 112 } 113 114 // can be nil if this pull is not stacked 115 stack, _ := r.Context().Value("stack").(models.Stack) 116 117 roundNumberStr := chi.URLParam(r, "round") 118 roundNumber, err := strconv.Atoi(roundNumberStr) 119 if err != nil { 120 roundNumber = pull.LastRoundNumber() 121 } 122 if roundNumber >= len(pull.Submissions) { 123 http.Error(w, "bad round id", http.StatusBadRequest) 124 log.Println("failed to parse round id", err) 125 return 126 } 127 128 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 129 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 130 resubmitResult := pages.Unknown 131 if user.Did == pull.OwnerDid { 132 resubmitResult = s.resubmitCheck(r, f, pull, stack) 133 } 134 135 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 136 LoggedInUser: user, 137 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 138 Pull: pull, 139 RoundNumber: roundNumber, 140 MergeCheck: mergeCheckResponse, 141 ResubmitCheck: resubmitResult, 142 BranchDeleteStatus: branchDeleteStatus, 143 Stack: stack, 144 }) 145 return 146 } 147} 148 149func (s *Pulls) repoPullHelper(w http.ResponseWriter, r *http.Request, interdiff bool) { 150 user := s.oauth.GetMultiAccountUser(r) 151 f, err := s.repoResolver.Resolve(r) 152 if err != nil { 153 log.Println("failed to get repo and knot", err) 154 return 155 } 156 157 pull, ok := r.Context().Value("pull").(*models.Pull) 158 if !ok { 159 log.Println("failed to get pull") 160 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 161 return 162 } 163 164 backlinks, err := db.GetBacklinks(s.db, pull.AtUri()) 165 if err != nil { 166 log.Println("failed to get pull backlinks", err) 167 s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.") 168 return 169 } 170 171 roundId := chi.URLParam(r, "round") 172 roundIdInt := pull.LastRoundNumber() 173 if r, err := strconv.Atoi(roundId); err == nil { 174 roundIdInt = r 175 } 176 if roundIdInt >= len(pull.Submissions) { 177 http.Error(w, "bad round id", http.StatusBadRequest) 178 log.Println("failed to parse round id", err) 179 return 180 } 181 182 var diffOpts types.DiffOpts 183 if d := r.URL.Query().Get("diff"); d == "split" { 184 diffOpts.Split = true 185 } 186 187 // can be nil if this pull is not stacked 188 stack, _ := r.Context().Value("stack").(models.Stack) 189 abandonedPulls, _ := r.Context().Value("abandonedPulls").([]*models.Pull) 190 191 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 192 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 193 resubmitResult := pages.Unknown 194 if user != nil && user.Did == pull.OwnerDid { 195 resubmitResult = s.resubmitCheck(r, f, pull, stack) 196 } 197 198 m := make(map[string]models.Pipeline) 199 200 var shas []string 201 for _, s := range pull.Submissions { 202 shas = append(shas, s.SourceRev) 203 } 204 for _, p := range stack { 205 shas = append(shas, p.LatestSha()) 206 } 207 for _, p := range abandonedPulls { 208 shas = append(shas, p.LatestSha()) 209 } 210 211 ps, err := db.GetPipelineStatuses( 212 s.db, 213 len(shas), 214 orm.FilterEq("repo_owner", f.Did), 215 orm.FilterEq("repo_name", f.Name), 216 orm.FilterEq("knot", f.Knot), 217 orm.FilterIn("sha", shas), 218 ) 219 if err != nil { 220 log.Printf("failed to fetch pipeline statuses: %s", err) 221 // non-fatal 222 } 223 224 for _, p := range ps { 225 m[p.Sha] = p 226 } 227 228 reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri()) 229 if err != nil { 230 log.Println("failed to get pull reactions") 231 } 232 233 userReactions := map[models.ReactionKind]bool{} 234 if user != nil { 235 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri()) 236 } 237 238 labelDefs, err := db.GetLabelDefinitions( 239 s.db, 240 orm.FilterIn("at_uri", f.Labels), 241 orm.FilterContains("scope", tangled.RepoPullNSID), 242 ) 243 if err != nil { 244 log.Println("failed to fetch labels", err) 245 s.pages.Error503(w) 246 return 247 } 248 249 defs := make(map[string]*models.LabelDefinition) 250 for _, l := range labelDefs { 251 defs[l.AtUri().String()] = &l 252 } 253 254 patch := pull.Submissions[roundIdInt].CombinedPatch() 255 var diff types.DiffRenderer 256 diff = patchutil.AsNiceDiff(patch, pull.TargetBranch) 257 258 if interdiff { 259 currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 260 if err != nil { 261 log.Println("failed to interdiff; current patch malformed") 262 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 263 return 264 } 265 266 previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 267 if err != nil { 268 log.Println("failed to interdiff; previous patch malformed") 269 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 270 return 271 } 272 273 diff = patchutil.Interdiff(previousPatch, currentPatch) 274 } 275 276 s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 277 LoggedInUser: user, 278 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 279 Pull: pull, 280 Stack: stack, 281 AbandonedPulls: abandonedPulls, 282 Backlinks: backlinks, 283 BranchDeleteStatus: branchDeleteStatus, 284 MergeCheck: mergeCheckResponse, 285 ResubmitCheck: resubmitResult, 286 Pipelines: m, 287 Diff: diff, 288 DiffOpts: diffOpts, 289 ActiveRound: roundIdInt, 290 IsInterdiff: interdiff, 291 292 Reactions: reactionMap, 293 UserReacted: userReactions, 294 295 LabelDefs: defs, 296 }) 297} 298 299func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 300 pull, ok := r.Context().Value("pull").(*models.Pull) 301 if !ok { 302 log.Println("failed to get pull") 303 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 304 return 305 } 306 307 http.Redirect(w, r, r.URL.String()+fmt.Sprintf("/round/%d", pull.LastRoundNumber()), http.StatusFound) 308} 309 310func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 311 if pull.State == models.PullMerged { 312 return types.MergeCheckResponse{} 313 } 314 315 scheme := "https" 316 if s.config.Core.Dev { 317 scheme = "http" 318 } 319 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 320 321 xrpcc := indigoxrpc.Client{ 322 Host: host, 323 } 324 325 patch := pull.LatestPatch() 326 if pull.IsStacked() { 327 // combine patches of substack 328 subStack := stack.Below(pull) 329 // collect the portion of the stack that is mergeable 330 mergeable := subStack.Mergeable() 331 // combine each patch 332 patch = mergeable.CombinedPatch() 333 } 334 335 resp, xe := tangled.RepoMergeCheck( 336 r.Context(), 337 &xrpcc, 338 &tangled.RepoMergeCheck_Input{ 339 Did: f.Did, 340 Name: f.Name, 341 Branch: pull.TargetBranch, 342 Patch: patch, 343 }, 344 ) 345 if err := xrpcclient.HandleXrpcErr(xe); err != nil { 346 log.Println("failed to check for mergeability", "err", err) 347 return types.MergeCheckResponse{ 348 Error: fmt.Sprintf("failed to check merge status: %s", err.Error()), 349 } 350 } 351 352 // convert xrpc response to internal types 353 conflicts := make([]types.ConflictInfo, len(resp.Conflicts)) 354 for i, conflict := range resp.Conflicts { 355 conflicts[i] = types.ConflictInfo{ 356 Filename: conflict.Filename, 357 Reason: conflict.Reason, 358 } 359 } 360 361 result := types.MergeCheckResponse{ 362 IsConflicted: resp.Is_conflicted, 363 Conflicts: conflicts, 364 } 365 366 if resp.Message != nil { 367 result.Message = *resp.Message 368 } 369 370 if resp.Error != nil { 371 result.Error = *resp.Error 372 } 373 374 return result 375} 376 377func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus { 378 if pull.State != models.PullMerged { 379 return nil 380 } 381 382 user := s.oauth.GetMultiAccountUser(r) 383 if user == nil { 384 return nil 385 } 386 387 var branch string 388 // check if the branch exists 389 // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 390 if pull.IsBranchBased() { 391 branch = pull.PullSource.Branch 392 } else if pull.IsForkBased() { 393 branch = pull.PullSource.Branch 394 repo = pull.PullSource.Repo 395 } else { 396 return nil 397 } 398 399 // deleted fork 400 if repo == nil { 401 return nil 402 } 403 404 // user can only delete branch if they are a collaborator in the repo that the branch belongs to 405 perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.DidSlashRepo()) 406 if !slices.Contains(perms, "repo:push") { 407 return nil 408 } 409 410 scheme := "http" 411 if !s.config.Core.Dev { 412 scheme = "https" 413 } 414 host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 415 xrpcc := &indigoxrpc.Client{ 416 Host: host, 417 } 418 419 resp, err := tangled.RepoBranch(r.Context(), xrpcc, branch, fmt.Sprintf("%s/%s", repo.Did, repo.Name)) 420 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 421 return nil 422 } 423 424 return &models.BranchDeleteStatus{ 425 Repo: repo, 426 Branch: resp.Name, 427 } 428} 429 430func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 431 if pull.State == models.PullMerged || pull.State == models.PullDeleted || pull.PullSource == nil { 432 return pages.Unknown 433 } 434 435 var knot, ownerDid, repoName string 436 437 if pull.PullSource.RepoAt != nil { 438 // fork-based pulls 439 sourceRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 440 if err != nil { 441 log.Println("failed to get source repo", err) 442 return pages.Unknown 443 } 444 445 knot = sourceRepo.Knot 446 ownerDid = sourceRepo.Did 447 repoName = sourceRepo.Name 448 } else { 449 // pulls within the same repo 450 knot = repo.Knot 451 ownerDid = repo.Did 452 repoName = repo.Name 453 } 454 455 scheme := "http" 456 if !s.config.Core.Dev { 457 scheme = "https" 458 } 459 host := fmt.Sprintf("%s://%s", scheme, knot) 460 xrpcc := &indigoxrpc.Client{ 461 Host: host, 462 } 463 464 didSlashName := fmt.Sprintf("%s/%s", ownerDid, repoName) 465 branchResp, err := tangled.RepoBranch(r.Context(), xrpcc, pull.PullSource.Branch, didSlashName) 466 if err != nil { 467 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 468 log.Println("failed to call XRPC repo.branches", xrpcerr) 469 return pages.Unknown 470 } 471 log.Println("failed to reach knotserver", err) 472 return pages.Unknown 473 } 474 475 targetBranch := branchResp 476 477 latestSourceRev := pull.LatestSha() 478 479 if pull.IsStacked() && stack != nil { 480 top := stack[0] 481 latestSourceRev = top.LatestSha() 482 } 483 484 if latestSourceRev != targetBranch.Hash { 485 return pages.ShouldResubmit 486 } 487 488 return pages.ShouldNotResubmit 489} 490 491func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 492 s.repoPullHelper(w, r, false) 493} 494 495func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 496 s.repoPullHelper(w, r, true) 497} 498 499func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 500 pull, ok := r.Context().Value("pull").(*models.Pull) 501 if !ok { 502 log.Println("failed to get pull") 503 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 504 return 505 } 506 507 roundId := chi.URLParam(r, "round") 508 roundIdInt, err := strconv.Atoi(roundId) 509 if err != nil || roundIdInt >= len(pull.Submissions) { 510 http.Error(w, "bad round id", http.StatusBadRequest) 511 log.Println("failed to parse round id", err) 512 return 513 } 514 515 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 516 w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 517} 518 519func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 520 l := s.logger.With("handler", "RepoPulls") 521 522 user := s.oauth.GetMultiAccountUser(r) 523 params := r.URL.Query() 524 525 state := models.PullOpen 526 switch params.Get("state") { 527 case "closed": 528 state = models.PullClosed 529 case "merged": 530 state = models.PullMerged 531 } 532 533 page := pagination.FromContext(r.Context()) 534 535 f, err := s.repoResolver.Resolve(r) 536 if err != nil { 537 log.Println("failed to get repo and knot", err) 538 return 539 } 540 541 var totalPulls int 542 switch state { 543 case models.PullOpen: 544 totalPulls = f.RepoStats.PullCount.Open 545 case models.PullMerged: 546 totalPulls = f.RepoStats.PullCount.Merged 547 case models.PullClosed: 548 totalPulls = f.RepoStats.PullCount.Closed 549 } 550 551 keyword := params.Get("q") 552 553 var pulls []*models.Pull 554 searchOpts := models.PullSearchOptions{ 555 Keyword: keyword, 556 RepoAt: f.RepoAt().String(), 557 State: state, 558 Page: page, 559 } 560 l.Debug("searching with", "searchOpts", searchOpts) 561 if keyword != "" { 562 res, err := s.indexer.Search(r.Context(), searchOpts) 563 if err != nil { 564 l.Error("failed to search for pulls", "err", err) 565 return 566 } 567 totalPulls = int(res.Total) 568 l.Debug("searched pulls with indexer", "count", len(res.Hits)) 569 570 pulls, err = db.GetPulls( 571 s.db, 572 orm.FilterIn("id", res.Hits), 573 ) 574 if err != nil { 575 log.Println("failed to get pulls", err) 576 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 577 return 578 } 579 } else { 580 pulls, err = db.GetPullsPaginated( 581 s.db, 582 page, 583 orm.FilterEq("repo_at", f.RepoAt()), 584 orm.FilterEq("state", searchOpts.State), 585 ) 586 if err != nil { 587 log.Println("failed to get pulls", err) 588 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 589 return 590 } 591 } 592 593 for _, p := range pulls { 594 var pullSourceRepo *models.Repo 595 if p.PullSource != nil { 596 if p.PullSource.RepoAt != nil { 597 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) 598 if err != nil { 599 log.Printf("failed to get repo by at uri: %v", err) 600 continue 601 } else { 602 p.PullSource.Repo = pullSourceRepo 603 } 604 } 605 } 606 } 607 608 // we want to group all stacked PRs into just one list 609 stacks := make(map[string]models.Stack) 610 var shas []string 611 n := 0 612 for _, p := range pulls { 613 // store the sha for later 614 shas = append(shas, p.LatestSha()) 615 // this PR is stacked 616 if p.StackId != "" { 617 // we have already seen this PR stack 618 if _, seen := stacks[p.StackId]; seen { 619 stacks[p.StackId] = append(stacks[p.StackId], p) 620 // skip this PR 621 } else { 622 stacks[p.StackId] = nil 623 pulls[n] = p 624 n++ 625 } 626 } else { 627 pulls[n] = p 628 n++ 629 } 630 } 631 pulls = pulls[:n] 632 633 ps, err := db.GetPipelineStatuses( 634 s.db, 635 len(shas), 636 orm.FilterEq("repo_owner", f.Did), 637 orm.FilterEq("repo_name", f.Name), 638 orm.FilterEq("knot", f.Knot), 639 orm.FilterIn("sha", shas), 640 ) 641 if err != nil { 642 log.Printf("failed to fetch pipeline statuses: %s", err) 643 // non-fatal 644 } 645 m := make(map[string]models.Pipeline) 646 for _, p := range ps { 647 m[p.Sha] = p 648 } 649 650 labelDefs, err := db.GetLabelDefinitions( 651 s.db, 652 orm.FilterIn("at_uri", f.Labels), 653 orm.FilterContains("scope", tangled.RepoPullNSID), 654 ) 655 if err != nil { 656 log.Println("failed to fetch labels", err) 657 s.pages.Error503(w) 658 return 659 } 660 661 defs := make(map[string]*models.LabelDefinition) 662 for _, l := range labelDefs { 663 defs[l.AtUri().String()] = &l 664 } 665 666 s.pages.RepoPulls(w, pages.RepoPullsParams{ 667 LoggedInUser: s.oauth.GetMultiAccountUser(r), 668 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 669 Pulls: pulls, 670 LabelDefs: defs, 671 FilteringBy: state, 672 FilterQuery: keyword, 673 Stacks: stacks, 674 Pipelines: m, 675 Page: page, 676 PullCount: totalPulls, 677 }) 678} 679 680func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 681 user := s.oauth.GetMultiAccountUser(r) 682 f, err := s.repoResolver.Resolve(r) 683 if err != nil { 684 log.Println("failed to get repo and knot", err) 685 return 686 } 687 688 pull, ok := r.Context().Value("pull").(*models.Pull) 689 if !ok { 690 log.Println("failed to get pull") 691 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 692 return 693 } 694 695 roundNumberStr := chi.URLParam(r, "round") 696 roundNumber, err := strconv.Atoi(roundNumberStr) 697 if err != nil || roundNumber >= len(pull.Submissions) { 698 http.Error(w, "bad round id", http.StatusBadRequest) 699 log.Println("failed to parse round id", err) 700 return 701 } 702 703 switch r.Method { 704 case http.MethodGet: 705 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ 706 LoggedInUser: user, 707 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 708 Pull: pull, 709 RoundNumber: roundNumber, 710 }) 711 return 712 case http.MethodPost: 713 body := r.FormValue("body") 714 if body == "" { 715 s.pages.Notice(w, "pull", "Comment body is required") 716 return 717 } 718 719 mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 720 721 // Start a transaction 722 tx, err := s.db.BeginTx(r.Context(), nil) 723 if err != nil { 724 log.Println("failed to start transaction", err) 725 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 726 return 727 } 728 defer tx.Rollback() 729 730 comment := models.Comment{ 731 Did: syntax.DID(user.Did), 732 Collection: tangled.CommentNSID, 733 Rkey: tid.TID(), 734 Subject: pull.AtUri(), 735 ReplyTo: nil, 736 Body: body, 737 Created: time.Now(), 738 Mentions: mentions, 739 References: references, 740 PullSubmissionId: &pull.Submissions[roundNumber].ID, 741 } 742 if err = comment.Validate(); err != nil { 743 log.Println("failed to validate comment", err) 744 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 745 return 746 } 747 748 client, err := s.oauth.AuthorizedClient(r) 749 if err != nil { 750 log.Println("failed to get authorized client", err) 751 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 752 return 753 } 754 755 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 756 Collection: comment.Collection.String(), 757 Repo: comment.Did.String(), 758 Rkey: comment.Rkey, 759 Record: &lexutil.LexiconTypeDecoder{ 760 Val: comment.AsRecord(), 761 }, 762 }) 763 if err != nil { 764 log.Println("failed to create pull comment", err) 765 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 766 return 767 } 768 769 // Create the pull comment in the database with the commentAt field 770 err = db.PutComment(tx, &comment) 771 if err != nil { 772 log.Println("failed to create pull comment", err) 773 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 774 return 775 } 776 777 // Commit the transaction 778 if err = tx.Commit(); err != nil { 779 log.Println("failed to commit transaction", err) 780 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 781 return 782 } 783 784 s.notifier.NewComment(r.Context(), &comment) 785 786 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 787 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, comment.Id)) 788 return 789 } 790} 791 792func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) { 793 user := s.oauth.GetMultiAccountUser(r) 794 f, err := s.repoResolver.Resolve(r) 795 if err != nil { 796 log.Println("failed to get repo and knot", err) 797 return 798 } 799 800 switch r.Method { 801 case http.MethodGet: 802 scheme := "http" 803 if !s.config.Core.Dev { 804 scheme = "https" 805 } 806 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 807 xrpcc := &indigoxrpc.Client{ 808 Host: host, 809 } 810 811 repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 812 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 813 if err != nil { 814 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 815 log.Println("failed to call XRPC repo.branches", xrpcerr) 816 s.pages.Error503(w) 817 return 818 } 819 log.Println("failed to fetch branches", err) 820 return 821 } 822 823 var result types.RepoBranchesResponse 824 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 825 log.Println("failed to decode XRPC response", err) 826 s.pages.Error503(w) 827 return 828 } 829 830 // can be one of "patch", "branch" or "fork" 831 strategy := r.URL.Query().Get("strategy") 832 // ignored if strategy is "patch" 833 sourceBranch := r.URL.Query().Get("sourceBranch") 834 targetBranch := r.URL.Query().Get("targetBranch") 835 836 s.pages.RepoNewPull(w, pages.RepoNewPullParams{ 837 LoggedInUser: user, 838 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 839 Branches: result.Branches, 840 Strategy: strategy, 841 SourceBranch: sourceBranch, 842 TargetBranch: targetBranch, 843 Title: r.URL.Query().Get("title"), 844 Body: r.URL.Query().Get("body"), 845 }) 846 847 case http.MethodPost: 848 title := r.FormValue("title") 849 body := r.FormValue("body") 850 targetBranch := r.FormValue("targetBranch") 851 fromFork := r.FormValue("fork") 852 sourceBranch := r.FormValue("sourceBranch") 853 patch := r.FormValue("patch") 854 userDid := syntax.DID(user.Did) 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(userDid.String(), 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, userDid, 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, userDid, 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, userDid, 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 userDid syntax.DID, 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, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1017} 1018 1019func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, 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, userDid, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 1027} 1028 1029func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, 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, userDid, 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 userDid syntax.DID, 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 userDid, 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: userDid.String(), 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 := comatproto.RepoUploadBlob(r.Context(), client, gz(patch)) 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: userDid.String(), 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 userDid syntax.DID, 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, userDid, 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 := comatproto.RepoUploadBlob(r.Context(), client, gz(p.LatestPatch())) 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: userDid.String(), 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.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 if user == nil || user.Did != pull.OwnerDid { 1632 log.Println("unauthorized user") 1633 w.WriteHeader(http.StatusUnauthorized) 1634 return 1635 } 1636 1637 f, err := s.repoResolver.Resolve(r) 1638 if err != nil { 1639 log.Println("failed to get repo and knot", err) 1640 return 1641 } 1642 1643 patch := r.FormValue("patch") 1644 1645 s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), 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 if user == nil || user.Did != pull.OwnerDid { 1659 log.Println("unauthorized user") 1660 w.WriteHeader(http.StatusUnauthorized) 1661 return 1662 } 1663 1664 f, err := s.repoResolver.Resolve(r) 1665 if err != nil { 1666 log.Println("failed to get repo and knot", err) 1667 return 1668 } 1669 1670 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.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, syntax.DID(user.Did), 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 if user == nil || user.Did != pull.OwnerDid { 1724 log.Println("unauthorized user") 1725 w.WriteHeader(http.StatusUnauthorized) 1726 return 1727 } 1728 1729 f, err := s.repoResolver.Resolve(r) 1730 if err != nil { 1731 log.Println("failed to get repo and knot", err) 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, syntax.DID(user.Did), pull, patch, combined, sourceRev) 1808} 1809 1810func (s *Pulls) resubmitPullHelper( 1811 w http.ResponseWriter, 1812 r *http.Request, 1813 repo *models.Repo, 1814 userDid syntax.DID, 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, userDid, 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, userDid.String(), 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 := comatproto.RepoUploadBlob(r.Context(), client, gz(patch)) 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: userDid.String(), 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 userDid syntax.DID, 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, userDid, 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 := comatproto.RepoUploadBlob(r.Context(), client, gz(patch)) 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 := comatproto.RepoUploadBlob(r.Context(), client, gz(patch)) 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: userDid.String(), 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.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.Did, f.Knot, f.DidSlashRepo())} 2257 isOwner := roles.IsOwner() 2258 isCollaborator := roles.IsCollaborator() 2259 isPullAuthor := user.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.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.Did, f.Knot, f.DidSlashRepo())} 2331 isOwner := roles.IsOwner() 2332 isCollaborator := roles.IsCollaborator() 2333 isPullAuthor := user.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.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, userDid syntax.DID, 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: userDid.String(), 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}