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