This repository has no description
0

Configure Feed

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

core / appview / pulls / pulls.go
80 kB 2677 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, 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.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 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 && user.Active != nil { 507 l = l.With("user", user.Active.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 && user.Active != nil { 804 l = l.With("user", user.Active.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.Active.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.Active.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 && user.Active != nil { 921 l = l.With("user", user.Active.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 978 if targetBranch == "" { 979 s.pages.Notice(w, "pull", "Target branch is required.") 980 return 981 } 982 983 // Determine PR type based on input parameters 984 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.RepoIdentifier())} 985 isPushAllowed := roles.IsPushAllowed() 986 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 987 isForkBased := fromFork != "" && sourceBranch != "" 988 isPatchBased := patch != "" && !isBranchBased && !isForkBased 989 isStacked := r.FormValue("isStacked") == "on" 990 991 if isPatchBased && !patchutil.IsFormatPatch(patch) { 992 if title == "" { 993 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 994 return 995 } 996 sanitizer := markup.NewSanitizer() 997 if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 998 s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 999 return 1000 } 1001 } 1002 1003 // Validate we have at least one valid PR creation method 1004 if !isBranchBased && !isPatchBased && !isForkBased { 1005 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 1006 return 1007 } 1008 1009 // Can't mix branch-based and patch-based approaches 1010 if isBranchBased && patch != "" { 1011 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 1012 return 1013 } 1014 1015 // us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1016 // if err != nil { 1017 // log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 1018 // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 1019 // return 1020 // } 1021 1022 // TODO: make capabilities an xrpc call 1023 caps := struct { 1024 PullRequests struct { 1025 FormatPatch bool 1026 BranchSubmissions bool 1027 ForkSubmissions bool 1028 PatchSubmissions bool 1029 } 1030 }{ 1031 PullRequests: struct { 1032 FormatPatch bool 1033 BranchSubmissions bool 1034 ForkSubmissions bool 1035 PatchSubmissions bool 1036 }{ 1037 FormatPatch: true, 1038 BranchSubmissions: true, 1039 ForkSubmissions: true, 1040 PatchSubmissions: true, 1041 }, 1042 } 1043 1044 // caps, err := us.Capabilities() 1045 // if err != nil { 1046 // log.Println("error fetching knot caps", f.Knot, err) 1047 // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 1048 // return 1049 // } 1050 1051 if !caps.PullRequests.FormatPatch { 1052 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") 1053 return 1054 } 1055 1056 // Handle the PR creation based on the type 1057 if isBranchBased { 1058 if !caps.PullRequests.BranchSubmissions { 1059 s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") 1060 return 1061 } 1062 s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch, isStacked) 1063 } else if isForkBased { 1064 if !caps.PullRequests.ForkSubmissions { 1065 s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") 1066 return 1067 } 1068 s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch, isStacked) 1069 } else if isPatchBased { 1070 if !caps.PullRequests.PatchSubmissions { 1071 s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") 1072 return 1073 } 1074 s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch, isStacked) 1075 } 1076 return 1077 } 1078} 1079 1080func (s *Pulls) handleBranchBasedPull( 1081 w http.ResponseWriter, 1082 r *http.Request, 1083 repo *models.Repo, 1084 user *oauth.MultiAccountUser, 1085 title, 1086 body, 1087 targetBranch, 1088 sourceBranch string, 1089 isStacked bool, 1090) { 1091 l := s.logger.With("handler", "handleBranchBasedPull", "user", user.Active.Did, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked) 1092 1093 scheme := "http" 1094 if !s.config.Core.Dev { 1095 scheme = "https" 1096 } 1097 host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 1098 xrpcc := &indigoxrpc.Client{ 1099 Host: host, 1100 } 1101 1102 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch) 1103 if err != nil { 1104 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1105 l.Error("failed to call XRPC repo.compare", "xrpcerr", xrpcerr, "err", err) 1106 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1107 return 1108 } 1109 l.Error("failed to compare", "err", err) 1110 s.pages.Notice(w, "pull", err.Error()) 1111 return 1112 } 1113 1114 var comparison types.RepoFormatPatchResponse 1115 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 1116 l.Error("failed to decode XRPC compare response", "err", err) 1117 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1118 return 1119 } 1120 1121 sourceRev := comparison.Rev2 1122 patch := comparison.FormatPatchRaw 1123 combined := comparison.CombinedPatchRaw 1124 1125 if err := s.validator.ValidatePatch(&patch); err != nil { 1126 s.logger.Error("failed to validate patch", "err", err) 1127 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1128 return 1129 } 1130 1131 pullSource := &models.PullSource{ 1132 Branch: sourceBranch, 1133 } 1134 recordPullSource := &tangled.RepoPull_Source{ 1135 Branch: sourceBranch, 1136 } 1137 1138 s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1139} 1140 1141func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.MultiAccountUser, title, body, targetBranch, patch string, isStacked bool) { 1142 if err := s.validator.ValidatePatch(&patch); err != nil { 1143 s.logger.Error("patch validation failed", "err", err) 1144 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1145 return 1146 } 1147 1148 s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 1149} 1150 1151func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.MultiAccountUser, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 1152 l := s.logger.With("handler", "handleForkBasedPull", "user", user.Active.Did, "fork_repo", forkRepo, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked) 1153 1154 repoString := strings.SplitN(forkRepo, "/", 2) 1155 forkOwnerDid := repoString[0] 1156 repoName := repoString[1] 1157 fork, err := db.GetForkByDid(s.db, forkOwnerDid, repoName) 1158 if errors.Is(err, sql.ErrNoRows) { 1159 s.pages.Notice(w, "pull", "No such fork.") 1160 return 1161 } else if err != nil { 1162 l.Error("failed to fetch fork", "err", err, "fork_owner_did", forkOwnerDid, "repo_name", repoName) 1163 s.pages.Notice(w, "pull", "Failed to fetch fork.") 1164 return 1165 } 1166 1167 client, err := s.oauth.ServiceClient( 1168 r, 1169 oauth.WithService(fork.Knot), 1170 oauth.WithLxm(tangled.RepoHiddenRefNSID), 1171 oauth.WithDev(s.config.Core.Dev), 1172 ) 1173 1174 resp, err := tangled.RepoHiddenRef( 1175 r.Context(), 1176 client, 1177 &tangled.RepoHiddenRef_Input{ 1178 ForkRef: sourceBranch, 1179 RemoteRef: targetBranch, 1180 Repo: fork.RepoAt().String(), 1181 }, 1182 ) 1183 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1184 s.logger.Error("failed to set hidden ref", "xrpcerr", xrpcerr, "err", err) 1185 s.pages.Notice(w, "pull", xrpcerr.Error()) 1186 return 1187 } 1188 1189 if !resp.Success { 1190 errorMsg := "Failed to create pull request" 1191 if resp.Error != nil { 1192 errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error) 1193 } 1194 s.pages.Notice(w, "pull", errorMsg) 1195 return 1196 } 1197 1198 hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 1199 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking 1200 // the targetBranch on the target repository. This code is a bit confusing, but here's an example: 1201 // hiddenRef: hidden/feature-1/main (on repo-fork) 1202 // targetBranch: main (on repo-1) 1203 // sourceBranch: feature-1 (on repo-fork) 1204 forkScheme := "http" 1205 if !s.config.Core.Dev { 1206 forkScheme = "https" 1207 } 1208 forkHost := fmt.Sprintf("%s://%s", forkScheme, fork.Knot) 1209 forkXrpcc := &indigoxrpc.Client{ 1210 Host: forkHost, 1211 } 1212 1213 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch) 1214 if err != nil { 1215 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1216 l.Error("failed to call XRPC repo.compare for fork", "xrpcerr", xrpcerr, "err", err, "hidden_ref", hiddenRef) 1217 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1218 return 1219 } 1220 l.Error("failed to compare across branches", "err", err, "hidden_ref", hiddenRef) 1221 s.pages.Notice(w, "pull", err.Error()) 1222 return 1223 } 1224 1225 var comparison types.RepoFormatPatchResponse 1226 if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 1227 l.Error("failed to decode XRPC compare response for fork", "err", err) 1228 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1229 return 1230 } 1231 1232 sourceRev := comparison.Rev2 1233 patch := comparison.FormatPatchRaw 1234 combined := comparison.CombinedPatchRaw 1235 1236 if err := s.validator.ValidatePatch(&patch); err != nil { 1237 s.logger.Error("failed to validate patch", "err", err) 1238 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1239 return 1240 } 1241 1242 forkAtUri := fork.RepoAt() 1243 forkAtUriStr := forkAtUri.String() 1244 1245 pullSource := &models.PullSource{ 1246 Branch: sourceBranch, 1247 RepoAt: &forkAtUri, 1248 } 1249 recordPullSource := &tangled.RepoPull_Source{ 1250 Branch: sourceBranch, 1251 Repo: &forkAtUriStr, 1252 } 1253 if fork.RepoDid != "" { 1254 recordPullSource.RepoDid = &fork.RepoDid 1255 } 1256 1257 s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1258} 1259 1260func (s *Pulls) createPullRequest( 1261 w http.ResponseWriter, 1262 r *http.Request, 1263 repo *models.Repo, 1264 user *oauth.MultiAccountUser, 1265 title, body, targetBranch string, 1266 patch string, 1267 combined string, 1268 sourceRev string, 1269 pullSource *models.PullSource, 1270 recordPullSource *tangled.RepoPull_Source, 1271 isStacked bool, 1272) { 1273 l := s.logger.With("handler", "createPullRequest", "user", user.Active.Did, "target_branch", targetBranch, "is_stacked", isStacked) 1274 1275 if isStacked { 1276 // creates a series of PRs, each linking to the previous, identified by jj's change-id 1277 s.createStackedPullRequest( 1278 w, 1279 r, 1280 repo, 1281 user, 1282 targetBranch, 1283 patch, 1284 sourceRev, 1285 pullSource, 1286 ) 1287 return 1288 } 1289 1290 client, err := s.oauth.AuthorizedClient(r) 1291 if err != nil { 1292 l.Error("failed to get authorized client", "err", err) 1293 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1294 return 1295 } 1296 1297 tx, err := s.db.BeginTx(r.Context(), nil) 1298 if err != nil { 1299 l.Error("failed to start tx", "err", err) 1300 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1301 return 1302 } 1303 defer tx.Rollback() 1304 1305 // We've already checked earlier if it's diff-based and title is empty, 1306 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1307 if title == "" || body == "" { 1308 formatPatches, err := patchutil.ExtractPatches(patch) 1309 if err != nil { 1310 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 1311 return 1312 } 1313 if len(formatPatches) == 0 { 1314 s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.") 1315 return 1316 } 1317 1318 if title == "" { 1319 title = formatPatches[0].Title 1320 } 1321 if body == "" { 1322 body = formatPatches[0].Body 1323 } 1324 } 1325 1326 mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 1327 1328 rkey := tid.TID() 1329 1330 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 1331 if err != nil { 1332 l.Error("failed to upload patch", "err", err) 1333 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1334 return 1335 } 1336 1337 now := time.Now() 1338 1339 initialSubmission := models.PullSubmission{ 1340 Patch: patch, 1341 Combined: combined, 1342 SourceRev: sourceRev, 1343 Blob: *blob.Blob, 1344 Created: time.Now(), 1345 } 1346 pull := &models.Pull{ 1347 Title: title, 1348 Body: body, 1349 TargetBranch: targetBranch, 1350 OwnerDid: user.Active.Did, 1351 RepoAt: repo.RepoAt(), 1352 Rkey: rkey, 1353 Mentions: mentions, 1354 References: references, 1355 Submissions: []*models.PullSubmission{ 1356 &initialSubmission, 1357 }, 1358 PullSource: pullSource, 1359 State: models.PullOpen, 1360 Created: now, 1361 } 1362 1363 record := tangled.RepoPull{ 1364 Title: title, 1365 Body: &body, 1366 Target: repoPullTarget(repo, targetBranch), 1367 Source: recordPullSource, 1368 CreatedAt: time.Now().Format(time.RFC3339), 1369 Rounds: []*tangled.RepoPull_Round{ 1370 initialSubmission.AsRecord(), 1371 }, 1372 Mentions: nil, 1373 References: nil, 1374 } 1375 1376 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1377 Collection: tangled.RepoPullNSID, 1378 Repo: user.Active.Did, 1379 Rkey: rkey, 1380 Record: &lexutil.LexiconTypeDecoder{ 1381 Val: &record, 1382 }, 1383 }) 1384 if err != nil { 1385 l.Error("failed to create pull request", "err", err) 1386 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1387 return 1388 } 1389 1390 err = db.PutPull(tx, pull) 1391 if err != nil { 1392 l.Error("failed to create pull request in database", "err", err) 1393 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1394 return 1395 } 1396 pullId, err := db.NextPullId(tx, repo.RepoAt()) 1397 if err != nil { 1398 s.logger.Error("failed to get pull id", "err", err) 1399 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1400 return 1401 } 1402 1403 if err = tx.Commit(); err != nil { 1404 l.Error("failed to commit transaction for pull request", "err", err) 1405 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1406 return 1407 } 1408 1409 s.notifier.NewPull(r.Context(), pull) 1410 1411 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1412 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId)) 1413} 1414 1415func (s *Pulls) createStackedPullRequest( 1416 w http.ResponseWriter, 1417 r *http.Request, 1418 repo *models.Repo, 1419 user *oauth.MultiAccountUser, 1420 targetBranch string, 1421 patch string, 1422 sourceRev string, 1423 pullSource *models.PullSource, 1424) { 1425 l := s.logger.With("handler", "createStackedPullRequest", "user", user.Active.Did, "target_branch", targetBranch, "source_rev", sourceRev) 1426 1427 // run some necessary checks for stacked-prs first 1428 1429 // must be branch or fork based 1430 if sourceRev == "" { 1431 l.Error("stacked PR from patch-based pull") 1432 s.pages.Notice(w, "pull", "Stacking is only supported on branch and fork based pull-requests.") 1433 return 1434 } 1435 1436 formatPatches, err := patchutil.ExtractPatches(patch) 1437 if err != nil { 1438 l.Error("failed to extract patches", "err", err) 1439 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 1440 return 1441 } 1442 1443 // must have atleast 1 patch to begin with 1444 if len(formatPatches) == 0 { 1445 l.Error("empty patches") 1446 s.pages.Notice(w, "pull", "No patches found in the generated format-patch.") 1447 return 1448 } 1449 1450 client, err := s.oauth.AuthorizedClient(r) 1451 if err != nil { 1452 l.Error("failed to get authorized client", "err", err) 1453 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1454 return 1455 } 1456 1457 // first upload all blobs 1458 blobs := make([]*lexutil.LexBlob, len(formatPatches)) 1459 for i, p := range formatPatches { 1460 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.Raw), ApplicationGzip) 1461 if err != nil { 1462 l.Error("failed to upload patch blob", "err", err, "patch_index", i) 1463 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1464 return 1465 } 1466 l.Info("uploaded blob", "idx", i+1, "total", len(formatPatches)) 1467 blobs[i] = blob.Blob 1468 } 1469 1470 // build a stack out of this patch 1471 stack, err := s.newStack(r.Context(), repo, user, targetBranch, pullSource, formatPatches, blobs) 1472 if err != nil { 1473 l.Error("failed to create stack", "err", err) 1474 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) 1475 return 1476 } 1477 1478 // apply all record creations at once 1479 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1480 for _, p := range stack { 1481 record := p.AsRecord() 1482 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1483 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1484 Collection: tangled.RepoPullNSID, 1485 Rkey: &p.Rkey, 1486 Value: &lexutil.LexiconTypeDecoder{ 1487 Val: &record, 1488 }, 1489 }, 1490 }) 1491 } 1492 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1493 Repo: user.Active.Did, 1494 Writes: writes, 1495 }) 1496 if err != nil { 1497 l.Error("failed to create stacked pull request", "err", err) 1498 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 1499 return 1500 } 1501 1502 // create all pulls at once 1503 tx, err := s.db.BeginTx(r.Context(), nil) 1504 if err != nil { 1505 l.Error("failed to start tx", "err", err) 1506 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1507 return 1508 } 1509 defer tx.Rollback() 1510 1511 for _, p := range stack { 1512 err = db.PutPull(tx, p) 1513 if err != nil { 1514 l.Error("failed to create pull request in database", "err", err, "pull_rkey", p.Rkey) 1515 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1516 return 1517 } 1518 1519 } 1520 1521 if err = tx.Commit(); err != nil { 1522 l.Error("failed to commit transaction for pull requests", "err", err) 1523 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1524 return 1525 } 1526 1527 // notify about each pull 1528 // 1529 // this is performed after tx.Commit, because it could result in a locked DB otherwise 1530 for _, p := range stack { 1531 s.notifier.NewPull(r.Context(), p) 1532 } 1533 1534 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1535 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo)) 1536} 1537 1538func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) { 1539 l := s.logger.With("handler", "ValidatePatch") 1540 1541 _, err := s.repoResolver.Resolve(r) 1542 if err != nil { 1543 l.Error("failed to get repo and knot", "err", err) 1544 return 1545 } 1546 1547 patch := r.FormValue("patch") 1548 if patch == "" { 1549 s.pages.Notice(w, "patch-error", "Patch is required.") 1550 return 1551 } 1552 1553 if err := s.validator.ValidatePatch(&patch); err != nil { 1554 l.Error("failed to validate patch", "err", err) 1555 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1556 return 1557 } 1558 1559 if patchutil.IsFormatPatch(patch) { 1560 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.") 1561 } else { 1562 s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.") 1563 } 1564} 1565 1566func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 1567 user := s.oauth.GetMultiAccountUser(r) 1568 1569 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 1570 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1571 }) 1572} 1573 1574func (s *Pulls) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { 1575 l := s.logger.With("handler", "CompareBranchesFragment") 1576 1577 user := s.oauth.GetMultiAccountUser(r) 1578 f, err := s.repoResolver.Resolve(r) 1579 if err != nil { 1580 l.Error("failed to get repo and knot", "err", err) 1581 return 1582 } 1583 1584 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 1585 1586 xrpcBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, f.RepoAt().String()) 1587 if err != nil { 1588 l.Error("failed to fetch branches", "err", err) 1589 s.pages.Error503(w) 1590 return 1591 } 1592 1593 var result types.RepoBranchesResponse 1594 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1595 l.Error("failed to decode XRPC response", "err", err) 1596 s.pages.Error503(w) 1597 return 1598 } 1599 1600 branches := result.Branches 1601 sort.Slice(branches, func(i int, j int) bool { 1602 return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When) 1603 }) 1604 1605 withoutDefault := []types.Branch{} 1606 for _, b := range branches { 1607 if b.IsDefault { 1608 continue 1609 } 1610 withoutDefault = append(withoutDefault, b) 1611 } 1612 1613 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 1614 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1615 Branches: withoutDefault, 1616 }) 1617} 1618 1619func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 1620 l := s.logger.With("handler", "CompareForksFragment") 1621 1622 user := s.oauth.GetMultiAccountUser(r) 1623 if user != nil && user.Active != nil { 1624 l = l.With("user", user.Active.Did) 1625 } 1626 1627 forks, err := db.GetForksByDid(s.db, user.Active.Did) 1628 if err != nil { 1629 l.Error("failed to get forks", "err", err) 1630 return 1631 } 1632 1633 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ 1634 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1635 Forks: forks, 1636 Selected: r.URL.Query().Get("fork"), 1637 }) 1638} 1639 1640func (s *Pulls) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { 1641 l := s.logger.With("handler", "CompareForksBranchesFragment") 1642 1643 user := s.oauth.GetMultiAccountUser(r) 1644 if user != nil && user.Active != nil { 1645 l = l.With("user", user.Active.Did) 1646 } 1647 1648 f, err := s.repoResolver.Resolve(r) 1649 if err != nil { 1650 l.Error("failed to get repo and knot", "err", err) 1651 return 1652 } 1653 1654 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 1655 1656 forkVal := r.URL.Query().Get("fork") 1657 repoString := strings.SplitN(forkVal, "/", 2) 1658 forkOwnerDid := repoString[0] 1659 forkName := repoString[1] 1660 // fork repo 1661 repo, err := db.GetRepo( 1662 s.db, 1663 orm.FilterEq("did", forkOwnerDid), 1664 orm.FilterEq("name", forkName), 1665 ) 1666 if err != nil { 1667 l.Error("failed to get repo", "fork_owner_did", forkOwnerDid, "fork_name", forkName, "err", err) 1668 return 1669 } 1670 1671 sourceXrpcBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, repo.RepoAt().String()) 1672 if err != nil { 1673 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1674 l.Error("failed to call XRPC repo.branches for source", "xrpcerr", xrpcerr, "err", err) 1675 s.pages.Error503(w) 1676 return 1677 } 1678 l.Error("failed to fetch source branches", "err", err) 1679 return 1680 } 1681 1682 // Decode source branches 1683 var sourceBranches types.RepoBranchesResponse 1684 if err := json.Unmarshal(sourceXrpcBytes, &sourceBranches); err != nil { 1685 l.Error("failed to decode source branches XRPC response", "err", err) 1686 s.pages.Error503(w) 1687 return 1688 } 1689 1690 targetXrpcBytes, err := tangled.GitTempListBranches(r.Context(), xrpcc, "", 0, f.RepoAt().String()) 1691 if err != nil { 1692 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1693 l.Error("failed to call XRPC repo.branches for target", "xrpcerr", xrpcerr, "err", err) 1694 s.pages.Error503(w) 1695 return 1696 } 1697 l.Error("failed to fetch target branches", "err", err) 1698 return 1699 } 1700 1701 // Decode target branches 1702 var targetBranches types.RepoBranchesResponse 1703 if err := json.Unmarshal(targetXrpcBytes, &targetBranches); err != nil { 1704 l.Error("failed to decode target branches XRPC response", "err", err) 1705 s.pages.Error503(w) 1706 return 1707 } 1708 1709 sort.Slice(sourceBranches.Branches, func(i int, j int) bool { 1710 return sourceBranches.Branches[i].Commit.Committer.When.After(sourceBranches.Branches[j].Commit.Committer.When) 1711 }) 1712 1713 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1714 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1715 SourceBranches: sourceBranches.Branches, 1716 TargetBranches: targetBranches.Branches, 1717 }) 1718} 1719 1720func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { 1721 l := s.logger.With("handler", "ResubmitPull") 1722 1723 user := s.oauth.GetMultiAccountUser(r) 1724 if user != nil && user.Active != nil { 1725 l = l.With("user", user.Active.Did) 1726 } 1727 1728 pull, ok := r.Context().Value("pull").(*models.Pull) 1729 if !ok { 1730 l.Error("failed to get pull") 1731 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1732 return 1733 } 1734 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 1735 1736 switch r.Method { 1737 case http.MethodGet: 1738 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 1739 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1740 Pull: pull, 1741 }) 1742 return 1743 case http.MethodPost: 1744 if pull.IsPatchBased() { 1745 s.resubmitPatch(w, r) 1746 return 1747 } else if pull.IsBranchBased() { 1748 s.resubmitBranch(w, r) 1749 return 1750 } else if pull.IsForkBased() { 1751 s.resubmitFork(w, r) 1752 return 1753 } 1754 } 1755} 1756 1757func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1758 l := s.logger.With("handler", "resubmitPatch") 1759 1760 user := s.oauth.GetMultiAccountUser(r) 1761 if user != nil && user.Active != nil { 1762 l = l.With("user", user.Active.Did) 1763 } 1764 1765 pull, ok := r.Context().Value("pull").(*models.Pull) 1766 if !ok { 1767 l.Error("failed to get pull") 1768 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1769 return 1770 } 1771 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 1772 1773 f, err := s.repoResolver.Resolve(r) 1774 if err != nil { 1775 l.Error("failed to get repo and knot", "err", err) 1776 return 1777 } 1778 1779 if user.Active.Did != pull.OwnerDid { 1780 l.Error("unauthorized user", "actual_user", user.Active.Did, "expected_owner", pull.OwnerDid) 1781 w.WriteHeader(http.StatusUnauthorized) 1782 return 1783 } 1784 1785 patch := r.FormValue("patch") 1786 1787 s.resubmitPullHelper(w, r, f, user, pull, patch, "", "") 1788} 1789 1790func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1791 l := s.logger.With("handler", "resubmitBranch") 1792 1793 user := s.oauth.GetMultiAccountUser(r) 1794 if user != nil && user.Active != nil { 1795 l = l.With("user", user.Active.Did) 1796 } 1797 1798 pull, ok := r.Context().Value("pull").(*models.Pull) 1799 if !ok { 1800 l.Error("failed to get pull") 1801 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 1802 return 1803 } 1804 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "target_branch", pull.TargetBranch) 1805 1806 f, err := s.repoResolver.Resolve(r) 1807 if err != nil { 1808 l.Error("failed to get repo and knot", "err", err) 1809 return 1810 } 1811 1812 if user.Active.Did != pull.OwnerDid { 1813 l.Error("unauthorized user", "actual_user", user.Active.Did, "expected_owner", pull.OwnerDid) 1814 w.WriteHeader(http.StatusUnauthorized) 1815 return 1816 } 1817 1818 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.RepoIdentifier())} 1819 if !roles.IsPushAllowed() { 1820 l.Error("unauthorized user - no push permission") 1821 w.WriteHeader(http.StatusUnauthorized) 1822 return 1823 } 1824 1825 scheme := "http" 1826 if !s.config.Core.Dev { 1827 scheme = "https" 1828 } 1829 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1830 xrpcc := &indigoxrpc.Client{ 1831 Host: host, 1832 } 1833 1834 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, f.RepoIdentifier(), pull.TargetBranch, pull.PullSource.Branch) 1835 if err != nil { 1836 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1837 l.Error("failed to call XRPC repo.compare", "xrpcerr", xrpcerr, "err", err, "source_branch", pull.PullSource.Branch) 1838 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1839 return 1840 } 1841 l.Error("compare request failed", "err", err, "source_branch", pull.PullSource.Branch) 1842 s.pages.Notice(w, "resubmit-error", err.Error()) 1843 return 1844 } 1845 1846 var comparison types.RepoFormatPatchResponse 1847 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 1848 l.Error("failed to decode XRPC compare response", "err", err) 1849 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1850 return 1851 } 1852 1853 sourceRev := comparison.Rev2 1854 patch := comparison.FormatPatchRaw 1855 combined := comparison.CombinedPatchRaw 1856 1857 s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1858} 1859 1860func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 1861 l := s.logger.With("handler", "resubmitFork") 1862 1863 user := s.oauth.GetMultiAccountUser(r) 1864 if user != nil && user.Active != nil { 1865 l = l.With("user", user.Active.Did) 1866 } 1867 1868 pull, ok := r.Context().Value("pull").(*models.Pull) 1869 if !ok { 1870 l.Error("failed to get pull") 1871 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 1872 return 1873 } 1874 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "target_branch", pull.TargetBranch) 1875 1876 f, err := s.repoResolver.Resolve(r) 1877 if err != nil { 1878 l.Error("failed to get repo and knot", "err", err) 1879 return 1880 } 1881 1882 if user.Active.Did != pull.OwnerDid { 1883 l.Error("unauthorized user", "actual_user", user.Active.Did, "expected_owner", pull.OwnerDid) 1884 w.WriteHeader(http.StatusUnauthorized) 1885 return 1886 } 1887 1888 forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 1889 if err != nil { 1890 l.Error("failed to get source repo", "err", err, "repo_at", pull.PullSource.RepoAt.String()) 1891 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1892 return 1893 } 1894 1895 // update the hidden tracking branch to latest 1896 client, err := s.oauth.ServiceClient( 1897 r, 1898 oauth.WithService(forkRepo.Knot), 1899 oauth.WithLxm(tangled.RepoHiddenRefNSID), 1900 oauth.WithDev(s.config.Core.Dev), 1901 ) 1902 if err != nil { 1903 l.Error("failed to connect to knot server", "err", err, "fork_knot", forkRepo.Knot) 1904 return 1905 } 1906 1907 resp, err := tangled.RepoHiddenRef( 1908 r.Context(), 1909 client, 1910 &tangled.RepoHiddenRef_Input{ 1911 ForkRef: pull.PullSource.Branch, 1912 RemoteRef: pull.TargetBranch, 1913 Repo: forkRepo.RepoAt().String(), 1914 }, 1915 ) 1916 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1917 s.logger.Error("failed to set hidden ref", "xrpcerr", xrpcerr, "err", err) 1918 s.pages.Notice(w, "resubmit-error", xrpcerr.Error()) 1919 return 1920 } 1921 if !resp.Success { 1922 l.Error("failed to update tracking ref", "err", resp.Error, "fork_ref", pull.PullSource.Branch, "remote_ref", pull.TargetBranch) 1923 s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.") 1924 return 1925 } 1926 1927 hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 1928 // extract patch by performing compare 1929 forkScheme := "http" 1930 if !s.config.Core.Dev { 1931 forkScheme = "https" 1932 } 1933 forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1934 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepo.RepoIdentifier(), hiddenRef, pull.PullSource.Branch) 1935 if err != nil { 1936 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1937 l.Error("failed to call XRPC repo.compare for fork", "xrpcerr", xrpcerr, "err", err, "hidden_ref", hiddenRef, "source_branch", pull.PullSource.Branch) 1938 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1939 return 1940 } 1941 l.Error("failed to compare branches", "err", err, "hidden_ref", hiddenRef, "source_branch", pull.PullSource.Branch) 1942 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1943 return 1944 } 1945 1946 var forkComparison types.RepoFormatPatchResponse 1947 if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 1948 l.Error("failed to decode XRPC compare response for fork", "err", err) 1949 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1950 return 1951 } 1952 1953 // Use the fork comparison we already made 1954 comparison := forkComparison 1955 1956 sourceRev := comparison.Rev2 1957 patch := comparison.FormatPatchRaw 1958 combined := comparison.CombinedPatchRaw 1959 1960 s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1961} 1962 1963func (s *Pulls) resubmitPullHelper( 1964 w http.ResponseWriter, 1965 r *http.Request, 1966 repo *models.Repo, 1967 user *oauth.MultiAccountUser, 1968 pull *models.Pull, 1969 patch string, 1970 combined string, 1971 sourceRev string, 1972) { 1973 l := s.logger.With("handler", "resubmitPullHelper", "user", user.Active.Did, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 1974 1975 stack := r.Context().Value("stack").(models.Stack) 1976 if stack != nil && len(stack) != 1 { 1977 l.Info("resubmitting stacked PR", "stack_size", len(stack)) 1978 s.resubmitStackedPullHelper(w, r, repo, user, pull, patch) 1979 return 1980 } 1981 1982 if err := s.validator.ValidatePatch(&patch); err != nil { 1983 s.pages.Notice(w, "resubmit-error", err.Error()) 1984 return 1985 } 1986 1987 if patch == pull.LatestPatch() { 1988 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 1989 return 1990 } 1991 1992 // validate sourceRev if branch/fork based 1993 if pull.IsBranchBased() || pull.IsForkBased() { 1994 if sourceRev == pull.LatestSha() { 1995 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1996 return 1997 } 1998 } 1999 2000 pullAt := pull.AtUri() 2001 newRoundNumber := len(pull.Submissions) 2002 newPatch := patch 2003 newSourceRev := sourceRev 2004 combinedPatch := combined 2005 2006 client, err := s.oauth.AuthorizedClient(r) 2007 if err != nil { 2008 l.Error("failed to authorize client", "err", err) 2009 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2010 return 2011 } 2012 2013 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Active.Did, pull.Rkey) 2014 if err != nil { 2015 // failed to get record 2016 l.Error("failed to get record from PDS", "err", err, "rkey", pull.Rkey) 2017 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 2018 return 2019 } 2020 2021 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 2022 if err != nil { 2023 l.Error("failed to upload patch blob", "err", err) 2024 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2025 return 2026 } 2027 record := pull.AsRecord() 2028 record.Rounds = append(record.Rounds, &tangled.RepoPull_Round{ 2029 CreatedAt: time.Now().Format(time.RFC3339), 2030 PatchBlob: blob.Blob, 2031 }) 2032 record.CreatedAt = time.Now().Format(time.RFC3339) 2033 2034 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 2035 Collection: tangled.RepoPullNSID, 2036 Repo: user.Active.Did, 2037 Rkey: pull.Rkey, 2038 SwapRecord: ex.Cid, 2039 Record: &lexutil.LexiconTypeDecoder{ 2040 Val: &record, 2041 }, 2042 }) 2043 if err != nil { 2044 l.Error("failed to update record on PDS", "err", err, "rkey", pull.Rkey) 2045 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2046 return 2047 } 2048 2049 err = db.ResubmitPull(s.db, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob) 2050 if err != nil { 2051 l.Error("failed to resubmit pull request in database", "err", err, "round_number", newRoundNumber) 2052 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2053 return 2054 } 2055 2056 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 2057 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2058} 2059 2060func (s *Pulls) resubmitStackedPullHelper( 2061 w http.ResponseWriter, 2062 r *http.Request, 2063 repo *models.Repo, 2064 user *oauth.MultiAccountUser, 2065 pull *models.Pull, 2066 patch string, 2067) { 2068 l := s.logger.With("handler", "resubmitStackedPullHelper", "user", user.Active.Did, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 2069 2070 targetBranch := pull.TargetBranch 2071 2072 origStack, _ := r.Context().Value("stack").(models.Stack) 2073 2074 formatPatches, err := patchutil.ExtractPatches(patch) 2075 if err != nil { 2076 l.Error("failed to extract patches", "err", err) 2077 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Failed to parse patches.") 2078 return 2079 } 2080 2081 // must have atleast 1 patch to begin with 2082 if len(formatPatches) == 0 { 2083 l.Error("no patches found in the generated format-patch") 2084 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request: No patches found in the generated patch.") 2085 return 2086 } 2087 2088 client, err := s.oauth.AuthorizedClient(r) 2089 if err != nil { 2090 l.Error("failed to get authorized client", "err", err) 2091 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 2092 return 2093 } 2094 2095 // first upload all blobs 2096 blobs := make([]*lexutil.LexBlob, len(formatPatches)) 2097 for i, p := range formatPatches { 2098 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.Raw), ApplicationGzip) 2099 if err != nil { 2100 l.Error("failed to upload patch blob", "err", err, "patch_index", i) 2101 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 2102 return 2103 } 2104 l.Info("uploaded blob", "idx", i+1, "total", len(formatPatches)) 2105 blobs[i] = blob.Blob 2106 } 2107 2108 newStack, err := s.newStack(r.Context(), repo, user, targetBranch, pull.PullSource, formatPatches, blobs) 2109 if err != nil { 2110 l.Error("failed to create resubmitted stack", "err", err) 2111 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2112 return 2113 } 2114 2115 // find the diff between the stacks, first, map them by changeId 2116 origById := make(map[string]*models.Pull) 2117 newById := make(map[string]*models.Pull) 2118 for _, p := range origStack { 2119 origById[p.LatestSubmission().ChangeId()] = p 2120 } 2121 for _, p := range newStack { 2122 newById[p.LatestSubmission().ChangeId()] = p 2123 } 2124 2125 // commits that got deleted: corresponding pull is closed 2126 // commits that got added: new pull is created 2127 // commits that got updated: corresponding pull is resubmitted & new round begins 2128 additions := make(map[string]*models.Pull) 2129 deletions := make(map[string]*models.Pull) 2130 updated := make(map[string]struct{}) 2131 2132 // pulls in original stack but not in new one 2133 for _, op := range origStack { 2134 if _, ok := newById[op.LatestSubmission().ChangeId()]; !ok { 2135 deletions[op.LatestSubmission().ChangeId()] = op 2136 } 2137 } 2138 2139 // pulls in new stack but not in original one 2140 for _, np := range newStack { 2141 if _, ok := origById[np.LatestSubmission().ChangeId()]; !ok { 2142 additions[np.LatestSubmission().ChangeId()] = np 2143 } 2144 } 2145 2146 // NOTE: this loop can be written in any of above blocks, 2147 // but is written separately in the interest of simpler code 2148 for _, np := range newStack { 2149 if op, ok := origById[np.LatestSubmission().ChangeId()]; ok { 2150 // pull exists in both stacks 2151 updated[op.LatestSubmission().ChangeId()] = struct{}{} 2152 } 2153 } 2154 2155 // NOTE: we can go through the newStack and update dependent relations and 2156 // rkeys now that we know which ones have been updated 2157 // update dependentOn relations for the entire stack 2158 var parentAt *syntax.ATURI 2159 for _, np := range newStack { 2160 if op, ok := origById[np.LatestSubmission().ChangeId()]; ok { 2161 // pull exists in both stacks 2162 np.Rkey = op.Rkey 2163 } 2164 np.DependentOn = parentAt 2165 x := np.AtUri() 2166 parentAt = &x 2167 } 2168 2169 l = l.With("additions", len(additions), "deletions", len(deletions), "updates", len(updated)) 2170 2171 tx, err := s.db.Begin() 2172 if err != nil { 2173 l.Error("failed to start transaction", "err", err) 2174 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2175 return 2176 } 2177 defer tx.Rollback() 2178 2179 // pds updates to make 2180 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 2181 2182 // deleted pulls are marked as deleted in the DB 2183 for _, p := range deletions { 2184 // do not do delete already merged PRs 2185 if p.State == models.PullMerged { 2186 continue 2187 } 2188 2189 err := db.AbandonPulls(tx, orm.FilterEq("repo_at", p.RepoAt), orm.FilterEq("at_uri", p.AtUri())) 2190 if err != nil { 2191 l.Error("failed to delete pull", "err", err, "pull_id", p.PullId) 2192 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2193 return 2194 } 2195 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2196 RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 2197 Collection: tangled.RepoPullNSID, 2198 Rkey: p.Rkey, 2199 }, 2200 }) 2201 } 2202 2203 // new pulls are created 2204 for _, p := range additions { 2205 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.LatestPatch()), ApplicationGzip) 2206 if err != nil { 2207 l.Error("failed to upload patch blob for new pull", "err", err, "change_id", p.LatestSubmission().ChangeId()) 2208 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2209 return 2210 } 2211 p.Submissions[0].Blob = *blob.Blob 2212 2213 if err = db.PutPull(tx, p); err != nil { 2214 l.Error("failed to create pull", "err", err, "pull_id", p.PullId, "change_id", p.LatestSubmission().ChangeId()) 2215 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2216 return 2217 } 2218 2219 record := p.AsRecord() 2220 record.Rounds = []*tangled.RepoPull_Round{ 2221 { 2222 CreatedAt: time.Now().Format(time.RFC3339), 2223 PatchBlob: blob.Blob, 2224 }, 2225 } 2226 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2227 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 2228 Collection: tangled.RepoPullNSID, 2229 Rkey: &p.Rkey, 2230 Value: &lexutil.LexiconTypeDecoder{ 2231 Val: &record, 2232 }, 2233 }, 2234 }) 2235 } 2236 2237 // updated pulls are, well, updated; to start a new round 2238 for id := range updated { 2239 op, _ := origById[id] 2240 np, _ := newById[id] 2241 2242 // do not update already merged PRs 2243 if op.State == models.PullMerged { 2244 continue 2245 } 2246 2247 // resubmit the new pull 2248 np.Rkey = op.Rkey 2249 pullAt := op.AtUri() 2250 newRoundNumber := len(op.Submissions) 2251 newPatch := np.LatestPatch() 2252 combinedPatch := np.LatestSubmission().Combined 2253 newSourceRev := np.LatestSha() 2254 2255 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(newPatch), ApplicationGzip) 2256 if err != nil { 2257 l.Error("failed to upload patch blob for update", "err", err, "change_id", id, "pull_id", op.PullId) 2258 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2259 return 2260 } 2261 2262 // create new round 2263 err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob) 2264 if err != nil { 2265 l.Error("failed to update pull in database", "err", err, "pull_id", op.PullId, "round_number", newRoundNumber) 2266 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2267 return 2268 } 2269 2270 // update dependent-on relation 2271 if np.DependentOn != nil { 2272 err := db.SetDependentOn(tx, *np.DependentOn, orm.FilterEq("at_uri", np.AtUri())) 2273 if err != nil { 2274 l.Error("failed to update pull in database", "err", err, "pull_id", op.PullId, "round_number", newRoundNumber) 2275 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2276 return 2277 } 2278 } 2279 2280 record := np.AsRecord() 2281 record.Rounds = op.AsRecord().Rounds 2282 record.Rounds = append(record.Rounds, &tangled.RepoPull_Round{ 2283 CreatedAt: time.Now().Format(time.RFC3339), 2284 PatchBlob: blob.Blob, 2285 }) 2286 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2287 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 2288 Collection: tangled.RepoPullNSID, 2289 Rkey: op.Rkey, 2290 Value: &lexutil.LexiconTypeDecoder{ 2291 Val: &record, 2292 }, 2293 }, 2294 }) 2295 } 2296 2297 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2298 Repo: user.Active.Did, 2299 Writes: writes, 2300 }) 2301 if err != nil { 2302 l.Error("failed to apply writes for stacked pull request", "err", err, "writes_count", len(writes)) 2303 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 2304 return 2305 } 2306 2307 err = tx.Commit() 2308 if err != nil { 2309 l.Error("failed to commit resubmit transaction", "err", err) 2310 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2311 return 2312 } 2313 2314 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 2315 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2316} 2317 2318func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 2319 l := s.logger.With("handler", "MergePull") 2320 2321 user := s.oauth.GetMultiAccountUser(r) 2322 if user != nil && user.Active != nil { 2323 l = l.With("user", user.Active.Did) 2324 } 2325 2326 f, err := s.repoResolver.Resolve(r) 2327 if err != nil { 2328 l.Error("failed to resolve repo", "err", err) 2329 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2330 return 2331 } 2332 l = l.With("repo_at", f.RepoAt().String()) 2333 2334 pull, ok := r.Context().Value("pull").(*models.Pull) 2335 if !ok { 2336 l.Error("failed to get pull") 2337 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2338 return 2339 } 2340 l = l.With("pull_id", pull.PullId, "target_branch", pull.TargetBranch) 2341 2342 stack, ok := r.Context().Value("stack").(models.Stack) 2343 if !ok { 2344 l.Error("failed to get stack") 2345 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2346 return 2347 } 2348 2349 // combine patches of substack 2350 subStack := stack.Below(pull) 2351 // collect the portion of the stack that is mergeable 2352 pullsToMerge := subStack.Mergeable() 2353 l = l.With("pulls_to_merge", len(pullsToMerge)) 2354 2355 patch := pullsToMerge.CombinedPatch() 2356 2357 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 2358 if err != nil { 2359 l.Error("failed to resolve identity", "err", err, "owner_did", pull.OwnerDid) 2360 w.WriteHeader(http.StatusNotFound) 2361 return 2362 } 2363 2364 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 2365 if err != nil { 2366 l.Warn("failed to get primary email", "err", err, "owner_did", pull.OwnerDid) 2367 } 2368 2369 authorName := ident.Handle.String() 2370 mergeInput := &tangled.RepoMerge_Input{ 2371 Did: f.Did, 2372 Name: f.Name, 2373 Branch: pull.TargetBranch, 2374 Patch: patch, 2375 CommitMessage: &pull.Title, 2376 AuthorName: &authorName, 2377 } 2378 2379 if pull.Body != "" { 2380 mergeInput.CommitBody = &pull.Body 2381 } 2382 2383 if email.Address != "" { 2384 mergeInput.AuthorEmail = &email.Address 2385 } 2386 2387 client, err := s.oauth.ServiceClient( 2388 r, 2389 oauth.WithService(f.Knot), 2390 oauth.WithLxm(tangled.RepoMergeNSID), 2391 oauth.WithDev(s.config.Core.Dev), 2392 ) 2393 if err != nil { 2394 l.Error("failed to connect to knot server", "err", err, "knot", f.Knot) 2395 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2396 return 2397 } 2398 2399 err = tangled.RepoMerge(r.Context(), client, mergeInput) 2400 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2401 s.logger.Error("failed to merge", "xrpcerr", xrpcerr, "err", err) 2402 s.pages.Notice(w, "pull-merge-error", xrpcerr.Error()) 2403 return 2404 } 2405 2406 tx, err := s.db.Begin() 2407 if err != nil { 2408 l.Error("failed to start transaction", "err", err) 2409 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2410 return 2411 } 2412 defer tx.Rollback() 2413 2414 var atUris []syntax.ATURI 2415 for _, p := range pullsToMerge { 2416 atUris = append(atUris, p.AtUri()) 2417 p.State = models.PullMerged 2418 } 2419 err = db.MergePulls(tx, orm.FilterEq("repo_at", f.RepoAt()), orm.FilterIn("at_uri", atUris)) 2420 if err != nil { 2421 l.Error("failed to update pull request status in database", "err", err) 2422 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2423 return 2424 } 2425 2426 err = tx.Commit() 2427 if err != nil { 2428 // TODO: this is unsound, we should also revert the merge from the knotserver here 2429 l.Error("failed to commit merge transaction", "err", err) 2430 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2431 return 2432 } 2433 2434 // notify about the pull merge 2435 for _, p := range pullsToMerge { 2436 s.notifier.NewPullState(r.Context(), syntax.DID(user.Active.Did), p) 2437 } 2438 2439 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2440 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2441} 2442 2443func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { 2444 l := s.logger.With("handler", "ClosePull") 2445 2446 user := s.oauth.GetMultiAccountUser(r) 2447 if user != nil && user.Active != nil { 2448 l = l.With("user", user.Active.Did) 2449 } 2450 2451 f, err := s.repoResolver.Resolve(r) 2452 if err != nil { 2453 l.Error("failed to resolve repo", "err", err) 2454 return 2455 } 2456 2457 pull, ok := r.Context().Value("pull").(*models.Pull) 2458 if !ok { 2459 l.Error("failed to get pull") 2460 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2461 return 2462 } 2463 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 2464 2465 // auth filter: only owner or collaborators can close 2466 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.RepoIdentifier())} 2467 isOwner := roles.IsOwner() 2468 isCollaborator := roles.IsCollaborator() 2469 isPullAuthor := user.Active.Did == pull.OwnerDid 2470 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2471 if !isCloseAllowed { 2472 l.Error("unauthorized to close pull", "is_owner", isOwner, "is_collaborator", isCollaborator, "is_pull_author", isPullAuthor) 2473 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 2474 return 2475 } 2476 2477 // Start a transaction 2478 tx, err := s.db.BeginTx(r.Context(), nil) 2479 if err != nil { 2480 l.Error("failed to start transaction", "err", err) 2481 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2482 return 2483 } 2484 defer tx.Rollback() 2485 2486 // if this PR is stacked, then we want to close all PRs above this one on the stack 2487 stack := r.Context().Value("stack").(models.Stack) 2488 pullsToClose := stack.Above(pull) 2489 var atUris []syntax.ATURI 2490 for _, p := range pullsToClose { 2491 atUris = append(atUris, p.AtUri()) 2492 p.State = models.PullClosed 2493 } 2494 err = db.ClosePulls( 2495 tx, 2496 orm.FilterEq("repo_at", f.RepoAt()), 2497 orm.FilterIn("at_uri", atUris), 2498 ) 2499 if err != nil { 2500 l.Error("failed to close pulls in database", "err", err, "pulls_to_close", len(pullsToClose)) 2501 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2502 } 2503 2504 // Commit the transaction 2505 if err = tx.Commit(); err != nil { 2506 l.Error("failed to commit transaction", "err", err) 2507 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2508 return 2509 } 2510 2511 for _, p := range pullsToClose { 2512 s.notifier.NewPullState(r.Context(), syntax.DID(user.Active.Did), p) 2513 } 2514 2515 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2516 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2517} 2518 2519func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { 2520 l := s.logger.With("handler", "ReopenPull") 2521 2522 user := s.oauth.GetMultiAccountUser(r) 2523 if user != nil && user.Active != nil { 2524 l = l.With("user", user.Active.Did) 2525 } 2526 2527 f, err := s.repoResolver.Resolve(r) 2528 if err != nil { 2529 l.Error("failed to resolve repo", "err", err) 2530 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2531 return 2532 } 2533 2534 pull, ok := r.Context().Value("pull").(*models.Pull) 2535 if !ok { 2536 l.Error("failed to get pull") 2537 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2538 return 2539 } 2540 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "state", pull.State) 2541 2542 // auth filter: only owner or collaborators can close 2543 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.RepoIdentifier())} 2544 isOwner := roles.IsOwner() 2545 isCollaborator := roles.IsCollaborator() 2546 isPullAuthor := user.Active.Did == pull.OwnerDid 2547 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2548 if !isCloseAllowed { 2549 l.Error("unauthorized to reopen pull", "is_owner", isOwner, "is_collaborator", isCollaborator, "is_pull_author", isPullAuthor) 2550 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 2551 return 2552 } 2553 2554 // Start a transaction 2555 tx, err := s.db.BeginTx(r.Context(), nil) 2556 if err != nil { 2557 l.Error("failed to start transaction", "err", err) 2558 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2559 return 2560 } 2561 defer tx.Rollback() 2562 2563 // if this PR is stacked, then we want to reopen all PRs above this one on the stack 2564 stack := r.Context().Value("stack").(models.Stack) 2565 pullsToReopen := stack.Below(pull) 2566 var atUris []syntax.ATURI 2567 for _, p := range pullsToReopen { 2568 atUris = append(atUris, p.AtUri()) 2569 p.State = models.PullOpen 2570 } 2571 err = db.ReopenPulls( 2572 tx, 2573 orm.FilterEq("repo_at", f.RepoAt()), 2574 orm.FilterIn("at_uri", atUris), 2575 ) 2576 if err != nil { 2577 l.Error("failed to reopen pulls in database", "err", err, "pulls_to_reopen", len(pullsToReopen)) 2578 s.pages.Notice(w, "pull-close", "Failed to reopen pull.") 2579 } 2580 2581 // Commit the transaction 2582 if err = tx.Commit(); err != nil { 2583 l.Error("failed to commit transaction", "err", err) 2584 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2585 return 2586 } 2587 2588 for _, p := range pullsToReopen { 2589 s.notifier.NewPullState(r.Context(), syntax.DID(user.Active.Did), p) 2590 } 2591 2592 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2593 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2594} 2595 2596func (s *Pulls) newStack( 2597 ctx context.Context, 2598 repo *models.Repo, 2599 user *oauth.MultiAccountUser, 2600 targetBranch string, 2601 pullSource *models.PullSource, 2602 formatPatches []types.FormatPatch, 2603 blobs []*lexutil.LexBlob, 2604) (models.Stack, error) { 2605 var stack models.Stack 2606 var parentAtUri *syntax.ATURI 2607 for i, fp := range formatPatches { 2608 // all patches must have a jj change-id 2609 _, err := fp.ChangeId() 2610 if err != nil { 2611 return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.") 2612 } 2613 2614 title := fp.Title 2615 body := fp.Body 2616 rkey := tid.TID() 2617 2618 mentions, references := s.mentionsResolver.Resolve(ctx, body) 2619 2620 now := time.Now() 2621 2622 pull := models.Pull{ 2623 Title: title, 2624 Body: body, 2625 TargetBranch: targetBranch, 2626 OwnerDid: user.Active.Did, 2627 RepoAt: repo.RepoAt(), 2628 Rkey: rkey, 2629 Mentions: mentions, 2630 References: references, 2631 Submissions: []*models.PullSubmission{ 2632 { 2633 Patch: fp.Raw, 2634 SourceRev: fp.SHA, 2635 Combined: fp.Raw, 2636 Blob: *blobs[i], 2637 Created: now, 2638 }, 2639 }, 2640 PullSource: pullSource, 2641 Created: now, 2642 State: models.PullOpen, 2643 2644 DependentOn: parentAtUri, 2645 Repo: repo, 2646 } 2647 2648 stack = append(stack, &pull) 2649 2650 parent := pull.AtUri() 2651 parentAtUri = &parent 2652 } 2653 2654 return stack, nil 2655} 2656 2657func gz(s string) io.Reader { 2658 var b bytes.Buffer 2659 w := gzip.NewWriter(&b) 2660 w.Write([]byte(s)) 2661 w.Close() 2662 return &b 2663} 2664 2665func ptrPullState(s models.PullState) *models.PullState { return &s } 2666 2667func repoPullTarget(repo *models.Repo, branch string) *tangled.RepoPull_Target { 2668 s := string(repo.RepoAt()) 2669 t := &tangled.RepoPull_Target{ 2670 Branch: branch, 2671 Repo: &s, 2672 } 2673 if repo.RepoDid != "" { 2674 t.RepoDid = &repo.RepoDid 2675 } 2676 return t 2677}