Monorepo for Tangled
0
fork

Configure Feed

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

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