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