This repository has no description
0

Configure Feed

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

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