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