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