Monorepo for Tangled tangled.org
816
fork

Configure Feed

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

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