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