A vibe coded tangled fork which supports pijul.
at 79d5bed403efd903f443d980cd87db3e2681ccb5 2542 lines 72 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 var authorDid string 564 if authorHandle := query.Get("author"); authorHandle != nil { 565 identity, err := s.idResolver.ResolveIdent(r.Context(), *authorHandle) 566 if err != nil { 567 l.Debug("failed to resolve author handle", "handle", *authorHandle, "err", err) 568 } else { 569 authorDid = identity.DID.String() 570 } 571 } 572 573 var negatedAuthorDid string 574 if negatedAuthors := query.GetAllNegated("author"); len(negatedAuthors) > 0 { 575 identity, err := s.idResolver.ResolveIdent(r.Context(), negatedAuthors[0]) 576 if err != nil { 577 l.Debug("failed to resolve negated author handle", "handle", negatedAuthors[0], "err", err) 578 } else { 579 negatedAuthorDid = identity.DID.String() 580 } 581 } 582 583 labels := query.GetAll("label") 584 negatedLabels := query.GetAllNegated("label") 585 586 var keywords, negatedKeywords []string 587 var phrases, negatedPhrases []string 588 for _, item := range query.Items() { 589 switch item.Kind { 590 case searchquery.KindKeyword: 591 if item.Negated { 592 negatedKeywords = append(negatedKeywords, item.Value) 593 } else { 594 keywords = append(keywords, item.Value) 595 } 596 case searchquery.KindQuoted: 597 if item.Negated { 598 negatedPhrases = append(negatedPhrases, item.Value) 599 } else { 600 phrases = append(phrases, item.Value) 601 } 602 } 603 } 604 605 searchOpts := models.PullSearchOptions{ 606 Keywords: keywords, 607 Phrases: phrases, 608 RepoAt: f.RepoAt().String(), 609 State: state, 610 AuthorDid: authorDid, 611 Labels: labels, 612 NegatedKeywords: negatedKeywords, 613 NegatedPhrases: negatedPhrases, 614 NegatedLabels: negatedLabels, 615 NegatedAuthorDid: negatedAuthorDid, 616 Page: page, 617 } 618 619 var totalPulls int 620 if state == nil { 621 totalPulls = f.RepoStats.PullCount.Open + f.RepoStats.PullCount.Merged + f.RepoStats.PullCount.Closed 622 } else { 623 switch *state { 624 case models.PullOpen: 625 totalPulls = f.RepoStats.PullCount.Open 626 case models.PullMerged: 627 totalPulls = f.RepoStats.PullCount.Merged 628 case models.PullClosed: 629 totalPulls = f.RepoStats.PullCount.Closed 630 } 631 } 632 633 repoInfo := s.repoResolver.GetRepoInfo(r, user) 634 635 var pulls []*models.Pull 636 637 if searchOpts.HasSearchFilters() { 638 res, err := s.indexer.Search(r.Context(), searchOpts) 639 if err != nil { 640 l.Error("failed to search for pulls", "err", err) 641 return 642 } 643 totalPulls = int(res.Total) 644 l.Debug("searched pulls with indexer", "count", len(res.Hits)) 645 646 if len(res.Hits) > 0 { 647 pulls, err = db.GetPulls( 648 s.db, 649 orm.FilterIn("id", res.Hits), 650 ) 651 if err != nil { 652 l.Error("failed to get pulls", "err", err) 653 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 654 return 655 } 656 } 657 } else { 658 filters := []orm.Filter{ 659 orm.FilterEq("repo_at", f.RepoAt()), 660 } 661 if state != nil { 662 filters = append(filters, orm.FilterEq("state", *state)) 663 } 664 pulls, err = db.GetPullsPaginated( 665 s.db, 666 page, 667 filters..., 668 ) 669 if err != nil { 670 l.Error("failed to get pulls", "err", err) 671 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 672 return 673 } 674 } 675 676 for _, p := range pulls { 677 var pullSourceRepo *models.Repo 678 if p.PullSource != nil { 679 if p.PullSource.RepoAt != nil { 680 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) 681 if err != nil { 682 log.Printf("failed to get repo by at uri: %v", err) 683 continue 684 } else { 685 p.PullSource.Repo = pullSourceRepo 686 } 687 } 688 } 689 } 690 691 // we want to group all stacked PRs into just one list 692 stacks := make(map[string]models.Stack) 693 var shas []string 694 n := 0 695 for _, p := range pulls { 696 // store the sha for later 697 shas = append(shas, p.LatestSha()) 698 // this PR is stacked 699 if p.StackId != "" { 700 // we have already seen this PR stack 701 if _, seen := stacks[p.StackId]; seen { 702 stacks[p.StackId] = append(stacks[p.StackId], p) 703 // skip this PR 704 } else { 705 stacks[p.StackId] = nil 706 pulls[n] = p 707 n++ 708 } 709 } else { 710 pulls[n] = p 711 n++ 712 } 713 } 714 pulls = pulls[:n] 715 716 ps, err := db.GetPipelineStatuses( 717 s.db, 718 len(shas), 719 orm.FilterEq("p.repo_owner", f.Did), 720 orm.FilterEq("p.repo_name", f.Name), 721 orm.FilterEq("p.knot", f.Knot), 722 orm.FilterIn("p.sha", shas), 723 ) 724 if err != nil { 725 log.Printf("failed to fetch pipeline statuses: %s", err) 726 // non-fatal 727 } 728 m := make(map[string]models.Pipeline) 729 for _, p := range ps { 730 m[p.Sha] = p 731 } 732 733 labelDefs, err := db.GetLabelDefinitions( 734 s.db, 735 orm.FilterIn("at_uri", f.Labels), 736 orm.FilterContains("scope", tangled.RepoPullNSID), 737 ) 738 if err != nil { 739 l.Error("failed to fetch labels", "err", err) 740 s.pages.Error503(w) 741 return 742 } 743 744 defs := make(map[string]*models.LabelDefinition) 745 for _, l := range labelDefs { 746 defs[l.AtUri().String()] = &l 747 } 748 749 filterState := "" 750 if state != nil { 751 filterState = state.String() 752 } 753 754 s.pages.RepoPulls(w, pages.RepoPullsParams{ 755 LoggedInUser: s.oauth.GetMultiAccountUser(r), 756 RepoInfo: repoInfo, 757 Pulls: pulls, 758 LabelDefs: defs, 759 FilterState: filterState, 760 FilterQuery: query.String(), 761 Stacks: stacks, 762 Pipelines: m, 763 Page: page, 764 PullCount: totalPulls, 765 }) 766} 767 768func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 769 user := s.oauth.GetMultiAccountUser(r) 770 f, err := s.repoResolver.Resolve(r) 771 if err != nil { 772 log.Println("failed to get repo and knot", err) 773 return 774 } 775 776 pull, ok := r.Context().Value("pull").(*models.Pull) 777 if !ok { 778 log.Println("failed to get pull") 779 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 780 return 781 } 782 783 roundNumberStr := chi.URLParam(r, "round") 784 roundNumber, err := strconv.Atoi(roundNumberStr) 785 if err != nil || roundNumber >= len(pull.Submissions) { 786 http.Error(w, "bad round id", http.StatusBadRequest) 787 log.Println("failed to parse round id", err) 788 return 789 } 790 791 switch r.Method { 792 case http.MethodGet: 793 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ 794 LoggedInUser: user, 795 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 796 Pull: pull, 797 RoundNumber: roundNumber, 798 }) 799 return 800 case http.MethodPost: 801 body := r.FormValue("body") 802 if body == "" { 803 s.pages.Notice(w, "pull", "Comment body is required") 804 return 805 } 806 807 mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 808 809 // Start a transaction 810 tx, err := s.db.BeginTx(r.Context(), nil) 811 if err != nil { 812 log.Println("failed to start transaction", err) 813 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 814 return 815 } 816 defer tx.Rollback() 817 818 createdAt := time.Now().Format(time.RFC3339) 819 820 client, err := s.oauth.AuthorizedClient(r) 821 if err != nil { 822 log.Println("failed to get authorized client", err) 823 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 824 return 825 } 826 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 827 Collection: tangled.RepoPullCommentNSID, 828 Repo: user.Active.Did, 829 Rkey: tid.TID(), 830 Record: &lexutil.LexiconTypeDecoder{ 831 Val: &tangled.RepoPullComment{ 832 Pull: pull.AtUri().String(), 833 Body: body, 834 CreatedAt: createdAt, 835 }, 836 }, 837 }) 838 if err != nil { 839 log.Println("failed to create pull comment", err) 840 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 841 return 842 } 843 844 comment := &models.PullComment{ 845 OwnerDid: user.Active.Did, 846 RepoAt: f.RepoAt().String(), 847 PullId: pull.PullId, 848 Body: body, 849 CommentAt: atResp.Uri, 850 SubmissionId: pull.Submissions[roundNumber].ID, 851 Mentions: mentions, 852 References: references, 853 } 854 855 // Create the pull comment in the database with the commentAt field 856 commentId, err := db.NewPullComment(tx, comment) 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 // Commit the transaction 864 if err = tx.Commit(); err != nil { 865 log.Println("failed to commit transaction", err) 866 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 867 return 868 } 869 870 s.notifier.NewPullComment(r.Context(), comment, mentions) 871 872 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 873 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId)) 874 return 875 } 876} 877 878func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) { 879 user := s.oauth.GetMultiAccountUser(r) 880 f, err := s.repoResolver.Resolve(r) 881 if err != nil { 882 log.Println("failed to get repo and knot", err) 883 return 884 } 885 886 switch r.Method { 887 case http.MethodGet: 888 scheme := "http" 889 if !s.config.Core.Dev { 890 scheme = "https" 891 } 892 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 893 xrpcc := &indigoxrpc.Client{ 894 Host: host, 895 } 896 897 repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 898 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 899 if err != nil { 900 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 901 log.Println("failed to call XRPC repo.branches", xrpcerr) 902 s.pages.Error503(w) 903 return 904 } 905 log.Println("failed to fetch branches", err) 906 return 907 } 908 909 var result types.RepoBranchesResponse 910 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 911 log.Println("failed to decode XRPC response", err) 912 s.pages.Error503(w) 913 return 914 } 915 916 // can be one of "patch", "branch" or "fork" 917 strategy := r.URL.Query().Get("strategy") 918 // ignored if strategy is "patch" 919 sourceBranch := r.URL.Query().Get("sourceBranch") 920 targetBranch := r.URL.Query().Get("targetBranch") 921 922 s.pages.RepoNewPull(w, pages.RepoNewPullParams{ 923 LoggedInUser: user, 924 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 925 Branches: result.Branches, 926 Strategy: strategy, 927 SourceBranch: sourceBranch, 928 TargetBranch: targetBranch, 929 Title: r.URL.Query().Get("title"), 930 Body: r.URL.Query().Get("body"), 931 }) 932 933 case http.MethodPost: 934 title := r.FormValue("title") 935 body := r.FormValue("body") 936 targetBranch := r.FormValue("targetBranch") 937 fromFork := r.FormValue("fork") 938 sourceBranch := r.FormValue("sourceBranch") 939 patch := r.FormValue("patch") 940 941 if targetBranch == "" { 942 s.pages.Notice(w, "pull", "Target branch is required.") 943 return 944 } 945 946 // Determine PR type based on input parameters 947 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 948 isPushAllowed := roles.IsPushAllowed() 949 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 950 isForkBased := fromFork != "" && sourceBranch != "" 951 isPatchBased := patch != "" && !isBranchBased && !isForkBased 952 isStacked := r.FormValue("isStacked") == "on" 953 954 if isPatchBased && !patchutil.IsFormatPatch(patch) { 955 if title == "" { 956 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 957 return 958 } 959 sanitizer := markup.NewSanitizer() 960 if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 961 s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 962 return 963 } 964 } 965 966 // Validate we have at least one valid PR creation method 967 if !isBranchBased && !isPatchBased && !isForkBased { 968 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 969 return 970 } 971 972 // Can't mix branch-based and patch-based approaches 973 if isBranchBased && patch != "" { 974 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 975 return 976 } 977 978 // us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 979 // if err != nil { 980 // log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 981 // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 982 // return 983 // } 984 985 // TODO: make capabilities an xrpc call 986 caps := struct { 987 PullRequests struct { 988 FormatPatch bool 989 BranchSubmissions bool 990 ForkSubmissions bool 991 PatchSubmissions bool 992 } 993 }{ 994 PullRequests: struct { 995 FormatPatch bool 996 BranchSubmissions bool 997 ForkSubmissions bool 998 PatchSubmissions bool 999 }{ 1000 FormatPatch: true, 1001 BranchSubmissions: true, 1002 ForkSubmissions: true, 1003 PatchSubmissions: true, 1004 }, 1005 } 1006 1007 // caps, err := us.Capabilities() 1008 // if err != nil { 1009 // log.Println("error fetching knot caps", f.Knot, err) 1010 // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 1011 // return 1012 // } 1013 1014 if !caps.PullRequests.FormatPatch { 1015 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") 1016 return 1017 } 1018 1019 // Handle the PR creation based on the type 1020 if isBranchBased { 1021 if !caps.PullRequests.BranchSubmissions { 1022 s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") 1023 return 1024 } 1025 s.handleBranchBasedPull(w, r, f, user, title, body, targetBranch, sourceBranch, isStacked) 1026 } else if isForkBased { 1027 if !caps.PullRequests.ForkSubmissions { 1028 s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") 1029 return 1030 } 1031 s.handleForkBasedPull(w, r, f, user, fromFork, title, body, targetBranch, sourceBranch, isStacked) 1032 } else if isPatchBased { 1033 if !caps.PullRequests.PatchSubmissions { 1034 s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") 1035 return 1036 } 1037 s.handlePatchBasedPull(w, r, f, user, title, body, targetBranch, patch, isStacked) 1038 } 1039 return 1040 } 1041} 1042 1043func (s *Pulls) handleBranchBasedPull( 1044 w http.ResponseWriter, 1045 r *http.Request, 1046 repo *models.Repo, 1047 user *oauth.MultiAccountUser, 1048 title, 1049 body, 1050 targetBranch, 1051 sourceBranch string, 1052 isStacked bool, 1053) { 1054 scheme := "http" 1055 if !s.config.Core.Dev { 1056 scheme = "https" 1057 } 1058 host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 1059 xrpcc := &indigoxrpc.Client{ 1060 Host: host, 1061 } 1062 1063 didSlashRepo := fmt.Sprintf("%s/%s", repo.Did, repo.Name) 1064 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, didSlashRepo, targetBranch, sourceBranch) 1065 if err != nil { 1066 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1067 log.Println("failed to call XRPC repo.compare", xrpcerr) 1068 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1069 return 1070 } 1071 log.Println("failed to compare", err) 1072 s.pages.Notice(w, "pull", err.Error()) 1073 return 1074 } 1075 1076 var comparison types.RepoFormatPatchResponse 1077 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 1078 log.Println("failed to decode XRPC compare response", err) 1079 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1080 return 1081 } 1082 1083 sourceRev := comparison.Rev2 1084 patch := comparison.FormatPatchRaw 1085 combined := comparison.CombinedPatchRaw 1086 1087 if err := s.validator.ValidatePatch(&patch); err != nil { 1088 s.logger.Error("failed to validate patch", "err", err) 1089 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1090 return 1091 } 1092 1093 pullSource := &models.PullSource{ 1094 Branch: sourceBranch, 1095 } 1096 recordPullSource := &tangled.RepoPull_Source{ 1097 Branch: sourceBranch, 1098 Sha: comparison.Rev2, 1099 } 1100 1101 s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1102} 1103 1104func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.MultiAccountUser, title, body, targetBranch, patch string, isStacked bool) { 1105 if err := s.validator.ValidatePatch(&patch); err != nil { 1106 s.logger.Error("patch validation failed", "err", err) 1107 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1108 return 1109 } 1110 1111 s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, "", "", nil, nil, isStacked) 1112} 1113 1114func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, user *oauth.MultiAccountUser, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool) { 1115 repoString := strings.SplitN(forkRepo, "/", 2) 1116 forkOwnerDid := repoString[0] 1117 repoName := repoString[1] 1118 fork, err := db.GetForkByDid(s.db, forkOwnerDid, repoName) 1119 if errors.Is(err, sql.ErrNoRows) { 1120 s.pages.Notice(w, "pull", "No such fork.") 1121 return 1122 } else if err != nil { 1123 log.Println("failed to fetch fork:", err) 1124 s.pages.Notice(w, "pull", "Failed to fetch fork.") 1125 return 1126 } 1127 1128 client, err := s.oauth.ServiceClient( 1129 r, 1130 oauth.WithService(fork.Knot), 1131 oauth.WithLxm(tangled.RepoHiddenRefNSID), 1132 oauth.WithDev(s.config.Core.Dev), 1133 ) 1134 1135 resp, err := tangled.RepoHiddenRef( 1136 r.Context(), 1137 client, 1138 &tangled.RepoHiddenRef_Input{ 1139 ForkRef: sourceBranch, 1140 RemoteRef: targetBranch, 1141 Repo: fork.RepoAt().String(), 1142 }, 1143 ) 1144 if err := xrpcclient.HandleXrpcErr(err); err != nil { 1145 s.pages.Notice(w, "pull", err.Error()) 1146 return 1147 } 1148 1149 if !resp.Success { 1150 errorMsg := "Failed to create pull request" 1151 if resp.Error != nil { 1152 errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error) 1153 } 1154 s.pages.Notice(w, "pull", errorMsg) 1155 return 1156 } 1157 1158 hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 1159 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking 1160 // the targetBranch on the target repository. This code is a bit confusing, but here's an example: 1161 // hiddenRef: hidden/feature-1/main (on repo-fork) 1162 // targetBranch: main (on repo-1) 1163 // sourceBranch: feature-1 (on repo-fork) 1164 forkScheme := "http" 1165 if !s.config.Core.Dev { 1166 forkScheme = "https" 1167 } 1168 forkHost := fmt.Sprintf("%s://%s", forkScheme, fork.Knot) 1169 forkXrpcc := &indigoxrpc.Client{ 1170 Host: forkHost, 1171 } 1172 1173 forkRepoId := fmt.Sprintf("%s/%s", fork.Did, fork.Name) 1174 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, forkRepoId, hiddenRef, sourceBranch) 1175 if err != nil { 1176 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1177 log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1178 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1179 return 1180 } 1181 log.Println("failed to compare across branches", err) 1182 s.pages.Notice(w, "pull", err.Error()) 1183 return 1184 } 1185 1186 var comparison types.RepoFormatPatchResponse 1187 if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 1188 log.Println("failed to decode XRPC compare response for fork", err) 1189 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1190 return 1191 } 1192 1193 sourceRev := comparison.Rev2 1194 patch := comparison.FormatPatchRaw 1195 combined := comparison.CombinedPatchRaw 1196 1197 if err := s.validator.ValidatePatch(&patch); err != nil { 1198 s.logger.Error("failed to validate patch", "err", err) 1199 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1200 return 1201 } 1202 1203 forkAtUri := fork.RepoAt() 1204 forkAtUriStr := forkAtUri.String() 1205 1206 pullSource := &models.PullSource{ 1207 Branch: sourceBranch, 1208 RepoAt: &forkAtUri, 1209 } 1210 recordPullSource := &tangled.RepoPull_Source{ 1211 Branch: sourceBranch, 1212 Repo: &forkAtUriStr, 1213 Sha: sourceRev, 1214 } 1215 1216 s.createPullRequest(w, r, repo, user, title, body, targetBranch, patch, combined, sourceRev, pullSource, recordPullSource, isStacked) 1217} 1218 1219func (s *Pulls) createPullRequest( 1220 w http.ResponseWriter, 1221 r *http.Request, 1222 repo *models.Repo, 1223 user *oauth.MultiAccountUser, 1224 title, body, targetBranch string, 1225 patch string, 1226 combined string, 1227 sourceRev string, 1228 pullSource *models.PullSource, 1229 recordPullSource *tangled.RepoPull_Source, 1230 isStacked bool, 1231) { 1232 if isStacked { 1233 // creates a series of PRs, each linking to the previous, identified by jj's change-id 1234 s.createStackedPullRequest( 1235 w, 1236 r, 1237 repo, 1238 user, 1239 targetBranch, 1240 patch, 1241 sourceRev, 1242 pullSource, 1243 ) 1244 return 1245 } 1246 1247 client, err := s.oauth.AuthorizedClient(r) 1248 if err != nil { 1249 log.Println("failed to get authorized client", err) 1250 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1251 return 1252 } 1253 1254 tx, err := s.db.BeginTx(r.Context(), nil) 1255 if err != nil { 1256 log.Println("failed to start tx") 1257 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1258 return 1259 } 1260 defer tx.Rollback() 1261 1262 // We've already checked earlier if it's diff-based and title is empty, 1263 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1264 if title == "" || body == "" { 1265 formatPatches, err := patchutil.ExtractPatches(patch) 1266 if err != nil { 1267 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 1268 return 1269 } 1270 if len(formatPatches) == 0 { 1271 s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.") 1272 return 1273 } 1274 1275 if title == "" { 1276 title = formatPatches[0].Title 1277 } 1278 if body == "" { 1279 body = formatPatches[0].Body 1280 } 1281 } 1282 1283 mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 1284 1285 rkey := tid.TID() 1286 initialSubmission := models.PullSubmission{ 1287 Patch: patch, 1288 Combined: combined, 1289 SourceRev: sourceRev, 1290 } 1291 pull := &models.Pull{ 1292 Title: title, 1293 Body: body, 1294 TargetBranch: targetBranch, 1295 OwnerDid: user.Active.Did, 1296 RepoAt: repo.RepoAt(), 1297 Rkey: rkey, 1298 Mentions: mentions, 1299 References: references, 1300 Submissions: []*models.PullSubmission{ 1301 &initialSubmission, 1302 }, 1303 PullSource: pullSource, 1304 } 1305 err = db.NewPull(tx, pull) 1306 if err != nil { 1307 log.Println("failed to create pull request", err) 1308 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1309 return 1310 } 1311 pullId, err := db.NextPullId(tx, repo.RepoAt()) 1312 if err != nil { 1313 log.Println("failed to get pull id", err) 1314 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1315 return 1316 } 1317 1318 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 1319 if err != nil { 1320 log.Println("failed to upload patch", err) 1321 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1322 return 1323 } 1324 1325 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1326 Collection: tangled.RepoPullNSID, 1327 Repo: user.Active.Did, 1328 Rkey: rkey, 1329 Record: &lexutil.LexiconTypeDecoder{ 1330 Val: &tangled.RepoPull{ 1331 Title: title, 1332 Target: &tangled.RepoPull_Target{ 1333 Repo: string(repo.RepoAt()), 1334 Branch: targetBranch, 1335 }, 1336 PatchBlob: blob.Blob, 1337 Source: recordPullSource, 1338 CreatedAt: time.Now().Format(time.RFC3339), 1339 }, 1340 }, 1341 }) 1342 if err != nil { 1343 log.Println("failed to create pull request", err) 1344 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1345 return 1346 } 1347 1348 if err = tx.Commit(); err != nil { 1349 log.Println("failed to create pull request", err) 1350 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1351 return 1352 } 1353 1354 s.notifier.NewPull(r.Context(), pull) 1355 1356 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1357 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId)) 1358} 1359 1360func (s *Pulls) createStackedPullRequest( 1361 w http.ResponseWriter, 1362 r *http.Request, 1363 repo *models.Repo, 1364 user *oauth.MultiAccountUser, 1365 targetBranch string, 1366 patch string, 1367 sourceRev string, 1368 pullSource *models.PullSource, 1369) { 1370 // run some necessary checks for stacked-prs first 1371 1372 // must be branch or fork based 1373 if sourceRev == "" { 1374 log.Println("stacked PR from patch-based pull") 1375 s.pages.Notice(w, "pull", "Stacking is only supported on branch and fork based pull-requests.") 1376 return 1377 } 1378 1379 formatPatches, err := patchutil.ExtractPatches(patch) 1380 if err != nil { 1381 log.Println("failed to extract patches", err) 1382 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 1383 return 1384 } 1385 1386 // must have atleast 1 patch to begin with 1387 if len(formatPatches) == 0 { 1388 log.Println("empty patches") 1389 s.pages.Notice(w, "pull", "No patches found in the generated format-patch.") 1390 return 1391 } 1392 1393 // build a stack out of this patch 1394 stackId := uuid.New() 1395 stack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pullSource, stackId.String()) 1396 if err != nil { 1397 log.Println("failed to create stack", err) 1398 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) 1399 return 1400 } 1401 1402 client, err := s.oauth.AuthorizedClient(r) 1403 if err != nil { 1404 log.Println("failed to get authorized client", err) 1405 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1406 return 1407 } 1408 1409 // apply all record creations at once 1410 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1411 for _, p := range stack { 1412 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.LatestPatch()), ApplicationGzip) 1413 if err != nil { 1414 log.Println("failed to upload patch blob", err) 1415 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1416 return 1417 } 1418 1419 record := p.AsRecord() 1420 record.PatchBlob = blob.Blob 1421 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1422 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1423 Collection: tangled.RepoPullNSID, 1424 Rkey: &p.Rkey, 1425 Value: &lexutil.LexiconTypeDecoder{ 1426 Val: &record, 1427 }, 1428 }, 1429 }) 1430 } 1431 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1432 Repo: user.Active.Did, 1433 Writes: writes, 1434 }) 1435 if err != nil { 1436 log.Println("failed to create stacked pull request", err) 1437 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 1438 return 1439 } 1440 1441 // create all pulls at once 1442 tx, err := s.db.BeginTx(r.Context(), nil) 1443 if err != nil { 1444 log.Println("failed to start tx") 1445 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1446 return 1447 } 1448 defer tx.Rollback() 1449 1450 for _, p := range stack { 1451 err = db.NewPull(tx, p) 1452 if err != nil { 1453 log.Println("failed to create pull request", err) 1454 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1455 return 1456 } 1457 1458 } 1459 1460 if err = tx.Commit(); err != nil { 1461 log.Println("failed to create pull request", err) 1462 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1463 return 1464 } 1465 1466 // notify about each pull 1467 // 1468 // this is performed after tx.Commit, because it could result in a locked DB otherwise 1469 for _, p := range stack { 1470 s.notifier.NewPull(r.Context(), p) 1471 } 1472 1473 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1474 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo)) 1475} 1476 1477func (s *Pulls) ValidatePatch(w http.ResponseWriter, r *http.Request) { 1478 _, err := s.repoResolver.Resolve(r) 1479 if err != nil { 1480 log.Println("failed to get repo and knot", err) 1481 return 1482 } 1483 1484 patch := r.FormValue("patch") 1485 if patch == "" { 1486 s.pages.Notice(w, "patch-error", "Patch is required.") 1487 return 1488 } 1489 1490 if err := s.validator.ValidatePatch(&patch); err != nil { 1491 s.logger.Error("faield to validate patch", "err", err) 1492 s.pages.Notice(w, "patch-error", "Invalid patch format. Please provide a valid git diff or format-patch.") 1493 return 1494 } 1495 1496 if patchutil.IsFormatPatch(patch) { 1497 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.") 1498 } else { 1499 s.pages.Notice(w, "patch-preview", "Regular git-diff detected. Please provide a title and description.") 1500 } 1501} 1502 1503func (s *Pulls) PatchUploadFragment(w http.ResponseWriter, r *http.Request) { 1504 user := s.oauth.GetMultiAccountUser(r) 1505 1506 s.pages.PullPatchUploadFragment(w, pages.PullPatchUploadParams{ 1507 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1508 }) 1509} 1510 1511func (s *Pulls) CompareBranchesFragment(w http.ResponseWriter, r *http.Request) { 1512 user := s.oauth.GetMultiAccountUser(r) 1513 f, err := s.repoResolver.Resolve(r) 1514 if err != nil { 1515 log.Println("failed to get repo and knot", err) 1516 return 1517 } 1518 1519 scheme := "http" 1520 if !s.config.Core.Dev { 1521 scheme = "https" 1522 } 1523 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1524 xrpcc := &indigoxrpc.Client{ 1525 Host: host, 1526 } 1527 1528 repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1529 xrpcBytes, err := tangled.RepoBranches(r.Context(), xrpcc, "", 0, repo) 1530 if err != nil { 1531 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1532 log.Println("failed to call XRPC repo.branches", xrpcerr) 1533 s.pages.Error503(w) 1534 return 1535 } 1536 log.Println("failed to fetch branches", err) 1537 return 1538 } 1539 1540 var result types.RepoBranchesResponse 1541 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1542 log.Println("failed to decode XRPC response", err) 1543 s.pages.Error503(w) 1544 return 1545 } 1546 1547 branches := result.Branches 1548 sort.Slice(branches, func(i int, j int) bool { 1549 return branches[i].Commit.Committer.When.After(branches[j].Commit.Committer.When) 1550 }) 1551 1552 withoutDefault := []types.Branch{} 1553 for _, b := range branches { 1554 if b.IsDefault { 1555 continue 1556 } 1557 withoutDefault = append(withoutDefault, b) 1558 } 1559 1560 s.pages.PullCompareBranchesFragment(w, pages.PullCompareBranchesParams{ 1561 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1562 Branches: withoutDefault, 1563 }) 1564} 1565 1566func (s *Pulls) CompareForksFragment(w http.ResponseWriter, r *http.Request) { 1567 user := s.oauth.GetMultiAccountUser(r) 1568 1569 forks, err := db.GetForksByDid(s.db, user.Active.Did) 1570 if err != nil { 1571 log.Println("failed to get forks", err) 1572 return 1573 } 1574 1575 s.pages.PullCompareForkFragment(w, pages.PullCompareForkParams{ 1576 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1577 Forks: forks, 1578 Selected: r.URL.Query().Get("fork"), 1579 }) 1580} 1581 1582func (s *Pulls) CompareForksBranchesFragment(w http.ResponseWriter, r *http.Request) { 1583 user := s.oauth.GetMultiAccountUser(r) 1584 1585 f, err := s.repoResolver.Resolve(r) 1586 if err != nil { 1587 log.Println("failed to get repo and knot", err) 1588 return 1589 } 1590 1591 forkVal := r.URL.Query().Get("fork") 1592 repoString := strings.SplitN(forkVal, "/", 2) 1593 forkOwnerDid := repoString[0] 1594 forkName := repoString[1] 1595 // fork repo 1596 repo, err := db.GetRepo( 1597 s.db, 1598 orm.FilterEq("did", forkOwnerDid), 1599 orm.FilterEq("name", forkName), 1600 ) 1601 if err != nil { 1602 log.Println("failed to get repo", "did", forkOwnerDid, "name", forkName, "err", err) 1603 return 1604 } 1605 1606 sourceScheme := "http" 1607 if !s.config.Core.Dev { 1608 sourceScheme = "https" 1609 } 1610 sourceHost := fmt.Sprintf("%s://%s", sourceScheme, repo.Knot) 1611 sourceXrpcc := &indigoxrpc.Client{ 1612 Host: sourceHost, 1613 } 1614 1615 sourceRepo := fmt.Sprintf("%s/%s", forkOwnerDid, repo.Name) 1616 sourceXrpcBytes, err := tangled.RepoBranches(r.Context(), sourceXrpcc, "", 0, sourceRepo) 1617 if err != nil { 1618 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1619 log.Println("failed to call XRPC repo.branches for source", xrpcerr) 1620 s.pages.Error503(w) 1621 return 1622 } 1623 log.Println("failed to fetch source branches", err) 1624 return 1625 } 1626 1627 // Decode source branches 1628 var sourceBranches types.RepoBranchesResponse 1629 if err := json.Unmarshal(sourceXrpcBytes, &sourceBranches); err != nil { 1630 log.Println("failed to decode source branches XRPC response", err) 1631 s.pages.Error503(w) 1632 return 1633 } 1634 1635 targetScheme := "http" 1636 if !s.config.Core.Dev { 1637 targetScheme = "https" 1638 } 1639 targetHost := fmt.Sprintf("%s://%s", targetScheme, f.Knot) 1640 targetXrpcc := &indigoxrpc.Client{ 1641 Host: targetHost, 1642 } 1643 1644 targetRepo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1645 targetXrpcBytes, err := tangled.RepoBranches(r.Context(), targetXrpcc, "", 0, targetRepo) 1646 if err != nil { 1647 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1648 log.Println("failed to call XRPC repo.branches for target", xrpcerr) 1649 s.pages.Error503(w) 1650 return 1651 } 1652 log.Println("failed to fetch target branches", err) 1653 return 1654 } 1655 1656 // Decode target branches 1657 var targetBranches types.RepoBranchesResponse 1658 if err := json.Unmarshal(targetXrpcBytes, &targetBranches); err != nil { 1659 log.Println("failed to decode target branches XRPC response", err) 1660 s.pages.Error503(w) 1661 return 1662 } 1663 1664 sort.Slice(sourceBranches.Branches, func(i int, j int) bool { 1665 return sourceBranches.Branches[i].Commit.Committer.When.After(sourceBranches.Branches[j].Commit.Committer.When) 1666 }) 1667 1668 s.pages.PullCompareForkBranchesFragment(w, pages.PullCompareForkBranchesParams{ 1669 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1670 SourceBranches: sourceBranches.Branches, 1671 TargetBranches: targetBranches.Branches, 1672 }) 1673} 1674 1675func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { 1676 user := s.oauth.GetMultiAccountUser(r) 1677 1678 pull, ok := r.Context().Value("pull").(*models.Pull) 1679 if !ok { 1680 log.Println("failed to get pull") 1681 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1682 return 1683 } 1684 1685 switch r.Method { 1686 case http.MethodGet: 1687 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 1688 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 1689 Pull: pull, 1690 }) 1691 return 1692 case http.MethodPost: 1693 if pull.IsPatchBased() { 1694 s.resubmitPatch(w, r) 1695 return 1696 } else if pull.IsBranchBased() { 1697 s.resubmitBranch(w, r) 1698 return 1699 } else if pull.IsForkBased() { 1700 s.resubmitFork(w, r) 1701 return 1702 } 1703 } 1704} 1705 1706func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 1707 user := s.oauth.GetMultiAccountUser(r) 1708 1709 pull, ok := r.Context().Value("pull").(*models.Pull) 1710 if !ok { 1711 log.Println("failed to get pull") 1712 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 1713 return 1714 } 1715 1716 f, err := s.repoResolver.Resolve(r) 1717 if err != nil { 1718 log.Println("failed to get repo and knot", err) 1719 return 1720 } 1721 1722 if user.Active.Did != pull.OwnerDid { 1723 log.Println("unauthorized user") 1724 w.WriteHeader(http.StatusUnauthorized) 1725 return 1726 } 1727 1728 patch := r.FormValue("patch") 1729 1730 s.resubmitPullHelper(w, r, f, user, pull, patch, "", "") 1731} 1732 1733func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 1734 user := s.oauth.GetMultiAccountUser(r) 1735 1736 pull, ok := r.Context().Value("pull").(*models.Pull) 1737 if !ok { 1738 log.Println("failed to get pull") 1739 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 1740 return 1741 } 1742 1743 f, err := s.repoResolver.Resolve(r) 1744 if err != nil { 1745 log.Println("failed to get repo and knot", err) 1746 return 1747 } 1748 1749 if user.Active.Did != pull.OwnerDid { 1750 log.Println("unauthorized user") 1751 w.WriteHeader(http.StatusUnauthorized) 1752 return 1753 } 1754 1755 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 1756 if !roles.IsPushAllowed() { 1757 log.Println("unauthorized user") 1758 w.WriteHeader(http.StatusUnauthorized) 1759 return 1760 } 1761 1762 scheme := "http" 1763 if !s.config.Core.Dev { 1764 scheme = "https" 1765 } 1766 host := fmt.Sprintf("%s://%s", scheme, f.Knot) 1767 xrpcc := &indigoxrpc.Client{ 1768 Host: host, 1769 } 1770 1771 repo := fmt.Sprintf("%s/%s", f.Did, f.Name) 1772 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo, pull.TargetBranch, pull.PullSource.Branch) 1773 if err != nil { 1774 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1775 log.Println("failed to call XRPC repo.compare", xrpcerr) 1776 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1777 return 1778 } 1779 log.Printf("compare request failed: %s", err) 1780 s.pages.Notice(w, "resubmit-error", err.Error()) 1781 return 1782 } 1783 1784 var comparison types.RepoFormatPatchResponse 1785 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 1786 log.Println("failed to decode XRPC compare response", err) 1787 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1788 return 1789 } 1790 1791 sourceRev := comparison.Rev2 1792 patch := comparison.FormatPatchRaw 1793 combined := comparison.CombinedPatchRaw 1794 1795 s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1796} 1797 1798func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 1799 user := s.oauth.GetMultiAccountUser(r) 1800 1801 pull, ok := r.Context().Value("pull").(*models.Pull) 1802 if !ok { 1803 log.Println("failed to get pull") 1804 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 1805 return 1806 } 1807 1808 f, err := s.repoResolver.Resolve(r) 1809 if err != nil { 1810 log.Println("failed to get repo and knot", err) 1811 return 1812 } 1813 1814 if user.Active.Did != pull.OwnerDid { 1815 log.Println("unauthorized user") 1816 w.WriteHeader(http.StatusUnauthorized) 1817 return 1818 } 1819 1820 forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 1821 if err != nil { 1822 log.Println("failed to get source repo", err) 1823 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1824 return 1825 } 1826 1827 // update the hidden tracking branch to latest 1828 client, err := s.oauth.ServiceClient( 1829 r, 1830 oauth.WithService(forkRepo.Knot), 1831 oauth.WithLxm(tangled.RepoHiddenRefNSID), 1832 oauth.WithDev(s.config.Core.Dev), 1833 ) 1834 if err != nil { 1835 log.Printf("failed to connect to knot server: %v", err) 1836 return 1837 } 1838 1839 resp, err := tangled.RepoHiddenRef( 1840 r.Context(), 1841 client, 1842 &tangled.RepoHiddenRef_Input{ 1843 ForkRef: pull.PullSource.Branch, 1844 RemoteRef: pull.TargetBranch, 1845 Repo: forkRepo.RepoAt().String(), 1846 }, 1847 ) 1848 if err := xrpcclient.HandleXrpcErr(err); err != nil { 1849 s.pages.Notice(w, "resubmit-error", err.Error()) 1850 return 1851 } 1852 if !resp.Success { 1853 log.Println("Failed to update tracking ref.", "err", resp.Error) 1854 s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.") 1855 return 1856 } 1857 1858 hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 1859 // extract patch by performing compare 1860 forkScheme := "http" 1861 if !s.config.Core.Dev { 1862 forkScheme = "https" 1863 } 1864 forkHost := fmt.Sprintf("%s://%s", forkScheme, forkRepo.Knot) 1865 forkRepoId := fmt.Sprintf("%s/%s", forkRepo.Did, forkRepo.Name) 1866 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), &indigoxrpc.Client{Host: forkHost}, forkRepoId, hiddenRef, pull.PullSource.Branch) 1867 if err != nil { 1868 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1869 log.Println("failed to call XRPC repo.compare for fork", xrpcerr) 1870 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1871 return 1872 } 1873 log.Printf("failed to compare branches: %s", err) 1874 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1875 return 1876 } 1877 1878 var forkComparison types.RepoFormatPatchResponse 1879 if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 1880 log.Println("failed to decode XRPC compare response for fork", err) 1881 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1882 return 1883 } 1884 1885 // Use the fork comparison we already made 1886 comparison := forkComparison 1887 1888 sourceRev := comparison.Rev2 1889 patch := comparison.FormatPatchRaw 1890 combined := comparison.CombinedPatchRaw 1891 1892 s.resubmitPullHelper(w, r, f, user, pull, patch, combined, sourceRev) 1893} 1894 1895func (s *Pulls) resubmitPullHelper( 1896 w http.ResponseWriter, 1897 r *http.Request, 1898 repo *models.Repo, 1899 user *oauth.MultiAccountUser, 1900 pull *models.Pull, 1901 patch string, 1902 combined string, 1903 sourceRev string, 1904) { 1905 if pull.IsStacked() { 1906 log.Println("resubmitting stacked PR") 1907 s.resubmitStackedPullHelper(w, r, repo, user, pull, patch, pull.StackId) 1908 return 1909 } 1910 1911 if err := s.validator.ValidatePatch(&patch); err != nil { 1912 s.pages.Notice(w, "resubmit-error", err.Error()) 1913 return 1914 } 1915 1916 if patch == pull.LatestPatch() { 1917 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 1918 return 1919 } 1920 1921 // validate sourceRev if branch/fork based 1922 if pull.IsBranchBased() || pull.IsForkBased() { 1923 if sourceRev == pull.LatestSha() { 1924 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 1925 return 1926 } 1927 } 1928 1929 tx, err := s.db.BeginTx(r.Context(), nil) 1930 if err != nil { 1931 log.Println("failed to start tx") 1932 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1933 return 1934 } 1935 defer tx.Rollback() 1936 1937 pullAt := pull.AtUri() 1938 newRoundNumber := len(pull.Submissions) 1939 newPatch := patch 1940 newSourceRev := sourceRev 1941 combinedPatch := combined 1942 err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 1943 if err != nil { 1944 log.Println("failed to create pull request", err) 1945 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1946 return 1947 } 1948 client, err := s.oauth.AuthorizedClient(r) 1949 if err != nil { 1950 log.Println("failed to authorize client") 1951 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 1952 return 1953 } 1954 1955 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, user.Active.Did, pull.Rkey) 1956 if err != nil { 1957 // failed to get record 1958 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 1959 return 1960 } 1961 1962 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 1963 if err != nil { 1964 log.Println("failed to upload patch blob", err) 1965 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1966 return 1967 } 1968 record := pull.AsRecord() 1969 record.PatchBlob = blob.Blob 1970 record.CreatedAt = time.Now().Format(time.RFC3339) 1971 1972 if record.Source != nil { 1973 record.Source.Sha = newSourceRev 1974 } 1975 1976 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1977 Collection: tangled.RepoPullNSID, 1978 Repo: user.Active.Did, 1979 Rkey: pull.Rkey, 1980 SwapRecord: ex.Cid, 1981 Record: &lexutil.LexiconTypeDecoder{ 1982 Val: &record, 1983 }, 1984 }) 1985 if err != nil { 1986 log.Println("failed to update record", err) 1987 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 1988 return 1989 } 1990 1991 if err = tx.Commit(); err != nil { 1992 log.Println("failed to commit transaction", err) 1993 s.pages.Notice(w, "resubmit-error", "Failed to resubmit pull.") 1994 return 1995 } 1996 1997 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1998 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 1999} 2000 2001func (s *Pulls) resubmitStackedPullHelper( 2002 w http.ResponseWriter, 2003 r *http.Request, 2004 repo *models.Repo, 2005 user *oauth.MultiAccountUser, 2006 pull *models.Pull, 2007 patch string, 2008 stackId string, 2009) { 2010 targetBranch := pull.TargetBranch 2011 2012 origStack, _ := r.Context().Value("stack").(models.Stack) 2013 newStack, err := s.newStack(r.Context(), repo, user, targetBranch, patch, pull.PullSource, stackId) 2014 if err != nil { 2015 log.Println("failed to create resubmitted stack", err) 2016 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2017 return 2018 } 2019 2020 // find the diff between the stacks, first, map them by changeId 2021 origById := make(map[string]*models.Pull) 2022 newById := make(map[string]*models.Pull) 2023 for _, p := range origStack { 2024 origById[p.ChangeId] = p 2025 } 2026 for _, p := range newStack { 2027 newById[p.ChangeId] = p 2028 } 2029 2030 // commits that got deleted: corresponding pull is closed 2031 // commits that got added: new pull is created 2032 // commits that got updated: corresponding pull is resubmitted & new round begins 2033 additions := make(map[string]*models.Pull) 2034 deletions := make(map[string]*models.Pull) 2035 updated := make(map[string]struct{}) 2036 2037 // pulls in orignal stack but not in new one 2038 for _, op := range origStack { 2039 if _, ok := newById[op.ChangeId]; !ok { 2040 deletions[op.ChangeId] = op 2041 } 2042 } 2043 2044 // pulls in new stack but not in original one 2045 for _, np := range newStack { 2046 if _, ok := origById[np.ChangeId]; !ok { 2047 additions[np.ChangeId] = np 2048 } 2049 } 2050 2051 // NOTE: this loop can be written in any of above blocks, 2052 // but is written separately in the interest of simpler code 2053 for _, np := range newStack { 2054 if op, ok := origById[np.ChangeId]; ok { 2055 // pull exists in both stacks 2056 updated[op.ChangeId] = struct{}{} 2057 } 2058 } 2059 2060 tx, err := s.db.Begin() 2061 if err != nil { 2062 log.Println("failed to start transaction", err) 2063 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2064 return 2065 } 2066 defer tx.Rollback() 2067 2068 client, err := s.oauth.AuthorizedClient(r) 2069 if err != nil { 2070 log.Println("failed to authorize client") 2071 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2072 return 2073 } 2074 2075 // pds updates to make 2076 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 2077 2078 // deleted pulls are marked as deleted in the DB 2079 for _, p := range deletions { 2080 // do not do delete already merged PRs 2081 if p.State == models.PullMerged { 2082 continue 2083 } 2084 2085 err := db.DeletePull(tx, p.RepoAt, p.PullId) 2086 if err != nil { 2087 log.Println("failed to delete pull", err, p.PullId) 2088 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2089 return 2090 } 2091 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2092 RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 2093 Collection: tangled.RepoPullNSID, 2094 Rkey: p.Rkey, 2095 }, 2096 }) 2097 } 2098 2099 // new pulls are created 2100 for _, p := range additions { 2101 err := db.NewPull(tx, p) 2102 if err != nil { 2103 log.Println("failed to create pull", err, p.PullId) 2104 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2105 return 2106 } 2107 2108 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 2109 if err != nil { 2110 log.Println("failed to upload patch blob", err) 2111 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2112 return 2113 } 2114 record := p.AsRecord() 2115 record.PatchBlob = blob.Blob 2116 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2117 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 2118 Collection: tangled.RepoPullNSID, 2119 Rkey: &p.Rkey, 2120 Value: &lexutil.LexiconTypeDecoder{ 2121 Val: &record, 2122 }, 2123 }, 2124 }) 2125 } 2126 2127 // updated pulls are, well, updated; to start a new round 2128 for id := range updated { 2129 op, _ := origById[id] 2130 np, _ := newById[id] 2131 2132 // do not update already merged PRs 2133 if op.State == models.PullMerged { 2134 continue 2135 } 2136 2137 // resubmit the new pull 2138 pullAt := op.AtUri() 2139 newRoundNumber := len(op.Submissions) 2140 newPatch := np.LatestPatch() 2141 combinedPatch := np.LatestSubmission().Combined 2142 newSourceRev := np.LatestSha() 2143 err := db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev) 2144 if err != nil { 2145 log.Println("failed to update pull", err, op.PullId) 2146 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2147 return 2148 } 2149 2150 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 2151 if err != nil { 2152 log.Println("failed to upload patch blob", err) 2153 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2154 return 2155 } 2156 record := np.AsRecord() 2157 record.PatchBlob = blob.Blob 2158 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2159 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 2160 Collection: tangled.RepoPullNSID, 2161 Rkey: op.Rkey, 2162 Value: &lexutil.LexiconTypeDecoder{ 2163 Val: &record, 2164 }, 2165 }, 2166 }) 2167 } 2168 2169 // update parent-change-id relations for the entire stack 2170 for _, p := range newStack { 2171 err := db.SetPullParentChangeId( 2172 tx, 2173 p.ParentChangeId, 2174 // these should be enough filters to be unique per-stack 2175 orm.FilterEq("repo_at", p.RepoAt.String()), 2176 orm.FilterEq("owner_did", p.OwnerDid), 2177 orm.FilterEq("change_id", p.ChangeId), 2178 ) 2179 2180 if err != nil { 2181 log.Println("failed to update pull", err, p.PullId) 2182 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2183 return 2184 } 2185 } 2186 2187 err = tx.Commit() 2188 if err != nil { 2189 log.Println("failed to resubmit pull", err) 2190 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2191 return 2192 } 2193 2194 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2195 Repo: user.Active.Did, 2196 Writes: writes, 2197 }) 2198 if err != nil { 2199 log.Println("failed to create stacked pull request", err) 2200 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 2201 return 2202 } 2203 2204 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 2205 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2206} 2207 2208func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 2209 user := s.oauth.GetMultiAccountUser(r) 2210 f, err := s.repoResolver.Resolve(r) 2211 if err != nil { 2212 log.Println("failed to resolve repo:", err) 2213 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2214 return 2215 } 2216 2217 pull, ok := r.Context().Value("pull").(*models.Pull) 2218 if !ok { 2219 log.Println("failed to get pull") 2220 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2221 return 2222 } 2223 2224 var pullsToMerge models.Stack 2225 pullsToMerge = append(pullsToMerge, pull) 2226 if pull.IsStacked() { 2227 stack, ok := r.Context().Value("stack").(models.Stack) 2228 if !ok { 2229 log.Println("failed to get stack") 2230 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2231 return 2232 } 2233 2234 // combine patches of substack 2235 subStack := stack.StrictlyBelow(pull) 2236 // collect the portion of the stack that is mergeable 2237 mergeable := subStack.Mergeable() 2238 // add to total patch 2239 pullsToMerge = append(pullsToMerge, mergeable...) 2240 } 2241 2242 patch := pullsToMerge.CombinedPatch() 2243 2244 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 2245 if err != nil { 2246 log.Printf("resolving identity: %s", err) 2247 w.WriteHeader(http.StatusNotFound) 2248 return 2249 } 2250 2251 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 2252 if err != nil { 2253 log.Printf("failed to get primary email: %s", err) 2254 } 2255 2256 authorName := ident.Handle.String() 2257 mergeInput := &tangled.RepoMerge_Input{ 2258 Did: f.Did, 2259 Name: f.Name, 2260 Branch: pull.TargetBranch, 2261 Patch: patch, 2262 CommitMessage: &pull.Title, 2263 AuthorName: &authorName, 2264 } 2265 2266 if pull.Body != "" { 2267 mergeInput.CommitBody = &pull.Body 2268 } 2269 2270 if email.Address != "" { 2271 mergeInput.AuthorEmail = &email.Address 2272 } 2273 2274 client, err := s.oauth.ServiceClient( 2275 r, 2276 oauth.WithService(f.Knot), 2277 oauth.WithLxm(tangled.RepoMergeNSID), 2278 oauth.WithDev(s.config.Core.Dev), 2279 ) 2280 if err != nil { 2281 log.Printf("failed to connect to knot server: %v", err) 2282 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2283 return 2284 } 2285 2286 err = tangled.RepoMerge(r.Context(), client, mergeInput) 2287 if err := xrpcclient.HandleXrpcErr(err); err != nil { 2288 s.pages.Notice(w, "pull-merge-error", err.Error()) 2289 return 2290 } 2291 2292 tx, err := s.db.Begin() 2293 if err != nil { 2294 log.Println("failed to start transcation", err) 2295 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2296 return 2297 } 2298 defer tx.Rollback() 2299 2300 for _, p := range pullsToMerge { 2301 err := db.MergePull(tx, f.RepoAt(), p.PullId) 2302 if err != nil { 2303 log.Printf("failed to update pull request status in database: %s", err) 2304 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2305 return 2306 } 2307 p.State = models.PullMerged 2308 } 2309 2310 err = tx.Commit() 2311 if err != nil { 2312 // TODO: this is unsound, we should also revert the merge from the knotserver here 2313 log.Printf("failed to update pull request status in database: %s", err) 2314 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2315 return 2316 } 2317 2318 // notify about the pull merge 2319 for _, p := range pullsToMerge { 2320 s.notifier.NewPullState(r.Context(), syntax.DID(user.Active.Did), p) 2321 } 2322 2323 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2324 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2325} 2326 2327func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { 2328 user := s.oauth.GetMultiAccountUser(r) 2329 2330 f, err := s.repoResolver.Resolve(r) 2331 if err != nil { 2332 log.Println("malformed middleware") 2333 return 2334 } 2335 2336 pull, ok := r.Context().Value("pull").(*models.Pull) 2337 if !ok { 2338 log.Println("failed to get pull") 2339 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2340 return 2341 } 2342 2343 // auth filter: only owner or collaborators can close 2344 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 2345 isOwner := roles.IsOwner() 2346 isCollaborator := roles.IsCollaborator() 2347 isPullAuthor := user.Active.Did == pull.OwnerDid 2348 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2349 if !isCloseAllowed { 2350 log.Println("failed to close pull") 2351 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 2352 return 2353 } 2354 2355 // Start a transaction 2356 tx, err := s.db.BeginTx(r.Context(), nil) 2357 if err != nil { 2358 log.Println("failed to start transaction", err) 2359 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2360 return 2361 } 2362 defer tx.Rollback() 2363 2364 var pullsToClose []*models.Pull 2365 pullsToClose = append(pullsToClose, pull) 2366 2367 // if this PR is stacked, then we want to close all PRs below this one on the stack 2368 if pull.IsStacked() { 2369 stack := r.Context().Value("stack").(models.Stack) 2370 subStack := stack.StrictlyBelow(pull) 2371 pullsToClose = append(pullsToClose, subStack...) 2372 } 2373 2374 for _, p := range pullsToClose { 2375 // Close the pull in the database 2376 err = db.ClosePull(tx, f.RepoAt(), p.PullId) 2377 if err != nil { 2378 log.Println("failed to close pull", err) 2379 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2380 return 2381 } 2382 p.State = models.PullClosed 2383 } 2384 2385 // Commit the transaction 2386 if err = tx.Commit(); err != nil { 2387 log.Println("failed to commit transaction", err) 2388 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2389 return 2390 } 2391 2392 for _, p := range pullsToClose { 2393 s.notifier.NewPullState(r.Context(), syntax.DID(user.Active.Did), p) 2394 } 2395 2396 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2397 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2398} 2399 2400func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { 2401 user := s.oauth.GetMultiAccountUser(r) 2402 2403 f, err := s.repoResolver.Resolve(r) 2404 if err != nil { 2405 log.Println("failed to resolve repo", err) 2406 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2407 return 2408 } 2409 2410 pull, ok := r.Context().Value("pull").(*models.Pull) 2411 if !ok { 2412 log.Println("failed to get pull") 2413 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2414 return 2415 } 2416 2417 // auth filter: only owner or collaborators can close 2418 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Active.Did, f.Knot, f.DidSlashRepo())} 2419 isOwner := roles.IsOwner() 2420 isCollaborator := roles.IsCollaborator() 2421 isPullAuthor := user.Active.Did == pull.OwnerDid 2422 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2423 if !isCloseAllowed { 2424 log.Println("failed to close pull") 2425 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 2426 return 2427 } 2428 2429 // Start a transaction 2430 tx, err := s.db.BeginTx(r.Context(), nil) 2431 if err != nil { 2432 log.Println("failed to start transaction", err) 2433 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2434 return 2435 } 2436 defer tx.Rollback() 2437 2438 var pullsToReopen []*models.Pull 2439 pullsToReopen = append(pullsToReopen, pull) 2440 2441 // if this PR is stacked, then we want to reopen all PRs above this one on the stack 2442 if pull.IsStacked() { 2443 stack := r.Context().Value("stack").(models.Stack) 2444 subStack := stack.StrictlyAbove(pull) 2445 pullsToReopen = append(pullsToReopen, subStack...) 2446 } 2447 2448 for _, p := range pullsToReopen { 2449 // Close the pull in the database 2450 err = db.ReopenPull(tx, f.RepoAt(), p.PullId) 2451 if err != nil { 2452 log.Println("failed to close pull", err) 2453 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2454 return 2455 } 2456 p.State = models.PullOpen 2457 } 2458 2459 // Commit the transaction 2460 if err = tx.Commit(); err != nil { 2461 log.Println("failed to commit transaction", err) 2462 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2463 return 2464 } 2465 2466 for _, p := range pullsToReopen { 2467 s.notifier.NewPullState(r.Context(), syntax.DID(user.Active.Did), p) 2468 } 2469 2470 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2471 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2472} 2473 2474func (s *Pulls) newStack(ctx context.Context, repo *models.Repo, user *oauth.MultiAccountUser, targetBranch, patch string, pullSource *models.PullSource, stackId string) (models.Stack, error) { 2475 formatPatches, err := patchutil.ExtractPatches(patch) 2476 if err != nil { 2477 return nil, fmt.Errorf("Failed to extract patches: %v", err) 2478 } 2479 2480 // must have atleast 1 patch to begin with 2481 if len(formatPatches) == 0 { 2482 return nil, fmt.Errorf("No patches found in the generated format-patch.") 2483 } 2484 2485 // the stack is identified by a UUID 2486 var stack models.Stack 2487 parentChangeId := "" 2488 for _, fp := range formatPatches { 2489 // all patches must have a jj change-id 2490 changeId, err := fp.ChangeId() 2491 if err != nil { 2492 return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.") 2493 } 2494 2495 title := fp.Title 2496 body := fp.Body 2497 rkey := tid.TID() 2498 2499 mentions, references := s.mentionsResolver.Resolve(ctx, body) 2500 2501 initialSubmission := models.PullSubmission{ 2502 Patch: fp.Raw, 2503 SourceRev: fp.SHA, 2504 Combined: fp.Raw, 2505 } 2506 pull := models.Pull{ 2507 Title: title, 2508 Body: body, 2509 TargetBranch: targetBranch, 2510 OwnerDid: user.Active.Did, 2511 RepoAt: repo.RepoAt(), 2512 Rkey: rkey, 2513 Mentions: mentions, 2514 References: references, 2515 Submissions: []*models.PullSubmission{ 2516 &initialSubmission, 2517 }, 2518 PullSource: pullSource, 2519 Created: time.Now(), 2520 2521 StackId: stackId, 2522 ChangeId: changeId, 2523 ParentChangeId: parentChangeId, 2524 } 2525 2526 stack = append(stack, &pull) 2527 2528 parentChangeId = changeId 2529 } 2530 2531 return stack, nil 2532} 2533 2534func gz(s string) io.Reader { 2535 var b bytes.Buffer 2536 w := gzip.NewWriter(&b) 2537 w.Write([]byte(s)) 2538 w.Close() 2539 return &b 2540} 2541 2542func ptrPullState(s models.PullState) *models.PullState { return &s }