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