Monorepo for Tangled tangled.org
11

Configure Feed

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

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