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