Monorepo for Tangled tangled.org
1

Configure Feed

Select the types of activity you want to include in your feed.

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