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