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