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