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