Monorepo for Tangled tangled.org
11

Configure Feed

Select the types of activity you want to include in your feed.

1package pulls 2 3import ( 4 "bytes" 5 "compress/gzip" 6 "context" 7 "database/sql" 8 "encoding/json" 9 "errors" 10 "fmt" 11 "io" 12 "iter" 13 "log/slog" 14 "net/http" 15 "net/url" 16 "slices" 17 "sort" 18 "strconv" 19 "strings" 20 "time" 21 22 "tangled.org/core/api/tangled" 23 "tangled.org/core/appview/config" 24 "tangled.org/core/appview/db" 25 pulls_indexer "tangled.org/core/appview/indexer/pulls" 26 "tangled.org/core/appview/mentions" 27 "tangled.org/core/appview/models" 28 "tangled.org/core/appview/notify" 29 "tangled.org/core/appview/oauth" 30 "tangled.org/core/appview/pages" 31 "tangled.org/core/appview/pages/markup" 32 "tangled.org/core/appview/pages/repoinfo" 33 "tangled.org/core/appview/pagination" 34 "tangled.org/core/appview/reporesolver" 35 "tangled.org/core/appview/searchquery" 36 "tangled.org/core/appview/validator" 37 "tangled.org/core/appview/xrpcclient" 38 "tangled.org/core/idresolver" 39 "tangled.org/core/ogre" 40 "tangled.org/core/orm" 41 "tangled.org/core/patchutil" 42 "tangled.org/core/rbac" 43 "tangled.org/core/tid" 44 "tangled.org/core/types" 45 "tangled.org/core/xrpc" 46 47 comatproto "github.com/bluesky-social/indigo/api/atproto" 48 "github.com/bluesky-social/indigo/atproto/atclient" 49 "github.com/bluesky-social/indigo/atproto/syntax" 50 lexutil "github.com/bluesky-social/indigo/lex/util" 51 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 52 "github.com/go-chi/chi/v5" 53) 54 55const ApplicationGzip = "application/gzip" 56 57type Pulls struct { 58 oauth *oauth.OAuth 59 repoResolver *reporesolver.RepoResolver 60 pages *pages.Pages 61 idResolver *idresolver.Resolver 62 mentionsResolver *mentions.Resolver 63 db *db.DB 64 config *config.Config 65 notifier notify.Notifier 66 enforcer *rbac.Enforcer 67 logger *slog.Logger 68 validator *validator.Validator 69 indexer *pulls_indexer.Indexer 70 ogreClient *ogre.Client 71} 72 73func New( 74 oauth *oauth.OAuth, 75 repoResolver *reporesolver.RepoResolver, 76 pages *pages.Pages, 77 resolver *idresolver.Resolver, 78 mentionsResolver *mentions.Resolver, 79 db *db.DB, 80 config *config.Config, 81 notifier notify.Notifier, 82 enforcer *rbac.Enforcer, 83 validator *validator.Validator, 84 indexer *pulls_indexer.Indexer, 85 logger *slog.Logger, 86) *Pulls { 87 return &Pulls{ 88 oauth: oauth, 89 repoResolver: repoResolver, 90 pages: pages, 91 idResolver: resolver, 92 mentionsResolver: mentionsResolver, 93 db: db, 94 config: config, 95 notifier: notifier, 96 enforcer: enforcer, 97 logger: logger, 98 validator: validator, 99 indexer: indexer, 100 ogreClient: ogre.NewClient(config.Ogre.Host), 101 } 102} 103 104func (s *Pulls) knotClient(host string) *indigoxrpc.Client { 105 scheme := "https" 106 if s.config.Core.Dev { 107 scheme = "http" 108 } 109 return &indigoxrpc.Client{Host: fmt.Sprintf("%s://%s", scheme, host)} 110} 111 112// htmx fragment 113func (s *Pulls) PullActions(w http.ResponseWriter, r *http.Request) { 114 l := s.logger.With("handler", "PullActions") 115 116 switch r.Method { 117 case http.MethodGet: 118 user := s.oauth.GetMultiAccountUser(r) 119 if user != nil { 120 l = l.With("user", user.Did) 121 } 122 123 f, err := s.repoResolver.Resolve(r) 124 if err != nil { 125 l.Error("failed to get repo and knot", "err", err) 126 return 127 } 128 129 pull, ok := r.Context().Value("pull").(*models.Pull) 130 if !ok { 131 l.Error("failed to get pull") 132 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 133 return 134 } 135 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 136 137 // can be nil if this pull is not stacked 138 stack, _ := r.Context().Value("stack").(models.Stack) 139 140 roundNumberStr := chi.URLParam(r, "round") 141 roundNumber, err := strconv.Atoi(roundNumberStr) 142 if err != nil { 143 roundNumber = pull.LastRoundNumber() 144 } 145 if roundNumber >= len(pull.Submissions) { 146 http.Error(w, "bad round id", http.StatusBadRequest) 147 l.Error("failed to parse round id", "err", err, "round_number", roundNumber) 148 return 149 } 150 151 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 152 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 153 resubmitResult := pages.Unknown 154 if user.Did == pull.OwnerDid { 155 resubmitResult = s.resubmitCheck(r, f, pull, stack) 156 } 157 158 s.pages.PullActionsFragment(w, pages.PullActionsParams{ 159 LoggedInUser: user, 160 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 161 Pull: pull, 162 RoundNumber: roundNumber, 163 MergeCheck: mergeCheckResponse, 164 ResubmitCheck: resubmitResult, 165 BranchDeleteStatus: branchDeleteStatus, 166 Stack: stack, 167 }) 168 return 169 } 170} 171 172func (s *Pulls) repoPullHelper(w http.ResponseWriter, r *http.Request, interdiff bool) { 173 l := s.logger.With("handler", "repoPullHelper", "interdiff", interdiff) 174 175 user := s.oauth.GetMultiAccountUser(r) 176 if user != nil { 177 l = l.With("user", user.Did) 178 } 179 180 f, err := s.repoResolver.Resolve(r) 181 if err != nil { 182 l.Error("failed to get repo and knot", "err", err) 183 return 184 } 185 186 pull, ok := r.Context().Value("pull").(*models.Pull) 187 if !ok { 188 l.Error("failed to get pull") 189 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 190 return 191 } 192 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 193 194 backlinks, err := db.GetBacklinks(s.db, pull.AtUri()) 195 if err != nil { 196 l.Error("failed to get pull backlinks", "err", err) 197 s.pages.Notice(w, "pull-error", "Failed to get pull. Try again later.") 198 return 199 } 200 201 roundId := chi.URLParam(r, "round") 202 roundIdInt := pull.LastRoundNumber() 203 if r, err := strconv.Atoi(roundId); err == nil { 204 roundIdInt = r 205 } 206 if roundIdInt >= len(pull.Submissions) { 207 http.Error(w, "bad round id", http.StatusBadRequest) 208 l.Error("failed to parse round id", "err", err, "round_number", roundIdInt) 209 return 210 } 211 212 var diffOpts types.DiffOpts 213 if d := r.URL.Query().Get("diff"); d == "split" { 214 diffOpts.Split = true 215 } 216 217 // can be nil if this pull is not stacked 218 stack, _ := r.Context().Value("stack").(models.Stack) 219 220 mergeCheckResponse := s.mergeCheck(r, f, pull, stack) 221 branchDeleteStatus := s.branchDeleteStatus(r, f, pull) 222 resubmitResult := pages.Unknown 223 if user != nil && user.Did == pull.OwnerDid { 224 resubmitResult = s.resubmitCheck(r, f, pull, stack) 225 } 226 227 m := make(map[string]models.Pipeline) 228 229 var shas []string 230 for _, s := range pull.Submissions { 231 shas = append(shas, s.SourceRev) 232 } 233 for _, p := range stack { 234 shas = append(shas, p.LatestSha()) 235 } 236 237 ps, err := db.GetPipelineStatuses( 238 s.db, 239 len(shas), 240 orm.FilterEq("p.repo_owner", f.Did), 241 orm.FilterEq("p.repo_name", f.Name), 242 orm.FilterEq("p.knot", f.Knot), 243 orm.FilterIn("p.sha", shas), 244 ) 245 if err != nil { 246 l.Error("failed to fetch pipeline statuses", "err", err) 247 // non-fatal 248 } 249 250 for _, p := range ps { 251 m[p.Sha] = p 252 } 253 254 reactionMap, err := db.GetReactionMap(s.db, 20, pull.AtUri()) 255 if err != nil { 256 l.Error("failed to get pull reactions", "err", err) 257 } 258 259 userReactions := map[models.ReactionKind]bool{} 260 if user != nil { 261 userReactions = db.GetReactionStatusMap(s.db, user.Did, pull.AtUri()) 262 } 263 264 labelDefs, err := db.GetLabelDefinitions( 265 s.db, 266 orm.FilterIn("at_uri", f.Labels), 267 orm.FilterContains("scope", tangled.RepoPullNSID), 268 ) 269 if err != nil { 270 l.Error("failed to fetch labels", "err", err) 271 s.pages.Error503(w) 272 return 273 } 274 275 defs := make(map[string]*models.LabelDefinition) 276 for _, l := range labelDefs { 277 defs[l.AtUri().String()] = &l 278 } 279 280 vouchRelationships := make(map[syntax.DID]*models.VouchRelationship) 281 if user != nil { 282 participants := pull.Participants() 283 vouchRelationships, err = db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), participants) 284 if err != nil { 285 l.Error("failed to fetch vouch relationships", "err", err) 286 } 287 } 288 289 patch := pull.Submissions[roundIdInt].CombinedPatch() 290 var diff types.DiffRenderer 291 diff = patchutil.AsNiceDiff(patch, pull.TargetBranch) 292 293 if interdiff { 294 currentPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt].CombinedPatch()) 295 if err != nil { 296 l.Error("failed to interdiff; current patch malformed", "err", err, "round_number", roundIdInt) 297 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; current patch is invalid.") 298 return 299 } 300 301 previousPatch, err := patchutil.AsDiff(pull.Submissions[roundIdInt-1].CombinedPatch()) 302 if err != nil { 303 l.Error("failed to interdiff; previous patch malformed", "err", err, "round_number", roundIdInt) 304 s.pages.Notice(w, fmt.Sprintf("interdiff-error-%d", roundIdInt), "Failed to calculate interdiff; previous patch is invalid.") 305 return 306 } 307 308 diff = patchutil.Interdiff(previousPatch, currentPatch) 309 } 310 311 err = s.pages.RepoSinglePull(w, pages.RepoSinglePullParams{ 312 LoggedInUser: user, 313 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 314 Pull: pull, 315 Stack: stack, 316 Backlinks: backlinks, 317 BranchDeleteStatus: branchDeleteStatus, 318 MergeCheck: mergeCheckResponse, 319 ResubmitCheck: resubmitResult, 320 Pipelines: m, 321 Diff: diff, 322 DiffOpts: diffOpts, 323 ActiveRound: roundIdInt, 324 IsInterdiff: interdiff, 325 326 Reactions: reactionMap, 327 UserReacted: userReactions, 328 329 LabelDefs: defs, 330 VouchRelationships: vouchRelationships, 331 }) 332 if err != nil { 333 l.Error("failed to render page", "err", err) 334 } 335} 336 337func (s *Pulls) RepoSinglePull(w http.ResponseWriter, r *http.Request) { 338 l := s.logger.With("handler", "RepoSinglePull") 339 340 pull, ok := r.Context().Value("pull").(*models.Pull) 341 if !ok { 342 l.Error("failed to get pull") 343 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 344 return 345 } 346 347 http.Redirect(w, r, r.URL.String()+fmt.Sprintf("/round/%d", pull.LastRoundNumber()), http.StatusFound) 348} 349 350func (s *Pulls) mergeCheck(r *http.Request, f *models.Repo, pull *models.Pull, stack models.Stack) types.MergeCheckResponse { 351 if pull.State == models.PullMerged { 352 return types.MergeCheckResponse{} 353 } 354 355 xrpcc := s.knotClient(f.Knot) 356 357 // combine patches of substack 358 subStack := stack.Below(pull) 359 // collect the portion of the stack that is mergeable 360 mergeable := subStack.Mergeable() 361 // combine each patch 362 patch := mergeable.CombinedPatch() 363 364 resp, err := tangled.RepoMergeCheck( 365 r.Context(), 366 xrpcc, 367 &tangled.RepoMergeCheck_Input{ 368 Did: f.Did, 369 Name: f.Name, 370 Branch: pull.TargetBranch, 371 Patch: patch, 372 }, 373 ) 374 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 375 s.logger.Error("failed to check for mergeability", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 376 return types.MergeCheckResponse{ 377 Error: fmt.Sprintf("failed to check merge status: %s", xrpcerr.Error()), 378 } 379 } 380 381 return mergeCheckResponseFrom(resp) 382} 383 384func mergeCheckResponseFrom(resp *tangled.RepoMergeCheck_Output) types.MergeCheckResponse { 385 conflicts := make([]types.ConflictInfo, len(resp.Conflicts)) 386 for i, c := range resp.Conflicts { 387 conflicts[i] = types.ConflictInfo{Filename: c.Filename, Reason: c.Reason} 388 } 389 out := types.MergeCheckResponse{ 390 IsConflicted: resp.Is_conflicted, 391 Conflicts: conflicts, 392 } 393 if resp.Message != nil { 394 out.Message = *resp.Message 395 } 396 if resp.Error != nil { 397 out.Error = *resp.Error 398 } 399 return out 400} 401 402func (s *Pulls) branchDeleteStatus(r *http.Request, repo *models.Repo, pull *models.Pull) *models.BranchDeleteStatus { 403 if pull.State != models.PullMerged { 404 return nil 405 } 406 407 user := s.oauth.GetMultiAccountUser(r) 408 if user == nil { 409 return nil 410 } 411 412 var branch string 413 // check if the branch exists 414 // NOTE: appview could cache branches/tags etc. for every repo by listening for gitRefUpdates 415 if pull.IsBranchBased() { 416 branch = pull.PullSource.Branch 417 } else if pull.IsForkBased() { 418 branch = pull.PullSource.Branch 419 repo = pull.PullSource.Repo 420 } else { 421 return nil 422 } 423 424 // deleted fork 425 if repo == nil { 426 return nil 427 } 428 429 // user can only delete branch if they are a collaborator in the repo that the branch belongs to 430 perms := s.enforcer.GetPermissionsInRepo(user.Did, repo.Knot, repo.RepoIdentifier()) 431 if !slices.Contains(perms, "repo:push") { 432 return nil 433 } 434 435 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 436 resp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, branch, repo.RepoAt().String()) 437 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 438 s.logger.Error("failed to get branch", "xrpcerr", xrpcerr, "err", err) 439 return nil 440 } 441 442 return &models.BranchDeleteStatus{ 443 Repo: repo, 444 Branch: resp.Name, 445 } 446} 447 448func (s *Pulls) resubmitCheck(r *http.Request, repo *models.Repo, pull *models.Pull, stack models.Stack) pages.ResubmitResult { 449 if pull.State == models.PullMerged || pull.State == models.PullAbandoned || pull.PullSource == nil { 450 return pages.Unknown 451 } 452 453 var sourceRepo syntax.ATURI 454 if pull.PullSource.RepoAt != nil { 455 sourceRepo = *pull.PullSource.RepoAt 456 } else { 457 sourceRepo = repo.RepoAt() 458 } 459 460 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 461 branchResp, err := tangled.GitTempGetBranch(r.Context(), xrpcc, pull.PullSource.Branch, sourceRepo.String()) 462 if err != nil { 463 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 464 s.logger.Error("failed to call XRPC repo.branches", "xrpcerr", xrpcerr, "err", err, "pull_id", pull.PullId, "branch", pull.PullSource.Branch) 465 return pages.Unknown 466 } 467 s.logger.Error("failed to reach knotserver", "err", err, "pull_id", pull.PullId) 468 return pages.Unknown 469 } 470 471 targetBranch := branchResp 472 473 top := stack[0] 474 latestSourceRev := top.LatestSha() 475 476 if latestSourceRev != targetBranch.Hash { 477 return pages.ShouldResubmit 478 } 479 480 return pages.ShouldNotResubmit 481} 482 483func (s *Pulls) RepoPullPatch(w http.ResponseWriter, r *http.Request) { 484 s.repoPullHelper(w, r, false) 485} 486 487func (s *Pulls) RepoPullInterdiff(w http.ResponseWriter, r *http.Request) { 488 s.repoPullHelper(w, r, true) 489} 490 491func (s *Pulls) RepoPullPatchRaw(w http.ResponseWriter, r *http.Request) { 492 l := s.logger.With("handler", "RepoPullPatchRaw") 493 494 pull, ok := r.Context().Value("pull").(*models.Pull) 495 if !ok { 496 l.Error("failed to get pull") 497 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 498 return 499 } 500 l = l.With("pull_id", pull.PullId) 501 502 roundId := chi.URLParam(r, "round") 503 roundIdInt, err := strconv.Atoi(roundId) 504 if err != nil || roundIdInt >= len(pull.Submissions) { 505 http.Error(w, "bad round id", http.StatusBadRequest) 506 l.Error("failed to parse round id", "err", err, "round_id_str", roundId) 507 return 508 } 509 510 w.Header().Set("Content-Type", "text/plain; charset=utf-8") 511 w.Write([]byte(pull.Submissions[roundIdInt].Patch)) 512} 513 514func (s *Pulls) RepoPulls(w http.ResponseWriter, r *http.Request) { 515 l := s.logger.With("handler", "RepoPulls") 516 517 user := s.oauth.GetMultiAccountUser(r) 518 if user != nil { 519 l = l.With("user", user.Did) 520 } 521 522 params := r.URL.Query() 523 page := pagination.FromContext(r.Context()) 524 525 f, err := s.repoResolver.Resolve(r) 526 if err != nil { 527 l.Error("failed to get repo and knot", "err", err) 528 return 529 } 530 l = l.With("repo_at", f.RepoAt().String()) 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 countOpts.State = &ps 646 countRes, err := s.indexer.Search(r.Context(), countOpts) 647 if err != nil { 648 continue 649 } 650 switch ps { 651 case models.PullOpen: 652 repoInfo.Stats.PullCount.Open = int(countRes.Total) 653 case models.PullMerged: 654 repoInfo.Stats.PullCount.Merged = int(countRes.Total) 655 case models.PullClosed: 656 repoInfo.Stats.PullCount.Closed = int(countRes.Total) 657 } 658 } 659 660 if len(res.Hits) > 0 { 661 pulls, err = db.GetPulls( 662 s.db, 663 orm.FilterIn("id", res.Hits), 664 ) 665 if err != nil { 666 l.Error("failed to get pulls", "err", err) 667 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 668 return 669 } 670 } 671 } else { 672 filters := []orm.Filter{ 673 orm.FilterEq("repo_at", f.RepoAt()), 674 } 675 if state != nil { 676 filters = append(filters, orm.FilterEq("state", *state)) 677 } 678 pulls, err = db.GetPullsPaginated( 679 s.db, 680 page, 681 filters..., 682 ) 683 if err != nil { 684 l.Error("failed to get pulls", "err", err) 685 s.pages.Notice(w, "pulls", "Failed to load pulls. Try again later.") 686 return 687 } 688 } 689 690 for _, p := range pulls { 691 var pullSourceRepo *models.Repo 692 if p.PullSource != nil { 693 if p.PullSource.RepoAt != nil { 694 pullSourceRepo, err = db.GetRepoByAtUri(s.db, p.PullSource.RepoAt.String()) 695 if err != nil { 696 l.Error("failed to get repo by at uri", "err", err, "repo_at", p.PullSource.RepoAt.String()) 697 continue 698 } else { 699 p.PullSource.Repo = pullSourceRepo 700 } 701 } 702 } 703 } 704 705 var stacks []models.Stack 706 var shas []string 707 708 pullMap := make(map[string]*models.Pull) 709 for _, p := range pulls { 710 shas = append(shas, p.LatestSha()) 711 pullMap[p.AtUri().String()] = p 712 } 713 714 // track which PRs have been added to stacks 715 visited := make(map[string]bool) 716 717 // group stacked PRs together using dependent_on relationships 718 for _, p := range pulls { 719 if visited[p.AtUri().String()] { 720 continue 721 } 722 723 root := p 724 for root.DependentOn != nil { 725 if parent, ok := pullMap[root.DependentOn.String()]; ok { 726 root = parent 727 } else { 728 break // parent not in current page 729 } 730 } 731 732 var stack models.Stack 733 current := root 734 for { 735 if visited[current.AtUri().String()] { 736 break 737 } 738 stack = append(stack, current) 739 visited[current.AtUri().String()] = true 740 741 found := false 742 for _, candidate := range pulls { 743 if candidate.DependentOn != nil && 744 candidate.DependentOn.String() == current.AtUri().String() { 745 current = candidate 746 found = true 747 break 748 } 749 } 750 if !found { 751 break 752 } 753 } 754 755 slices.Reverse(stack) 756 stacks = append(stacks, stack) 757 } 758 759 ps, err := db.GetPipelineStatuses( 760 s.db, 761 len(shas), 762 orm.FilterEq("p.repo_owner", f.Did), 763 orm.FilterEq("p.repo_name", f.Name), 764 orm.FilterEq("p.knot", f.Knot), 765 orm.FilterIn("p.sha", shas), 766 ) 767 if err != nil { 768 l.Warn("failed to fetch pipeline statuses", "err", err) 769 // non-fatal 770 } 771 m := make(map[string]models.Pipeline) 772 for _, p := range ps { 773 m[p.Sha] = p 774 } 775 776 labelDefs, err := db.GetLabelDefinitions( 777 s.db, 778 orm.FilterIn("at_uri", f.Labels), 779 orm.FilterContains("scope", tangled.RepoPullNSID), 780 ) 781 if err != nil { 782 l.Error("failed to fetch labels", "err", err) 783 s.pages.Error503(w) 784 return 785 } 786 787 defs := make(map[string]*models.LabelDefinition) 788 for _, l := range labelDefs { 789 defs[l.AtUri().String()] = &l 790 } 791 792 filterState := "" 793 if state != nil { 794 filterState = state.String() 795 } 796 797 vouchRelationships := make(map[syntax.DID]*models.VouchRelationship) 798 if user != nil { 799 dids := make([]syntax.DID, len(pulls)) 800 for i, p := range pulls { 801 dids[i] = syntax.DID(p.OwnerDid) 802 } 803 vouchRelationships, err = db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), dids) 804 if err != nil { 805 l.Error("failed to fetch vouch relationships", "err", err) 806 } 807 } 808 809 err = s.pages.RepoPulls(w, pages.RepoPullsParams{ 810 LoggedInUser: s.oauth.GetMultiAccountUser(r), 811 RepoInfo: repoInfo, 812 Pulls: pulls, 813 LabelDefs: defs, 814 FilterState: filterState, 815 FilterQuery: query.String(), 816 Stacks: stacks, 817 Pipelines: m, 818 Page: page, 819 PullCount: totalPulls, 820 VouchRelationships: vouchRelationships, 821 }) 822 if err != nil { 823 l.Error("failed to render page", "err", err) 824 } 825} 826 827func (s *Pulls) PullComment(w http.ResponseWriter, r *http.Request) { 828 l := s.logger.With("handler", "PullComment") 829 830 user := s.oauth.GetMultiAccountUser(r) 831 if user != nil { 832 l = l.With("user", user.Did) 833 } 834 835 f, err := s.repoResolver.Resolve(r) 836 if err != nil { 837 l.Error("failed to get repo and knot", "err", err) 838 return 839 } 840 841 pull, ok := r.Context().Value("pull").(*models.Pull) 842 if !ok { 843 l.Error("failed to get pull") 844 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 845 return 846 } 847 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 848 849 roundNumberStr := chi.URLParam(r, "round") 850 roundNumber, err := strconv.Atoi(roundNumberStr) 851 if err != nil || roundNumber >= len(pull.Submissions) { 852 http.Error(w, "bad round id", http.StatusBadRequest) 853 l.Error("failed to parse round id", "err", err, "round_number_str", roundNumberStr) 854 return 855 } 856 857 switch r.Method { 858 case http.MethodGet: 859 s.pages.PullNewCommentFragment(w, pages.PullNewCommentParams{ 860 LoggedInUser: user, 861 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 862 Pull: pull, 863 RoundNumber: roundNumber, 864 }) 865 return 866 case http.MethodPost: 867 body := r.FormValue("body") 868 if body == "" { 869 s.pages.Notice(w, "pull", "Comment body is required") 870 return 871 } 872 873 mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 874 875 // Start a transaction 876 tx, err := s.db.BeginTx(r.Context(), nil) 877 if err != nil { 878 l.Error("failed to start transaction", "err", err) 879 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 880 return 881 } 882 defer tx.Rollback() 883 884 createdAt := time.Now().Format(time.RFC3339) 885 886 client, err := s.oauth.AuthorizedClient(r) 887 if err != nil { 888 l.Error("failed to get authorized client", "err", err) 889 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 890 return 891 } 892 atResp, err := comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 893 Collection: tangled.RepoPullCommentNSID, 894 Repo: user.Did, 895 Rkey: tid.TID(), 896 Record: &lexutil.LexiconTypeDecoder{ 897 Val: &tangled.RepoPullComment{ 898 Pull: pull.AtUri().String(), 899 Body: body, 900 CreatedAt: createdAt, 901 }, 902 }, 903 }) 904 if err != nil { 905 l.Error("failed to create pull comment", "err", err) 906 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 907 return 908 } 909 910 comment := &models.PullComment{ 911 OwnerDid: user.Did, 912 RepoAt: f.RepoAt().String(), 913 PullId: pull.PullId, 914 Body: body, 915 CommentAt: atResp.Uri, 916 SubmissionId: pull.Submissions[roundNumber].ID, 917 Mentions: mentions, 918 References: references, 919 } 920 921 // Create the pull comment in the database with the commentAt field 922 commentId, err := db.NewPullComment(tx, comment) 923 if err != nil { 924 l.Error("failed to create pull comment in database", "err", err) 925 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 926 return 927 } 928 929 // Commit the transaction 930 if err = tx.Commit(); err != nil { 931 l.Error("failed to commit transaction", "err", err) 932 s.pages.Notice(w, "pull-comment", "Failed to create comment.") 933 return 934 } 935 936 s.notifier.NewPullComment(r.Context(), comment, mentions) 937 938 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 939 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d#comment-%d", ownerSlashRepo, pull.PullId, commentId)) 940 return 941 } 942} 943 944func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) { 945 l := s.logger.With("handler", "NewPull") 946 947 user := s.oauth.GetMultiAccountUser(r) 948 if user != nil { 949 l = l.With("user", user.Did) 950 } 951 952 f, err := s.repoResolver.Resolve(r) 953 if err != nil { 954 l.Error("failed to get repo and knot", "err", err) 955 return 956 } 957 l = l.With("repo_at", f.RepoAt().String()) 958 959 switch r.Method { 960 case http.MethodGet: 961 params, err := s.composeParams(r, f) 962 if err != nil { 963 l.Error("failed to build compose params", "err", err) 964 s.pages.Error503(w) 965 return 966 } 967 s.pages.RepoNewPull(w, params) 968 969 case http.MethodPost: 970 title := r.FormValue("title") 971 body := r.FormValue("body") 972 targetBranch := r.FormValue("targetBranch") 973 fromFork := r.FormValue("fork") 974 sourceBranch := r.FormValue("sourceBranch") 975 patch := r.FormValue("patch") 976 userDid := syntax.DID(user.Did) 977 978 if targetBranch == "" { 979 s.pages.Notice(w, "pull", "Target branch is required.") 980 return 981 } 982 983 // Determine PR type based on input parameters 984 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(userDid.String(), f.Knot, f.RepoIdentifier())} 985 isPushAllowed := roles.IsPushAllowed() 986 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 987 isForkBased := fromFork != "" && sourceBranch != "" 988 isPatchBased := patch != "" && !isBranchBased && !isForkBased 989 isStacked := r.FormValue("mode") == "stack" && !isPatchBased 990 991 if isPatchBased && !patchutil.IsFormatPatch(patch) { 992 if title == "" { 993 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 994 return 995 } 996 sanitizer := markup.NewSanitizer() 997 if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 998 s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 999 return 1000 } 1001 } 1002 1003 // Validate we have at least one valid PR creation method 1004 if !isBranchBased && !isPatchBased && !isForkBased { 1005 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 1006 return 1007 } 1008 1009 // Can't mix branch-based and patch-based approaches 1010 if isBranchBased && patch != "" { 1011 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 1012 return 1013 } 1014 1015 if isBranchBased && sourceBranch == targetBranch { 1016 s.pages.Notice(w, "pull", "Source and target branch must be different.") 1017 return 1018 } 1019 1020 // us, err := knotclient.NewUnsignedClient(f.Knot, s.config.Core.Dev) 1021 // if err != nil { 1022 // log.Printf("failed to create unsigned client to %s: %v", f.Knot, err) 1023 // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 1024 // return 1025 // } 1026 1027 // TODO: make capabilities an xrpc call 1028 caps := struct { 1029 PullRequests struct { 1030 FormatPatch bool 1031 BranchSubmissions bool 1032 ForkSubmissions bool 1033 PatchSubmissions bool 1034 } 1035 }{ 1036 PullRequests: struct { 1037 FormatPatch bool 1038 BranchSubmissions bool 1039 ForkSubmissions bool 1040 PatchSubmissions bool 1041 }{ 1042 FormatPatch: true, 1043 BranchSubmissions: true, 1044 ForkSubmissions: true, 1045 PatchSubmissions: true, 1046 }, 1047 } 1048 1049 // caps, err := us.Capabilities() 1050 // if err != nil { 1051 // log.Println("error fetching knot caps", f.Knot, err) 1052 // s.pages.Notice(w, "pull", "Failed to create a pull request. Try again later.") 1053 // return 1054 // } 1055 1056 if !caps.PullRequests.FormatPatch { 1057 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") 1058 return 1059 } 1060 1061 stackTitles := parseBracketedForm(r.Form, "stackTitle") 1062 stackBodies := parseBracketedForm(r.Form, "stackBody") 1063 1064 // Handle the PR creation based on the type 1065 if isBranchBased { 1066 if !caps.PullRequests.BranchSubmissions { 1067 s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") 1068 return 1069 } 1070 s.handleBranchBasedPull(w, r, f, userDid, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies) 1071 } else if isForkBased { 1072 if !caps.PullRequests.ForkSubmissions { 1073 s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") 1074 return 1075 } 1076 s.handleForkBasedPull(w, r, f, userDid, fromFork, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies) 1077 } else if isPatchBased { 1078 if !caps.PullRequests.PatchSubmissions { 1079 s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") 1080 return 1081 } 1082 s.handlePatchBasedPull(w, r, f, userDid, title, body, targetBranch, patch, isStacked, stackTitles, stackBodies) 1083 } 1084 return 1085 } 1086} 1087 1088func (s *Pulls) handleBranchBasedPull( 1089 w http.ResponseWriter, 1090 r *http.Request, 1091 repo *models.Repo, 1092 userDid syntax.DID, 1093 title, 1094 body, 1095 targetBranch, 1096 sourceBranch string, 1097 isStacked bool, 1098 stackTitles, stackBodies map[string]string, 1099) { 1100 l := s.logger.With("handler", "handleBranchBasedPull", "user", userDid, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked) 1101 1102 xrpcc := s.knotClient(repo.Knot) 1103 1104 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch) 1105 if err != nil { 1106 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1107 l.Error("failed to call XRPC repo.compare", "xrpcerr", xrpcerr, "err", err) 1108 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1109 return 1110 } 1111 l.Error("failed to compare", "err", err) 1112 s.pages.Notice(w, "pull", err.Error()) 1113 return 1114 } 1115 1116 var comparison types.RepoFormatPatchResponse 1117 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 1118 l.Error("failed to decode XRPC compare response", "err", err) 1119 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1120 return 1121 } 1122 1123 if len(comparison.FormatPatch) == 0 { 1124 s.pages.Notice(w, "pull", "No commits between target and source.") 1125 return 1126 } 1127 1128 sourceRev := comparison.Rev2 1129 patch := comparison.FormatPatchRaw 1130 combined := comparison.CombinedPatchRaw 1131 1132 if err := s.validator.ValidatePatch(&patch); err != nil { 1133 s.logger.Error("failed to validate patch", "err", err) 1134 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1135 return 1136 } 1137 1138 pullSource := &models.PullSource{ 1139 Branch: sourceBranch, 1140 } 1141 1142 s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies) 1143} 1144 1145func (s *Pulls) handlePatchBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, title, body, targetBranch, patch string, isStacked bool, stackTitles, stackBodies map[string]string) { 1146 if err := s.validator.ValidatePatch(&patch); err != nil { 1147 s.logger.Error("patch validation failed", "err", err) 1148 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1149 return 1150 } 1151 1152 s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, "", "", nil, isStacked, stackTitles, stackBodies) 1153} 1154 1155func (s *Pulls) handleForkBasedPull(w http.ResponseWriter, r *http.Request, repo *models.Repo, userDid syntax.DID, forkRepo string, title, body, targetBranch, sourceBranch string, isStacked bool, stackTitles, stackBodies map[string]string) { 1156 l := s.logger.With("handler", "handleForkBasedPull", "user", userDid, "fork_repo", forkRepo, "target_branch", targetBranch, "source_branch", sourceBranch, "is_stacked", isStacked) 1157 1158 repoString := strings.SplitN(forkRepo, "/", 2) 1159 forkOwnerDid := repoString[0] 1160 repoName := repoString[1] 1161 fork, err := db.GetForkByDid(s.db, forkOwnerDid, repoName) 1162 if errors.Is(err, sql.ErrNoRows) { 1163 s.pages.Notice(w, "pull", "No such fork.") 1164 return 1165 } else if err != nil { 1166 l.Error("failed to fetch fork", "err", err, "fork_owner_did", forkOwnerDid, "repo_name", repoName) 1167 s.pages.Notice(w, "pull", "Failed to fetch fork.") 1168 return 1169 } 1170 1171 client, err := s.oauth.ServiceClient( 1172 r, 1173 oauth.WithService(fork.Knot), 1174 oauth.WithLxm(tangled.RepoHiddenRefNSID), 1175 oauth.WithDev(s.config.Core.Dev), 1176 ) 1177 1178 resp, err := tangled.RepoHiddenRef( 1179 r.Context(), 1180 client, 1181 &tangled.RepoHiddenRef_Input{ 1182 ForkRef: sourceBranch, 1183 RemoteRef: targetBranch, 1184 Repo: fork.RepoAt().String(), 1185 }, 1186 ) 1187 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1188 s.logger.Error("failed to set hidden ref", "xrpcerr", xrpcerr, "err", err) 1189 s.pages.Notice(w, "pull", xrpcerr.Error()) 1190 return 1191 } 1192 1193 if !resp.Success { 1194 errorMsg := "Failed to create pull request" 1195 if resp.Error != nil { 1196 errorMsg = fmt.Sprintf("Failed to create pull request: %s", *resp.Error) 1197 } 1198 s.pages.Notice(w, "pull", errorMsg) 1199 return 1200 } 1201 1202 hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 1203 // We're now comparing the sourceBranch (on the fork) against the hiddenRef which is tracking 1204 // the targetBranch on the target repository. This code is a bit confusing, but here's an example: 1205 // hiddenRef: hidden/feature-1/main (on repo-fork) 1206 // targetBranch: main (on repo-1) 1207 // sourceBranch: feature-1 (on repo-fork) 1208 forkXrpcc := s.knotClient(fork.Knot) 1209 1210 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch) 1211 if err != nil { 1212 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 1213 l.Error("failed to call XRPC repo.compare for fork", "xrpcerr", xrpcerr, "err", err, "hidden_ref", hiddenRef) 1214 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1215 return 1216 } 1217 l.Error("failed to compare across branches", "err", err, "hidden_ref", hiddenRef) 1218 s.pages.Notice(w, "pull", err.Error()) 1219 return 1220 } 1221 1222 var comparison types.RepoFormatPatchResponse 1223 if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 1224 l.Error("failed to decode XRPC compare response for fork", "err", err) 1225 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1226 return 1227 } 1228 1229 if len(comparison.FormatPatch) == 0 { 1230 s.pages.Notice(w, "pull", "No commits between target and source.") 1231 return 1232 } 1233 1234 sourceRev := comparison.Rev2 1235 patch := comparison.FormatPatchRaw 1236 combined := comparison.CombinedPatchRaw 1237 1238 if err := s.validator.ValidatePatch(&patch); err != nil { 1239 s.logger.Error("failed to validate patch", "err", err) 1240 s.pages.Notice(w, "pull", "Invalid patch format. Please provide a valid diff.") 1241 return 1242 } 1243 1244 forkAtUri := fork.RepoAt() 1245 var forkDid *syntax.DID 1246 if fork.RepoDid != "" { 1247 forkDid = new(syntax.DID) 1248 *forkDid = syntax.DID(fork.RepoDid) 1249 } 1250 1251 pullSource := &models.PullSource{ 1252 Branch: sourceBranch, 1253 RepoAt: &forkAtUri, 1254 RepoDid: forkDid, 1255 } 1256 1257 s.createPullRequest(w, r, repo, userDid, title, body, targetBranch, patch, combined, sourceRev, pullSource, isStacked, stackTitles, stackBodies) 1258} 1259 1260func (s *Pulls) createPullRequest( 1261 w http.ResponseWriter, 1262 r *http.Request, 1263 repo *models.Repo, 1264 userDid syntax.DID, 1265 title, body, targetBranch string, 1266 patch string, 1267 combined string, 1268 sourceRev string, 1269 pullSource *models.PullSource, 1270 isStacked bool, 1271 stackTitles, stackBodies map[string]string, 1272) { 1273 l := s.logger.With("handler", "createPullRequest", "user", userDid, "target_branch", targetBranch, "is_stacked", isStacked) 1274 1275 if isStacked { 1276 // creates a series of PRs, each linking to the previous, identified by jj's change-id 1277 s.createStackedPullRequest( 1278 w, 1279 r, 1280 repo, 1281 userDid, 1282 targetBranch, 1283 patch, 1284 sourceRev, 1285 pullSource, 1286 stackTitles, 1287 stackBodies, 1288 ) 1289 return 1290 } 1291 1292 client, err := s.oauth.AuthorizedClient(r) 1293 if err != nil { 1294 l.Error("failed to get authorized client", "err", err) 1295 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1296 return 1297 } 1298 1299 tx, err := s.db.BeginTx(r.Context(), nil) 1300 if err != nil { 1301 l.Error("failed to start tx", "err", err) 1302 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1303 return 1304 } 1305 defer tx.Rollback() 1306 1307 // We've already checked earlier if it's diff-based and title is empty, 1308 // so if it's still empty now, it's intentionally skipped owing to format-patch. 1309 if title == "" || body == "" { 1310 formatPatches, err := patchutil.ExtractPatches(patch) 1311 if err != nil { 1312 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 1313 return 1314 } 1315 if len(formatPatches) == 0 { 1316 s.pages.Notice(w, "pull", "No patches found in the supplied format-patch.") 1317 return 1318 } 1319 1320 if title == "" { 1321 title = formatPatches[0].Title 1322 } 1323 if body == "" { 1324 body = formatPatches[0].Body 1325 } 1326 } 1327 1328 mentions, references := s.mentionsResolver.Resolve(r.Context(), body) 1329 1330 rkey := tid.TID() 1331 1332 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 1333 if err != nil { 1334 l.Error("failed to upload patch", "err", err) 1335 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1336 return 1337 } 1338 1339 now := time.Now() 1340 1341 pull := &models.Pull{ 1342 Title: title, 1343 Body: body, 1344 TargetBranch: targetBranch, 1345 OwnerDid: userDid.String(), 1346 RepoAt: repo.RepoAt(), 1347 Rkey: rkey, 1348 Mentions: mentions, 1349 References: references, 1350 Submissions: []*models.PullSubmission{ 1351 { 1352 Patch: patch, 1353 Combined: combined, 1354 SourceRev: sourceRev, 1355 Blob: *blob.Blob, 1356 Created: now, 1357 }, 1358 }, 1359 PullSource: pullSource, 1360 State: models.PullOpen, 1361 Created: now, 1362 } 1363 1364 record := pull.AsRecord() 1365 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 1366 Collection: tangled.RepoPullNSID, 1367 Repo: userDid.String(), 1368 Rkey: rkey, 1369 Record: &lexutil.LexiconTypeDecoder{ 1370 Val: &record, 1371 }, 1372 }) 1373 if err != nil { 1374 l.Error("failed to create pull request", "err", err) 1375 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1376 return 1377 } 1378 1379 err = db.PutPull(tx, pull) 1380 if err != nil { 1381 l.Error("failed to create pull request in database", "err", err) 1382 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1383 return 1384 } 1385 pullId, err := db.NextPullId(tx, repo.RepoAt()) 1386 if err != nil { 1387 s.logger.Error("failed to get pull id", "err", err) 1388 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1389 return 1390 } 1391 1392 if err = tx.Commit(); err != nil { 1393 l.Error("failed to commit transaction for pull request", "err", err) 1394 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1395 return 1396 } 1397 1398 s.notifier.NewPull(r.Context(), pull) 1399 1400 s.applyCreationLabels(r.Context(), client, userDid, []*models.Pull{pull}, r.Form, repo) 1401 1402 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1403 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pullId)) 1404} 1405 1406func (s *Pulls) createStackedPullRequest( 1407 w http.ResponseWriter, 1408 r *http.Request, 1409 repo *models.Repo, 1410 userDid syntax.DID, 1411 targetBranch string, 1412 patch string, 1413 sourceRev string, 1414 pullSource *models.PullSource, 1415 stackTitles, stackBodies map[string]string, 1416) { 1417 l := s.logger.With("handler", "createStackedPullRequest", "user", userDid, "target_branch", targetBranch, "source_rev", sourceRev) 1418 1419 // run some necessary checks for stacked-prs first 1420 1421 formatPatches, err := patchutil.ExtractPatches(patch) 1422 if err != nil { 1423 l.Error("failed to extract patches", "err", err) 1424 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to extract patches: %v", err)) 1425 return 1426 } 1427 1428 // must have atleast 1 patch to begin with 1429 if len(formatPatches) == 0 { 1430 l.Error("empty patches") 1431 s.pages.Notice(w, "pull", "No patches found in the generated format-patch.") 1432 return 1433 } 1434 1435 client, err := s.oauth.AuthorizedClient(r) 1436 if err != nil { 1437 l.Error("failed to get authorized client", "err", err) 1438 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1439 return 1440 } 1441 1442 // first upload all blobs 1443 blobs := make([]*lexutil.LexBlob, len(formatPatches)) 1444 for i, p := range formatPatches { 1445 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.Raw), ApplicationGzip) 1446 if err != nil { 1447 l.Error("failed to upload patch blob", "err", err, "patch_index", i) 1448 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1449 return 1450 } 1451 l.Info("uploaded blob", "idx", i+1, "total", len(formatPatches)) 1452 blobs[i] = blob.Blob 1453 } 1454 1455 // build a stack out of this patch 1456 stack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pullSource, formatPatches, blobs, stackTitles, stackBodies) 1457 if err != nil { 1458 l.Error("failed to create stack", "err", err) 1459 s.pages.Notice(w, "pull", fmt.Sprintf("Failed to create stack: %v", err)) 1460 return 1461 } 1462 1463 // apply all record creations at once 1464 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 1465 for _, p := range stack { 1466 record := p.AsRecord() 1467 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 1468 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 1469 Collection: tangled.RepoPullNSID, 1470 Rkey: &p.Rkey, 1471 Value: &lexutil.LexiconTypeDecoder{ 1472 Val: &record, 1473 }, 1474 }, 1475 }) 1476 } 1477 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 1478 Repo: userDid.String(), 1479 Writes: writes, 1480 }) 1481 if err != nil { 1482 l.Error("failed to create stacked pull request", "err", err) 1483 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 1484 return 1485 } 1486 1487 // create all pulls at once 1488 tx, err := s.db.BeginTx(r.Context(), nil) 1489 if err != nil { 1490 l.Error("failed to start tx", "err", err) 1491 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1492 return 1493 } 1494 defer tx.Rollback() 1495 1496 for _, p := range stack { 1497 err = db.PutPull(tx, p) 1498 if err != nil { 1499 l.Error("failed to create pull request in database", "err", err, "pull_rkey", p.Rkey) 1500 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1501 return 1502 } 1503 1504 } 1505 1506 if err = tx.Commit(); err != nil { 1507 l.Error("failed to commit transaction for pull requests", "err", err) 1508 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 1509 return 1510 } 1511 1512 // notify about each pull 1513 // 1514 // this is performed after tx.Commit, because it could result in a locked DB otherwise 1515 for _, p := range stack { 1516 s.notifier.NewPull(r.Context(), p) 1517 } 1518 1519 s.applyCreationLabels(r.Context(), client, userDid, stack, r.Form, repo) 1520 1521 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 1522 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls", ownerSlashRepo)) 1523} 1524 1525func (s *Pulls) MarkdownPreview(w http.ResponseWriter, r *http.Request) { 1526 body := r.FormValue("body") 1527 s.pages.MarkdownPreviewFragment(w, body) 1528} 1529 1530func (s *Pulls) RefreshCompose(w http.ResponseWriter, r *http.Request) { 1531 l := s.logger.With("handler", "RefreshCompose") 1532 1533 f, err := s.repoResolver.Resolve(r) 1534 if err != nil { 1535 l.Error("failed to resolve repo", "err", err) 1536 s.pages.Error503(w) 1537 return 1538 } 1539 1540 params, err := s.composeParams(r, f) 1541 if err != nil { 1542 l.Error("failed to build compose params", "err", err) 1543 s.pages.Error503(w) 1544 return 1545 } 1546 w.Header().Set("HX-Replace-Url", composeCanonicalURL(params)) 1547 s.pages.PullComposeHostFragment(w, params) 1548} 1549 1550func composeCanonicalURL(params pages.RepoNewPullParams) string { 1551 base := fmt.Sprintf("/%s/pulls/new", params.RepoInfo.FullName()) 1552 q := url.Values{} 1553 if params.IsStacked { 1554 q.Set("mode", "stack") 1555 } 1556 if params.Source != "" && params.Source != pages.SourceBranch { 1557 q.Set("source", string(params.Source)) 1558 } 1559 if params.SourceBranch != "" { 1560 q.Set("sourceBranch", params.SourceBranch) 1561 } 1562 if params.TargetBranch != "" { 1563 q.Set("targetBranch", params.TargetBranch) 1564 } 1565 if params.Source == pages.SourceFork && params.Fork != "" { 1566 q.Set("fork", params.Fork) 1567 } 1568 if len(q) == 0 { 1569 return base 1570 } 1571 return base + "?" + q.Encode() 1572} 1573 1574func (s *Pulls) composeParams(r *http.Request, repo *models.Repo) (pages.RepoNewPullParams, error) { 1575 l := s.logger.With("handler", "composeParams") 1576 user := s.oauth.GetMultiAccountUser(r) 1577 1578 branches, err := s.listBranches(r.Context(), repo) 1579 if err != nil { 1580 return pages.RepoNewPullParams{}, err 1581 } 1582 1583 var forks []models.Repo 1584 if user != nil { 1585 forks, err = db.GetForksByDid(s.db, user.Did) 1586 if err != nil { 1587 l.Warn("failed to list user forks", "err", err, "user", user.Did) 1588 } 1589 } 1590 1591 repoInfo := s.repoResolver.GetRepoInfo(r, user) 1592 source, ok := pages.ParseSource(r.FormValue("source")) 1593 if !ok { 1594 source = pages.SourceBranch 1595 if !repoInfo.Roles.IsPushAllowed() { 1596 source = pages.SourceFork 1597 } 1598 } 1599 1600 sourceBranch := r.FormValue("sourceBranch") 1601 targetBranch := r.FormValue("targetBranch") 1602 fork := r.FormValue("fork") 1603 patch := r.FormValue("patch") 1604 1605 if source == pages.SourceFork && fork == "" && len(forks) == 1 { 1606 fork = fmt.Sprintf("%s/%s", forks[0].Did, forks[0].Name) 1607 } 1608 1609 var forkBranches []types.Branch 1610 var forkBranchesErr error 1611 if source == pages.SourceFork && fork != "" { 1612 forkBranches, forkBranchesErr = s.listForkBranches(r.Context(), fork) 1613 if forkBranchesErr != nil { 1614 l.Warn("failed to list fork branches", "err", forkBranchesErr, "fork", fork) 1615 } 1616 } 1617 1618 sourceBranchList := sourceBranchChoices(branches) 1619 targetBranch = defaultTargetBranch(branches, targetBranch) 1620 sourceBranch = defaultSourceBranch(source, sourceBranch, sourceBranchList, forkBranches) 1621 1622 comparison, diff, prefetchErr := s.prefetchComparison(r, repo, source, fork, targetBranch, sourceBranch, patch) 1623 var prefillErr string 1624 if joined := errors.Join(prefetchErr, forkBranchesErr); joined != nil { 1625 prefillErr = joined.Error() 1626 } 1627 1628 mergeCheck := s.composeMergeCheck(r.Context(), repo, targetBranch, comparison) 1629 1630 refreshUrl := fmt.Sprintf("/%s/pulls/new/refresh", repoInfo.FullName()) 1631 var diffOpts types.DiffOpts 1632 if r.FormValue("diff") == "split" { 1633 diffOpts.Split = true 1634 } 1635 diffOpts.RefreshUrl = refreshUrl 1636 diffOpts.Target = "#diff-area" 1637 1638 labelDefs, err := s.pullLabelDefs(repo) 1639 if err != nil { 1640 l.Warn("failed to load label definitions", "err", err) 1641 } 1642 labelState := labelStateFromForm(r.Form, labelDefs) 1643 perCidLabelForms := parseStackLabelForms(r.Form) 1644 stackLabelStates := make(map[string]models.LabelState, len(perCidLabelForms)) 1645 for cid, perForm := range perCidLabelForms { 1646 stackLabelStates[cid] = labelStateFromForm(perForm, labelDefs) 1647 } 1648 1649 stackTitles := parseBracketedForm(r.Form, "stackTitle") 1650 stackBodies := parseBracketedForm(r.Form, "stackBody") 1651 stackSplits := parseBracketedForm(r.Form, "stackSplit") 1652 1653 title := r.FormValue("title") 1654 body := r.FormValue("body") 1655 if comparison != nil && len(comparison.FormatPatch) > 0 { 1656 first := comparison.FormatPatch[0] 1657 if title == "" && first.PatchHeader != nil { 1658 title = first.Title 1659 } 1660 if body == "" && first.PatchHeader != nil { 1661 body = first.Body 1662 } 1663 } 1664 1665 isStacked := r.FormValue("mode") == "stack" && source != pages.SourcePatch 1666 var stackedDiffs []pages.StackedDiff 1667 if isStacked { 1668 stackedDiffs = stackPerCommitDiffs(comparison, targetBranch, refreshUrl, stackSplits) 1669 } 1670 1671 return pages.RepoNewPullParams{ 1672 LoggedInUser: user, 1673 RepoInfo: repoInfo, 1674 Branches: branches, 1675 SourceBranches: sourceBranchList, 1676 ForkBranches: forkBranches, 1677 Forks: forks, 1678 Source: source, 1679 SourceBranch: sourceBranch, 1680 TargetBranch: targetBranch, 1681 Fork: fork, 1682 Patch: patch, 1683 Title: title, 1684 Body: body, 1685 IsStacked: isStacked, 1686 Comparison: comparison, 1687 Diff: diff, 1688 DiffOpts: diffOpts, 1689 StackedDiffs: stackedDiffs, 1690 MergeCheck: mergeCheck, 1691 StackTitles: stackTitles, 1692 StackBodies: stackBodies, 1693 PrefillError: prefillErr, 1694 LabelDefs: labelDefs, 1695 LabelState: labelState, 1696 StackLabelStates: stackLabelStates, 1697 }, nil 1698} 1699 1700func (s *Pulls) pullLabelDefs(repo *models.Repo) (map[string]*models.LabelDefinition, error) { 1701 defs, err := db.GetLabelDefinitions( 1702 s.db, 1703 orm.FilterIn("at_uri", repo.Labels), 1704 orm.FilterContains("scope", tangled.RepoPullNSID), 1705 ) 1706 if err != nil { 1707 return nil, err 1708 } 1709 1710 out := make(map[string]*models.LabelDefinition, len(defs)) 1711 for i := range defs { 1712 d := defs[i] 1713 if !slices.Contains(d.Scope, tangled.RepoPullNSID) { 1714 continue 1715 } 1716 out[d.AtUri().String()] = &d 1717 } 1718 return out, nil 1719} 1720 1721func formLabelEntries(form url.Values, defs map[string]*models.LabelDefinition) iter.Seq2[string, string] { 1722 return func(yield func(string, string) bool) { 1723 for key := range defs { 1724 for _, v := range form[key] { 1725 if v == "" { 1726 continue 1727 } 1728 if !yield(key, v) { 1729 return 1730 } 1731 } 1732 } 1733 } 1734} 1735 1736func labelStateFromForm(form url.Values, defs map[string]*models.LabelDefinition) models.LabelState { 1737 state := models.NewLabelState() 1738 actx := &models.LabelApplicationCtx{Defs: defs} 1739 for key, val := range formLabelEntries(form, defs) { 1740 _ = actx.ApplyLabelOp(state, models.LabelOp{ 1741 Operation: models.LabelOperationAdd, 1742 OperandKey: key, 1743 OperandValue: val, 1744 }) 1745 } 1746 return state 1747} 1748 1749func buildCreationLabelOps( 1750 userDid syntax.DID, 1751 subject syntax.ATURI, 1752 rkey string, 1753 form url.Values, 1754 defs map[string]*models.LabelDefinition, 1755 performedAt time.Time, 1756) []models.LabelOp { 1757 var ops []models.LabelOp 1758 for key, val := range formLabelEntries(form, defs) { 1759 ops = append(ops, models.LabelOp{ 1760 Did: userDid.String(), 1761 Rkey: rkey, 1762 Subject: subject, 1763 Operation: models.LabelOperationAdd, 1764 OperandKey: key, 1765 OperandValue: val, 1766 PerformedAt: performedAt, 1767 }) 1768 } 1769 return ops 1770} 1771 1772func (s *Pulls) applyCreationLabels( 1773 ctx context.Context, 1774 client *atclient.APIClient, 1775 userDid syntax.DID, 1776 pulls []*models.Pull, 1777 form url.Values, 1778 repo *models.Repo, 1779) { 1780 l := s.logger.With("handler", "applyCreationLabels", "user", userDid) 1781 1782 defs, err := s.pullLabelDefs(repo) 1783 if err != nil { 1784 l.Warn("failed to fetch label defs", "err", err) 1785 return 1786 } 1787 if len(defs) == 0 { 1788 return 1789 } 1790 1791 perCidForms := parseStackLabelForms(form) 1792 1793 applyAll := form.Get("applyLabelsToAll") == "on" 1794 var firstStackForm url.Values 1795 if applyAll && len(pulls) > 0 && len(pulls[0].Submissions) > 0 { 1796 if firstCid := pulls[0].Submissions[0].ChangeId(); firstCid != "" { 1797 if f, ok := perCidForms[firstCid]; ok { 1798 firstStackForm = f 1799 } 1800 } 1801 } 1802 1803 performedAt := time.Now() 1804 for _, pull := range pulls { 1805 labelForm := form 1806 if firstStackForm != nil { 1807 labelForm = firstStackForm 1808 } else if len(perCidForms) > 0 && len(pull.Submissions) > 0 { 1809 if cid := pull.Submissions[0].ChangeId(); cid != "" { 1810 if perForm, ok := perCidForms[cid]; ok { 1811 labelForm = perForm 1812 } 1813 } 1814 } 1815 rkey := tid.TID() 1816 raw := buildCreationLabelOps(userDid, pull.AtUri(), rkey, labelForm, defs, performedAt) 1817 1818 valid := make([]models.LabelOp, 0, len(raw)) 1819 for _, op := range raw { 1820 def := defs[op.OperandKey] 1821 if err := s.validator.ValidateLabelOp(def, repo, &op); err != nil { 1822 l.Warn("invalid label op", "err", err, "subject", op.Subject, "key", op.OperandKey) 1823 continue 1824 } 1825 valid = append(valid, op) 1826 } 1827 if len(valid) == 0 { 1828 continue 1829 } 1830 1831 record := models.LabelOpsAsRecord(valid) 1832 if _, err := comatproto.RepoPutRecord(ctx, client, &comatproto.RepoPutRecord_Input{ 1833 Collection: tangled.LabelOpNSID, 1834 Repo: userDid.String(), 1835 Rkey: rkey, 1836 Record: &lexutil.LexiconTypeDecoder{Val: &record}, 1837 }); err != nil { 1838 l.Warn("failed to write label ops to PDS", "err", err, "subject", pull.AtUri()) 1839 continue 1840 } 1841 1842 if err := s.indexLabelOps(ctx, valid); err != nil { 1843 l.Warn("failed to index label ops", "err", err, "subject", pull.AtUri()) 1844 if _, err := comatproto.RepoDeleteRecord(context.Background(), client, &comatproto.RepoDeleteRecord_Input{ 1845 Collection: tangled.LabelOpNSID, 1846 Repo: userDid.String(), 1847 Rkey: rkey, 1848 }); err != nil { 1849 l.Warn("failed to rollback label ops record from PDS", "err", err, "subject", pull.AtUri()) 1850 } 1851 continue 1852 } 1853 1854 s.notifier.NewPullLabelOp(ctx, pull) 1855 } 1856} 1857 1858func (s *Pulls) indexLabelOps(ctx context.Context, ops []models.LabelOp) error { 1859 tx, err := s.db.BeginTx(ctx, nil) 1860 if err != nil { 1861 return err 1862 } 1863 defer tx.Rollback() 1864 for _, op := range ops { 1865 if _, err := db.AddLabelOp(tx, &op); err != nil { 1866 return err 1867 } 1868 } 1869 return tx.Commit() 1870} 1871 1872func (s *Pulls) listBranches(ctx context.Context, repo *models.Repo) ([]types.Branch, error) { 1873 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 1874 xrpcBytes, err := tangled.GitTempListBranches(ctx, xrpcc, "", 0, repo.RepoAt().String()) 1875 if err != nil { 1876 return nil, err 1877 } 1878 var result types.RepoBranchesResponse 1879 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 1880 return nil, err 1881 } 1882 return result.Branches, nil 1883} 1884 1885func (s *Pulls) listForkBranches(ctx context.Context, forkIdent string) ([]types.Branch, error) { 1886 parts := strings.SplitN(forkIdent, "/", 2) 1887 if len(parts) != 2 { 1888 return nil, fmt.Errorf("invalid fork identifier: %s", forkIdent) 1889 } 1890 forkRepo, err := db.GetRepo(s.db, orm.FilterEq("did", parts[0]), orm.FilterEq("name", parts[1])) 1891 if err != nil { 1892 return nil, err 1893 } 1894 branches, err := s.listBranches(ctx, forkRepo) 1895 if err != nil { 1896 return nil, err 1897 } 1898 return sortBranchesByRecency(branches), nil 1899} 1900 1901func sourceBranchChoices(branches []types.Branch) []types.Branch { 1902 withoutDefault := slices.DeleteFunc(slices.Clone(branches), func(b types.Branch) bool { 1903 return b.IsDefault 1904 }) 1905 return sortBranchesByRecency(withoutDefault) 1906} 1907 1908func defaultTargetBranch(branches []types.Branch, current string) string { 1909 if slices.ContainsFunc(branches, func(b types.Branch) bool { return b.Reference.Name == current }) { 1910 return current 1911 } 1912 if idx := slices.IndexFunc(branches, func(b types.Branch) bool { return b.IsDefault }); idx >= 0 { 1913 return branches[idx].Reference.Name 1914 } 1915 return "" 1916} 1917 1918func defaultSourceBranch(source pages.Source, current string, branchChoices, forkBranches []types.Branch) string { 1919 var candidates []types.Branch 1920 switch source { 1921 case pages.SourceFork: 1922 candidates = forkBranches 1923 case pages.SourceBranch: 1924 candidates = branchChoices 1925 default: 1926 return current 1927 } 1928 if slices.ContainsFunc(candidates, func(b types.Branch) bool { return b.Reference.Name == current }) { 1929 return current 1930 } 1931 if len(candidates) == 0 { 1932 return "" 1933 } 1934 return candidates[0].Reference.Name 1935} 1936 1937func sortBranchesByRecency(branches []types.Branch) []types.Branch { 1938 out := slices.Clone(branches) 1939 sort.SliceStable(out, func(i, j int) bool { 1940 if out[i].Commit == nil || out[j].Commit == nil { 1941 return out[i].Commit != nil 1942 } 1943 return out[i].Commit.Committer.When.After(out[j].Commit.Committer.When) 1944 }) 1945 return out 1946} 1947 1948func (s *Pulls) prefetchComparison(r *http.Request, repo *models.Repo, source pages.Source, fork, targetBranch, sourceBranch, patch string) (*types.RepoFormatPatchResponse, *types.NiceDiff, error) { 1949 var ( 1950 comparison *types.RepoFormatPatchResponse 1951 err error 1952 ) 1953 switch source { 1954 case pages.SourcePatch: 1955 if strings.TrimSpace(patch) == "" { 1956 return nil, nil, nil 1957 } 1958 if verr := s.validator.ValidatePatch(&patch); verr != nil { 1959 return nil, nil, fmt.Errorf("invalid patch: paste a valid git diff or format-patch") 1960 } 1961 comparison = parsePastedPatch(patch) 1962 case pages.SourceBranch: 1963 if targetBranch == "" || sourceBranch == "" { 1964 return nil, nil, nil 1965 } 1966 comparison, err = s.fetchBranchComparison(r.Context(), repo, targetBranch, sourceBranch) 1967 case pages.SourceFork: 1968 if fork == "" || targetBranch == "" || sourceBranch == "" { 1969 return nil, nil, nil 1970 } 1971 comparison, err = s.fetchForkComparison(r, fork, targetBranch, sourceBranch) 1972 default: 1973 return nil, nil, nil 1974 } 1975 if err != nil { 1976 s.logger.With("handler", "prefetchComparison").Warn("failed to pre-fetch comparison", "err", err, "source", source) 1977 return nil, nil, err 1978 } 1979 1980 return comparison, deriveDiff(comparison, targetBranch), nil 1981} 1982 1983func (s *Pulls) composeMergeCheck(ctx context.Context, repo *models.Repo, targetBranch string, comparison *types.RepoFormatPatchResponse) *types.MergeCheckResponse { 1984 if comparison == nil || targetBranch == "" { 1985 return nil 1986 } 1987 patch := comparison.CombinedPatchRaw 1988 if patch == "" { 1989 patch = comparison.FormatPatchRaw 1990 } 1991 if patch == "" { 1992 return nil 1993 } 1994 1995 xrpcc := s.knotClient(repo.Knot) 1996 1997 resp, err := tangled.RepoMergeCheck(ctx, xrpcc, &tangled.RepoMergeCheck_Input{ 1998 Did: repo.Did, 1999 Name: repo.Name, 2000 Branch: targetBranch, 2001 Patch: patch, 2002 }) 2003 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2004 s.logger.With("handler", "composeMergeCheck").Warn("failed to check mergeability", "xrpcerr", xrpcerr, "err", err, "target_branch", targetBranch) 2005 return &types.MergeCheckResponse{Error: xrpcerr.Error()} 2006 } 2007 2008 out := mergeCheckResponseFrom(resp) 2009 return &out 2010} 2011 2012func bracketComponents(key, prefix string) ([]string, bool) { 2013 if !strings.HasPrefix(key, prefix) { 2014 return nil, false 2015 } 2016 rest := key[len(prefix):] 2017 var parts []string 2018 for len(rest) > 0 { 2019 if !strings.HasPrefix(rest, "[") { 2020 return nil, false 2021 } 2022 end := strings.Index(rest, "]") 2023 if end <= 0 { 2024 return nil, false 2025 } 2026 parts = append(parts, rest[1:end]) 2027 rest = rest[end+1:] 2028 } 2029 if len(parts) == 0 { 2030 return nil, false 2031 } 2032 return parts, true 2033} 2034 2035func parseBracketedForm(form url.Values, prefix string) map[string]string { 2036 out := make(map[string]string) 2037 for key, vals := range form { 2038 parts, ok := bracketComponents(key, prefix) 2039 if !ok || len(parts) != 1 || parts[0] == "" || len(vals) == 0 { 2040 continue 2041 } 2042 out[parts[0]] = vals[0] 2043 } 2044 return out 2045} 2046 2047func parseStackLabelForms(form url.Values) map[string]url.Values { 2048 out := make(map[string]url.Values) 2049 for key, vals := range form { 2050 parts, ok := bracketComponents(key, "stackLabel") 2051 if !ok || len(parts) != 2 || parts[0] == "" || parts[1] == "" { 2052 continue 2053 } 2054 cid, atUri := parts[0], parts[1] 2055 if _, ok := out[cid]; !ok { 2056 out[cid] = make(url.Values) 2057 } 2058 out[cid][atUri] = append(out[cid][atUri], vals...) 2059 } 2060 return out 2061} 2062 2063func parsePastedPatch(patch string) *types.RepoFormatPatchResponse { 2064 if patch == "" { 2065 return nil 2066 } 2067 response := &types.RepoFormatPatchResponse{FormatPatchRaw: patch} 2068 if patchutil.IsFormatPatch(patch) { 2069 if patches, err := patchutil.ExtractPatches(patch); err == nil { 2070 response.FormatPatch = patches 2071 } 2072 } 2073 return response 2074} 2075 2076func (s *Pulls) fetchBranchComparison(ctx context.Context, repo *models.Repo, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { 2077 xrpcc := s.knotClient(repo.Knot) 2078 2079 xrpcBytes, err := tangled.RepoCompare(ctx, xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch) 2080 if err != nil { 2081 return nil, err 2082 } 2083 2084 var comparison types.RepoFormatPatchResponse 2085 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 2086 return nil, err 2087 } 2088 return &comparison, nil 2089} 2090 2091func (s *Pulls) fetchForkComparison(r *http.Request, forkIdent, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { 2092 parts := strings.SplitN(forkIdent, "/", 2) 2093 if len(parts) != 2 { 2094 return nil, fmt.Errorf("invalid fork identifier: %s", forkIdent) 2095 } 2096 fork, err := db.GetForkByDid(s.db, parts[0], parts[1]) 2097 if err != nil { 2098 return nil, err 2099 } 2100 2101 client, err := s.oauth.ServiceClient( 2102 r, 2103 oauth.WithService(fork.Knot), 2104 oauth.WithLxm(tangled.RepoHiddenRefNSID), 2105 oauth.WithDev(s.config.Core.Dev), 2106 ) 2107 if err != nil { 2108 return nil, err 2109 } 2110 2111 resp, err := tangled.RepoHiddenRef( 2112 r.Context(), 2113 client, 2114 &tangled.RepoHiddenRef_Input{ 2115 ForkRef: sourceBranch, 2116 RemoteRef: targetBranch, 2117 Repo: fork.RepoAt().String(), 2118 }, 2119 ) 2120 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2121 return nil, xrpcerr 2122 } 2123 if !resp.Success { 2124 if resp.Error != nil { 2125 return nil, fmt.Errorf("hidden ref failed: %s", *resp.Error) 2126 } 2127 return nil, fmt.Errorf("hidden ref failed") 2128 } 2129 2130 hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 2131 forkXrpcc := s.knotClient(fork.Knot) 2132 2133 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch) 2134 if err != nil { 2135 return nil, err 2136 } 2137 2138 var comparison types.RepoFormatPatchResponse 2139 if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 2140 return nil, err 2141 } 2142 return &comparison, nil 2143} 2144 2145func stackPerCommitDiffs( 2146 comparison *types.RepoFormatPatchResponse, 2147 targetBranch, refreshUrl string, 2148 stackSplits map[string]string, 2149) []pages.StackedDiff { 2150 if comparison == nil { 2151 return nil 2152 } 2153 out := make([]pages.StackedDiff, len(comparison.FormatPatch)) 2154 for i, p := range comparison.FormatPatch { 2155 nd := patchutil.AsNiceDiff(p.Raw, targetBranch) 2156 out[i].Diff = &nd 2157 cid := p.ChangeIdOrEmpty() 2158 if cid == "" { 2159 continue 2160 } 2161 out[i].Opts = types.DiffOpts{ 2162 Split: stackSplits[cid] == "split", 2163 RefreshUrl: refreshUrl, 2164 Target: fmt.Sprintf("#stack-diff-%s", cid), 2165 Field: fmt.Sprintf("stackSplit[%s]", cid), 2166 } 2167 } 2168 return out 2169} 2170 2171func deriveDiff(comparison *types.RepoFormatPatchResponse, targetBranch string) *types.NiceDiff { 2172 if comparison == nil { 2173 return nil 2174 } 2175 raw := comparison.CombinedPatchRaw 2176 if raw == "" { 2177 raw = comparison.FormatPatchRaw 2178 } 2179 d := patchutil.AsNiceDiff(raw, targetBranch) 2180 return &d 2181} 2182 2183func (s *Pulls) ResubmitPull(w http.ResponseWriter, r *http.Request) { 2184 l := s.logger.With("handler", "ResubmitPull") 2185 2186 user := s.oauth.GetMultiAccountUser(r) 2187 if user != nil { 2188 l = l.With("user", user.Did) 2189 } 2190 2191 pull, ok := r.Context().Value("pull").(*models.Pull) 2192 if !ok { 2193 l.Error("failed to get pull") 2194 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2195 return 2196 } 2197 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 2198 2199 switch r.Method { 2200 case http.MethodGet: 2201 s.pages.PullResubmitFragment(w, pages.PullResubmitParams{ 2202 RepoInfo: s.repoResolver.GetRepoInfo(r, user), 2203 Pull: pull, 2204 }) 2205 return 2206 case http.MethodPost: 2207 if pull.IsPatchBased() { 2208 s.resubmitPatch(w, r) 2209 return 2210 } else if pull.IsBranchBased() { 2211 s.resubmitBranch(w, r) 2212 return 2213 } else if pull.IsForkBased() { 2214 s.resubmitFork(w, r) 2215 return 2216 } 2217 } 2218} 2219 2220func (s *Pulls) resubmitPatch(w http.ResponseWriter, r *http.Request) { 2221 l := s.logger.With("handler", "resubmitPatch") 2222 2223 user := s.oauth.GetMultiAccountUser(r) 2224 if user != nil { 2225 l = l.With("user", user.Did) 2226 } 2227 2228 pull, ok := r.Context().Value("pull").(*models.Pull) 2229 if !ok { 2230 l.Error("failed to get pull") 2231 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2232 return 2233 } 2234 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 2235 2236 if user == nil || user.Did != pull.OwnerDid { 2237 l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid) 2238 w.WriteHeader(http.StatusUnauthorized) 2239 return 2240 } 2241 2242 f, err := s.repoResolver.Resolve(r) 2243 if err != nil { 2244 l.Error("failed to get repo and knot", "err", err) 2245 return 2246 } 2247 2248 patch := r.FormValue("patch") 2249 2250 s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, "", "") 2251} 2252 2253func (s *Pulls) resubmitBranch(w http.ResponseWriter, r *http.Request) { 2254 l := s.logger.With("handler", "resubmitBranch") 2255 2256 user := s.oauth.GetMultiAccountUser(r) 2257 if user != nil { 2258 l = l.With("user", user.Did) 2259 } 2260 2261 pull, ok := r.Context().Value("pull").(*models.Pull) 2262 if !ok { 2263 l.Error("failed to get pull") 2264 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 2265 return 2266 } 2267 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "target_branch", pull.TargetBranch) 2268 2269 if user == nil || user.Did != pull.OwnerDid { 2270 l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid) 2271 w.WriteHeader(http.StatusUnauthorized) 2272 return 2273 } 2274 2275 f, err := s.repoResolver.Resolve(r) 2276 if err != nil { 2277 l.Error("failed to get repo and knot", "err", err) 2278 return 2279 } 2280 2281 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())} 2282 if !roles.IsPushAllowed() { 2283 l.Warn("unauthorized user - no push permission") 2284 w.WriteHeader(http.StatusUnauthorized) 2285 return 2286 } 2287 2288 xrpcc := s.knotClient(f.Knot) 2289 2290 xrpcBytes, err := tangled.RepoCompare(r.Context(), xrpcc, f.RepoIdentifier(), pull.TargetBranch, pull.PullSource.Branch) 2291 if err != nil { 2292 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2293 l.Error("failed to call XRPC repo.compare", "xrpcerr", xrpcerr, "err", err, "source_branch", pull.PullSource.Branch) 2294 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2295 return 2296 } 2297 l.Error("compare request failed", "err", err, "source_branch", pull.PullSource.Branch) 2298 s.pages.Notice(w, "resubmit-error", err.Error()) 2299 return 2300 } 2301 2302 var comparison types.RepoFormatPatchResponse 2303 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 2304 l.Error("failed to decode XRPC compare response", "err", err) 2305 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2306 return 2307 } 2308 2309 sourceRev := comparison.Rev2 2310 patch := comparison.FormatPatchRaw 2311 combined := comparison.CombinedPatchRaw 2312 2313 s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, combined, sourceRev) 2314} 2315 2316func (s *Pulls) resubmitFork(w http.ResponseWriter, r *http.Request) { 2317 l := s.logger.With("handler", "resubmitFork") 2318 2319 user := s.oauth.GetMultiAccountUser(r) 2320 if user != nil { 2321 l = l.With("user", user.Did) 2322 } 2323 2324 pull, ok := r.Context().Value("pull").(*models.Pull) 2325 if !ok { 2326 l.Error("failed to get pull") 2327 s.pages.Notice(w, "resubmit-error", "Failed to edit patch. Try again later.") 2328 return 2329 } 2330 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "target_branch", pull.TargetBranch) 2331 2332 if user == nil || user.Did != pull.OwnerDid { 2333 l.Warn("unauthorized user", "actual_user", user.Did, "expected_owner", pull.OwnerDid) 2334 w.WriteHeader(http.StatusUnauthorized) 2335 return 2336 } 2337 2338 f, err := s.repoResolver.Resolve(r) 2339 if err != nil { 2340 l.Error("failed to get repo and knot", "err", err) 2341 return 2342 } 2343 2344 forkRepo, err := db.GetRepoByAtUri(s.db, pull.PullSource.RepoAt.String()) 2345 if err != nil { 2346 l.Error("failed to get source repo", "err", err, "repo_at", pull.PullSource.RepoAt.String()) 2347 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2348 return 2349 } 2350 2351 // update the hidden tracking branch to latest 2352 client, err := s.oauth.ServiceClient( 2353 r, 2354 oauth.WithService(forkRepo.Knot), 2355 oauth.WithLxm(tangled.RepoHiddenRefNSID), 2356 oauth.WithDev(s.config.Core.Dev), 2357 ) 2358 if err != nil { 2359 l.Error("failed to connect to knot server", "err", err, "fork_knot", forkRepo.Knot) 2360 return 2361 } 2362 2363 resp, err := tangled.RepoHiddenRef( 2364 r.Context(), 2365 client, 2366 &tangled.RepoHiddenRef_Input{ 2367 ForkRef: pull.PullSource.Branch, 2368 RemoteRef: pull.TargetBranch, 2369 Repo: forkRepo.RepoAt().String(), 2370 }, 2371 ) 2372 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2373 s.logger.Error("failed to set hidden ref", "xrpcerr", xrpcerr, "err", err) 2374 s.pages.Notice(w, "resubmit-error", xrpcerr.Error()) 2375 return 2376 } 2377 if !resp.Success { 2378 l.Error("failed to update tracking ref", "err", resp.Error, "fork_ref", pull.PullSource.Branch, "remote_ref", pull.TargetBranch) 2379 s.pages.Notice(w, "resubmit-error", "Failed to update tracking ref.") 2380 return 2381 } 2382 2383 hiddenRef := fmt.Sprintf("hidden/%s/%s", pull.PullSource.Branch, pull.TargetBranch) 2384 // extract patch by performing compare 2385 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), s.knotClient(forkRepo.Knot), forkRepo.RepoIdentifier(), hiddenRef, pull.PullSource.Branch) 2386 if err != nil { 2387 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2388 l.Error("failed to call XRPC repo.compare for fork", "xrpcerr", xrpcerr, "err", err, "hidden_ref", hiddenRef, "source_branch", pull.PullSource.Branch) 2389 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2390 return 2391 } 2392 l.Error("failed to compare branches", "err", err, "hidden_ref", hiddenRef, "source_branch", pull.PullSource.Branch) 2393 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2394 return 2395 } 2396 2397 var forkComparison types.RepoFormatPatchResponse 2398 if err := json.Unmarshal(forkXrpcBytes, &forkComparison); err != nil { 2399 l.Error("failed to decode XRPC compare response for fork", "err", err) 2400 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2401 return 2402 } 2403 2404 // Use the fork comparison we already made 2405 comparison := forkComparison 2406 2407 sourceRev := comparison.Rev2 2408 patch := comparison.FormatPatchRaw 2409 combined := comparison.CombinedPatchRaw 2410 2411 s.resubmitPullHelper(w, r, f, syntax.DID(user.Did), pull, patch, combined, sourceRev) 2412} 2413 2414func (s *Pulls) resubmitPullHelper( 2415 w http.ResponseWriter, 2416 r *http.Request, 2417 repo *models.Repo, 2418 userDid syntax.DID, 2419 pull *models.Pull, 2420 patch string, 2421 combined string, 2422 sourceRev string, 2423) { 2424 l := s.logger.With("handler", "resubmitPullHelper", "user", userDid, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 2425 2426 stack := r.Context().Value("stack").(models.Stack) 2427 if stack != nil && len(stack) != 1 { 2428 l.Info("resubmitting stacked PR", "stack_size", len(stack)) 2429 s.resubmitStackedPullHelper(w, r, repo, userDid, pull, patch) 2430 return 2431 } 2432 2433 if err := s.validator.ValidatePatch(&patch); err != nil { 2434 s.pages.Notice(w, "resubmit-error", err.Error()) 2435 return 2436 } 2437 2438 if patch == pull.LatestPatch() { 2439 s.pages.Notice(w, "resubmit-error", "Patch is identical to previous submission.") 2440 return 2441 } 2442 2443 // validate sourceRev if branch/fork based 2444 if pull.IsBranchBased() || pull.IsForkBased() { 2445 if sourceRev == pull.LatestSha() { 2446 s.pages.Notice(w, "resubmit-error", "This branch has not changed since the last submission.") 2447 return 2448 } 2449 } 2450 2451 pullAt := pull.AtUri() 2452 newRoundNumber := len(pull.Submissions) 2453 newPatch := patch 2454 newSourceRev := sourceRev 2455 combinedPatch := combined 2456 2457 client, err := s.oauth.AuthorizedClient(r) 2458 if err != nil { 2459 l.Error("failed to authorize client", "err", err) 2460 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2461 return 2462 } 2463 2464 ex, err := comatproto.RepoGetRecord(r.Context(), client, "", tangled.RepoPullNSID, userDid.String(), pull.Rkey) 2465 if err != nil { 2466 // failed to get record 2467 l.Error("failed to get record from PDS", "err", err, "rkey", pull.Rkey) 2468 s.pages.Notice(w, "resubmit-error", "Failed to update pull, no record found on PDS.") 2469 return 2470 } 2471 2472 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(patch), ApplicationGzip) 2473 if err != nil { 2474 l.Error("failed to upload patch blob", "err", err) 2475 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2476 return 2477 } 2478 record := pull.AsRecord() 2479 record.Rounds = append(record.Rounds, &tangled.RepoPull_Round{ 2480 CreatedAt: time.Now().Format(time.RFC3339), 2481 PatchBlob: blob.Blob, 2482 }) 2483 2484 _, err = comatproto.RepoPutRecord(r.Context(), client, &comatproto.RepoPutRecord_Input{ 2485 Collection: tangled.RepoPullNSID, 2486 Repo: userDid.String(), 2487 Rkey: pull.Rkey, 2488 SwapRecord: ex.Cid, 2489 Record: &lexutil.LexiconTypeDecoder{ 2490 Val: &record, 2491 }, 2492 }) 2493 if err != nil { 2494 l.Error("failed to update record on PDS", "err", err, "rkey", pull.Rkey) 2495 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2496 return 2497 } 2498 2499 err = db.ResubmitPull(s.db, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob) 2500 if err != nil { 2501 l.Error("failed to resubmit pull request in database", "err", err, "round_number", newRoundNumber) 2502 s.pages.Notice(w, "resubmit-error", "Failed to create pull request. Try again later.") 2503 return 2504 } 2505 2506 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 2507 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2508} 2509 2510func (s *Pulls) resubmitStackedPullHelper( 2511 w http.ResponseWriter, 2512 r *http.Request, 2513 repo *models.Repo, 2514 userDid syntax.DID, 2515 pull *models.Pull, 2516 patch string, 2517) { 2518 l := s.logger.With("handler", "resubmitStackedPullHelper", "user", userDid, "pull_id", pull.PullId, "target_branch", pull.TargetBranch) 2519 2520 targetBranch := pull.TargetBranch 2521 2522 origStack, _ := r.Context().Value("stack").(models.Stack) 2523 2524 formatPatches, err := patchutil.ExtractPatches(patch) 2525 if err != nil { 2526 l.Error("failed to extract patches", "err", err) 2527 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Failed to parse patches.") 2528 return 2529 } 2530 2531 // must have atleast 1 patch to begin with 2532 if len(formatPatches) == 0 { 2533 l.Error("no patches found in the generated format-patch") 2534 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request: No patches found in the generated patch.") 2535 return 2536 } 2537 2538 client, err := s.oauth.AuthorizedClient(r) 2539 if err != nil { 2540 l.Error("failed to get authorized client", "err", err) 2541 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 2542 return 2543 } 2544 2545 // first upload all blobs 2546 blobs := make([]*lexutil.LexBlob, len(formatPatches)) 2547 for i, p := range formatPatches { 2548 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.Raw), ApplicationGzip) 2549 if err != nil { 2550 l.Error("failed to upload patch blob", "err", err, "patch_index", i) 2551 s.pages.Notice(w, "pull", "Failed to create pull request. Try again later.") 2552 return 2553 } 2554 l.Info("uploaded blob", "idx", i+1, "total", len(formatPatches)) 2555 blobs[i] = blob.Blob 2556 } 2557 2558 newStack, err := s.newStack(r.Context(), repo, userDid, targetBranch, pull.PullSource, formatPatches, blobs, nil, nil) 2559 if err != nil { 2560 l.Error("failed to create resubmitted stack", "err", err) 2561 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2562 return 2563 } 2564 2565 // find the diff between the stacks, first, map them by changeId 2566 origById := make(map[string]*models.Pull) 2567 newById := make(map[string]*models.Pull) 2568 for _, p := range origStack { 2569 origById[p.LatestSubmission().ChangeId()] = p 2570 } 2571 for _, p := range newStack { 2572 newById[p.LatestSubmission().ChangeId()] = p 2573 } 2574 2575 // commits that got deleted: corresponding pull is closed 2576 // commits that got added: new pull is created 2577 // commits that got updated: corresponding pull is resubmitted & new round begins 2578 additions := make(map[string]*models.Pull) 2579 deletions := make(map[string]*models.Pull) 2580 updated := make(map[string]struct{}) 2581 2582 // pulls in original stack but not in new one 2583 for _, op := range origStack { 2584 if _, ok := newById[op.LatestSubmission().ChangeId()]; !ok { 2585 deletions[op.LatestSubmission().ChangeId()] = op 2586 } 2587 } 2588 2589 // pulls in new stack but not in original one 2590 for _, np := range newStack { 2591 if _, ok := origById[np.LatestSubmission().ChangeId()]; !ok { 2592 additions[np.LatestSubmission().ChangeId()] = np 2593 } 2594 } 2595 2596 // NOTE: this loop can be written in any of above blocks, 2597 // but is written separately in the interest of simpler code 2598 for _, np := range newStack { 2599 if op, ok := origById[np.LatestSubmission().ChangeId()]; ok { 2600 // pull exists in both stacks 2601 updated[op.LatestSubmission().ChangeId()] = struct{}{} 2602 } 2603 } 2604 2605 // NOTE: we can go through the newStack and update dependent relations and 2606 // rkeys now that we know which ones have been updated 2607 // update dependentOn relations for the entire stack 2608 var parentAt *syntax.ATURI 2609 for _, np := range newStack { 2610 if op, ok := origById[np.LatestSubmission().ChangeId()]; ok { 2611 // pull exists in both stacks 2612 np.Rkey = op.Rkey 2613 } 2614 np.DependentOn = parentAt 2615 x := np.AtUri() 2616 parentAt = &x 2617 } 2618 2619 l = l.With("additions", len(additions), "deletions", len(deletions), "updates", len(updated)) 2620 2621 tx, err := s.db.Begin() 2622 if err != nil { 2623 l.Error("failed to start transaction", "err", err) 2624 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2625 return 2626 } 2627 defer tx.Rollback() 2628 2629 // pds updates to make 2630 var writes []*comatproto.RepoApplyWrites_Input_Writes_Elem 2631 2632 // deleted pulls are marked as deleted in the DB 2633 for _, p := range deletions { 2634 // do not do delete already merged PRs 2635 if p.State == models.PullMerged { 2636 continue 2637 } 2638 2639 err := db.AbandonPulls(tx, orm.FilterEq("repo_at", p.RepoAt), orm.FilterEq("at_uri", p.AtUri())) 2640 if err != nil { 2641 l.Error("failed to delete pull", "err", err, "pull_id", p.PullId) 2642 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2643 return 2644 } 2645 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2646 RepoApplyWrites_Delete: &comatproto.RepoApplyWrites_Delete{ 2647 Collection: tangled.RepoPullNSID, 2648 Rkey: p.Rkey, 2649 }, 2650 }) 2651 } 2652 2653 // new pulls are created 2654 for _, p := range additions { 2655 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(p.LatestPatch()), ApplicationGzip) 2656 if err != nil { 2657 l.Error("failed to upload patch blob for new pull", "err", err, "change_id", p.LatestSubmission().ChangeId()) 2658 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2659 return 2660 } 2661 p.Submissions[0].Blob = *blob.Blob 2662 2663 if err = db.PutPull(tx, p); err != nil { 2664 l.Error("failed to create pull", "err", err, "pull_id", p.PullId, "change_id", p.LatestSubmission().ChangeId()) 2665 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2666 return 2667 } 2668 2669 record := p.AsRecord() 2670 record.Rounds = []*tangled.RepoPull_Round{ 2671 { 2672 CreatedAt: time.Now().Format(time.RFC3339), 2673 PatchBlob: blob.Blob, 2674 }, 2675 } 2676 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2677 RepoApplyWrites_Create: &comatproto.RepoApplyWrites_Create{ 2678 Collection: tangled.RepoPullNSID, 2679 Rkey: &p.Rkey, 2680 Value: &lexutil.LexiconTypeDecoder{ 2681 Val: &record, 2682 }, 2683 }, 2684 }) 2685 } 2686 2687 // updated pulls are, well, updated; to start a new round 2688 for id := range updated { 2689 op, _ := origById[id] 2690 np, _ := newById[id] 2691 2692 // do not update already merged PRs 2693 if op.State == models.PullMerged { 2694 continue 2695 } 2696 2697 // resubmit the new pull 2698 np.Rkey = op.Rkey 2699 pullAt := op.AtUri() 2700 newRoundNumber := len(op.Submissions) 2701 newPatch := np.LatestPatch() 2702 combinedPatch := np.LatestSubmission().Combined 2703 newSourceRev := np.LatestSha() 2704 2705 blob, err := xrpc.RepoUploadBlob(r.Context(), client, gz(newPatch), ApplicationGzip) 2706 if err != nil { 2707 l.Error("failed to upload patch blob for update", "err", err, "change_id", id, "pull_id", op.PullId) 2708 s.pages.Notice(w, "resubmit-error", "Failed to update pull request on the PDS. Try again later.") 2709 return 2710 } 2711 2712 // create new round 2713 err = db.ResubmitPull(tx, pullAt, newRoundNumber, newPatch, combinedPatch, newSourceRev, blob.Blob) 2714 if err != nil { 2715 l.Error("failed to update pull in database", "err", err, "pull_id", op.PullId, "round_number", newRoundNumber) 2716 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2717 return 2718 } 2719 2720 // update dependent-on relation 2721 if np.DependentOn != nil { 2722 err := db.SetDependentOn(tx, *np.DependentOn, orm.FilterEq("at_uri", np.AtUri())) 2723 if err != nil { 2724 l.Error("failed to update pull in database", "err", err, "pull_id", op.PullId, "round_number", newRoundNumber) 2725 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2726 return 2727 } 2728 } 2729 2730 record := np.AsRecord() 2731 record.Rounds = op.AsRecord().Rounds 2732 record.Rounds = append(record.Rounds, &tangled.RepoPull_Round{ 2733 CreatedAt: time.Now().Format(time.RFC3339), 2734 PatchBlob: blob.Blob, 2735 }) 2736 writes = append(writes, &comatproto.RepoApplyWrites_Input_Writes_Elem{ 2737 RepoApplyWrites_Update: &comatproto.RepoApplyWrites_Update{ 2738 Collection: tangled.RepoPullNSID, 2739 Rkey: op.Rkey, 2740 Value: &lexutil.LexiconTypeDecoder{ 2741 Val: &record, 2742 }, 2743 }, 2744 }) 2745 } 2746 2747 _, err = comatproto.RepoApplyWrites(r.Context(), client, &comatproto.RepoApplyWrites_Input{ 2748 Repo: userDid.String(), 2749 Writes: writes, 2750 }) 2751 if err != nil { 2752 l.Error("failed to apply writes for stacked pull request", "err", err, "writes_count", len(writes)) 2753 s.pages.Notice(w, "pull", "Failed to create stacked pull request. Try again later.") 2754 return 2755 } 2756 2757 err = tx.Commit() 2758 if err != nil { 2759 l.Error("failed to commit resubmit transaction", "err", err) 2760 s.pages.Notice(w, "pull-resubmit-error", "Failed to resubmit pull request. Try again later.") 2761 return 2762 } 2763 2764 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, repo) 2765 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2766} 2767 2768func (s *Pulls) MergePull(w http.ResponseWriter, r *http.Request) { 2769 l := s.logger.With("handler", "MergePull") 2770 2771 user := s.oauth.GetMultiAccountUser(r) 2772 if user != nil { 2773 l = l.With("user", user.Did) 2774 } 2775 2776 f, err := s.repoResolver.Resolve(r) 2777 if err != nil { 2778 l.Error("failed to resolve repo", "err", err) 2779 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2780 return 2781 } 2782 l = l.With("repo_at", f.RepoAt().String()) 2783 2784 pull, ok := r.Context().Value("pull").(*models.Pull) 2785 if !ok { 2786 l.Error("failed to get pull") 2787 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2788 return 2789 } 2790 l = l.With("pull_id", pull.PullId, "target_branch", pull.TargetBranch) 2791 2792 stack, ok := r.Context().Value("stack").(models.Stack) 2793 if !ok { 2794 l.Error("failed to get stack") 2795 s.pages.Notice(w, "pull-merge-error", "Failed to merge patch. Try again later.") 2796 return 2797 } 2798 2799 // combine patches of substack 2800 subStack := stack.Below(pull) 2801 // collect the portion of the stack that is mergeable 2802 pullsToMerge := subStack.Mergeable() 2803 l = l.With("pulls_to_merge", len(pullsToMerge)) 2804 2805 patch := pullsToMerge.CombinedPatch() 2806 2807 ident, err := s.idResolver.ResolveIdent(r.Context(), pull.OwnerDid) 2808 if err != nil { 2809 l.Error("failed to resolve identity", "err", err, "owner_did", pull.OwnerDid) 2810 w.WriteHeader(http.StatusNotFound) 2811 return 2812 } 2813 2814 email, err := db.GetPrimaryEmail(s.db, pull.OwnerDid) 2815 if err != nil { 2816 l.Warn("failed to get primary email", "err", err, "owner_did", pull.OwnerDid) 2817 } 2818 2819 authorName := ident.Handle.String() 2820 mergeInput := &tangled.RepoMerge_Input{ 2821 Did: f.Did, 2822 Name: f.Name, 2823 Branch: pull.TargetBranch, 2824 Patch: patch, 2825 CommitMessage: &pull.Title, 2826 AuthorName: &authorName, 2827 } 2828 2829 if pull.Body != "" { 2830 mergeInput.CommitBody = &pull.Body 2831 } 2832 2833 if email.Address != "" { 2834 mergeInput.AuthorEmail = &email.Address 2835 } 2836 2837 client, err := s.oauth.ServiceClient( 2838 r, 2839 oauth.WithService(f.Knot), 2840 oauth.WithLxm(tangled.RepoMergeNSID), 2841 oauth.WithDev(s.config.Core.Dev), 2842 oauth.WithTimeout(time.Second*20), // merge is quite slow on large repos, like witchsky 2843 ) 2844 if err != nil { 2845 l.Error("failed to connect to knot server", "err", err, "knot", f.Knot) 2846 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2847 return 2848 } 2849 2850 err = tangled.RepoMerge(r.Context(), client, mergeInput) 2851 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 2852 s.logger.Error("failed to merge", "xrpcerr", xrpcerr, "err", err) 2853 s.pages.Notice(w, "pull-merge-error", xrpcerr.Error()) 2854 return 2855 } 2856 2857 tx, err := s.db.Begin() 2858 if err != nil { 2859 l.Error("failed to start transaction", "err", err) 2860 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2861 return 2862 } 2863 defer tx.Rollback() 2864 2865 var atUris []syntax.ATURI 2866 for _, p := range pullsToMerge { 2867 atUris = append(atUris, p.AtUri()) 2868 p.State = models.PullMerged 2869 } 2870 err = db.MergePulls(tx, orm.FilterEq("repo_at", f.RepoAt()), orm.FilterIn("at_uri", atUris)) 2871 if err != nil { 2872 l.Error("failed to update pull request status in database", "err", err) 2873 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2874 return 2875 } 2876 2877 err = tx.Commit() 2878 if err != nil { 2879 // TODO: this is unsound, we should also revert the merge from the knotserver here 2880 l.Error("failed to commit merge transaction", "err", err) 2881 s.pages.Notice(w, "pull-merge-error", "Failed to merge pull request. Try again later.") 2882 return 2883 } 2884 2885 // notify about the pull merge 2886 for _, p := range pullsToMerge { 2887 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2888 } 2889 2890 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2891 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2892} 2893 2894func (s *Pulls) ClosePull(w http.ResponseWriter, r *http.Request) { 2895 l := s.logger.With("handler", "ClosePull") 2896 2897 user := s.oauth.GetMultiAccountUser(r) 2898 if user != nil { 2899 l = l.With("user", user.Did) 2900 } 2901 2902 f, err := s.repoResolver.Resolve(r) 2903 if err != nil { 2904 l.Error("failed to resolve repo", "err", err) 2905 return 2906 } 2907 2908 pull, ok := r.Context().Value("pull").(*models.Pull) 2909 if !ok { 2910 l.Error("failed to get pull") 2911 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2912 return 2913 } 2914 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid) 2915 2916 // auth filter: only owner or collaborators can close 2917 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())} 2918 isOwner := roles.IsOwner() 2919 isCollaborator := roles.IsCollaborator() 2920 isPullAuthor := user.Did == pull.OwnerDid 2921 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2922 if !isCloseAllowed { 2923 l.Error("unauthorized to close pull", "is_owner", isOwner, "is_collaborator", isCollaborator, "is_pull_author", isPullAuthor) 2924 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 2925 return 2926 } 2927 2928 // Start a transaction 2929 tx, err := s.db.BeginTx(r.Context(), nil) 2930 if err != nil { 2931 l.Error("failed to start transaction", "err", err) 2932 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2933 return 2934 } 2935 defer tx.Rollback() 2936 2937 // if this PR is stacked, then we want to close all PRs above this one on the stack 2938 stack := r.Context().Value("stack").(models.Stack) 2939 pullsToClose := stack.Above(pull) 2940 var atUris []syntax.ATURI 2941 for _, p := range pullsToClose { 2942 atUris = append(atUris, p.AtUri()) 2943 p.State = models.PullClosed 2944 } 2945 err = db.ClosePulls( 2946 tx, 2947 orm.FilterEq("repo_at", f.RepoAt()), 2948 orm.FilterIn("at_uri", atUris), 2949 ) 2950 if err != nil { 2951 l.Error("failed to close pulls in database", "err", err, "pulls_to_close", len(pullsToClose)) 2952 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2953 } 2954 2955 // Commit the transaction 2956 if err = tx.Commit(); err != nil { 2957 l.Error("failed to commit transaction", "err", err) 2958 s.pages.Notice(w, "pull-close", "Failed to close pull.") 2959 return 2960 } 2961 2962 for _, p := range pullsToClose { 2963 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 2964 } 2965 2966 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 2967 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 2968} 2969 2970func (s *Pulls) ReopenPull(w http.ResponseWriter, r *http.Request) { 2971 l := s.logger.With("handler", "ReopenPull") 2972 2973 user := s.oauth.GetMultiAccountUser(r) 2974 if user != nil { 2975 l = l.With("user", user.Did) 2976 } 2977 2978 f, err := s.repoResolver.Resolve(r) 2979 if err != nil { 2980 l.Error("failed to resolve repo", "err", err) 2981 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 2982 return 2983 } 2984 2985 pull, ok := r.Context().Value("pull").(*models.Pull) 2986 if !ok { 2987 l.Error("failed to get pull") 2988 s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") 2989 return 2990 } 2991 l = l.With("pull_id", pull.PullId, "pull_owner", pull.OwnerDid, "state", pull.State) 2992 2993 // auth filter: only owner or collaborators can close 2994 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(user.Did, f.Knot, f.RepoIdentifier())} 2995 isOwner := roles.IsOwner() 2996 isCollaborator := roles.IsCollaborator() 2997 isPullAuthor := user.Did == pull.OwnerDid 2998 isCloseAllowed := isOwner || isCollaborator || isPullAuthor 2999 if !isCloseAllowed { 3000 l.Error("unauthorized to reopen pull", "is_owner", isOwner, "is_collaborator", isCollaborator, "is_pull_author", isPullAuthor) 3001 s.pages.Notice(w, "pull-close", "You are unauthorized to close this pull.") 3002 return 3003 } 3004 3005 // Start a transaction 3006 tx, err := s.db.BeginTx(r.Context(), nil) 3007 if err != nil { 3008 l.Error("failed to start transaction", "err", err) 3009 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 3010 return 3011 } 3012 defer tx.Rollback() 3013 3014 // if this PR is stacked, then we want to reopen all PRs above this one on the stack 3015 stack := r.Context().Value("stack").(models.Stack) 3016 pullsToReopen := stack.Below(pull) 3017 var atUris []syntax.ATURI 3018 for _, p := range pullsToReopen { 3019 atUris = append(atUris, p.AtUri()) 3020 p.State = models.PullOpen 3021 } 3022 err = db.ReopenPulls( 3023 tx, 3024 orm.FilterEq("repo_at", f.RepoAt()), 3025 orm.FilterIn("at_uri", atUris), 3026 ) 3027 if err != nil { 3028 l.Error("failed to reopen pulls in database", "err", err, "pulls_to_reopen", len(pullsToReopen)) 3029 s.pages.Notice(w, "pull-close", "Failed to reopen pull.") 3030 } 3031 3032 // Commit the transaction 3033 if err = tx.Commit(); err != nil { 3034 l.Error("failed to commit transaction", "err", err) 3035 s.pages.Notice(w, "pull-reopen", "Failed to reopen pull.") 3036 return 3037 } 3038 3039 for _, p := range pullsToReopen { 3040 s.notifier.NewPullState(r.Context(), syntax.DID(user.Did), p) 3041 } 3042 3043 ownerSlashRepo := reporesolver.GetBaseRepoPath(r, f) 3044 s.pages.HxLocation(w, fmt.Sprintf("/%s/pulls/%d", ownerSlashRepo, pull.PullId)) 3045} 3046 3047func (s *Pulls) newStack( 3048 ctx context.Context, 3049 repo *models.Repo, 3050 userDid syntax.DID, 3051 targetBranch string, 3052 pullSource *models.PullSource, 3053 formatPatches []types.FormatPatch, 3054 blobs []*lexutil.LexBlob, 3055 stackTitles, stackBodies map[string]string, 3056) (models.Stack, error) { 3057 var stack models.Stack 3058 var parentAtUri *syntax.ATURI 3059 for i, fp := range formatPatches { 3060 // all patches must have a jj change-id 3061 cid, err := fp.ChangeId() 3062 if err != nil { 3063 return nil, fmt.Errorf("Stacking is only supported if all patches contain a change-id commit header.") 3064 } 3065 3066 title := fp.Title 3067 body := fp.Body 3068 if override, ok := stackTitles[cid]; ok && strings.TrimSpace(override) != "" { 3069 title = override 3070 } 3071 if override, ok := stackBodies[cid]; ok { 3072 body = override 3073 } 3074 rkey := tid.TID() 3075 3076 mentions, references := s.mentionsResolver.Resolve(ctx, body) 3077 3078 now := time.Now() 3079 3080 pull := models.Pull{ 3081 Title: title, 3082 Body: body, 3083 TargetBranch: targetBranch, 3084 OwnerDid: userDid.String(), 3085 RepoAt: repo.RepoAt(), 3086 Rkey: rkey, 3087 Mentions: mentions, 3088 References: references, 3089 Submissions: []*models.PullSubmission{ 3090 { 3091 Patch: fp.Raw, 3092 SourceRev: fp.SHA, 3093 Combined: fp.Raw, 3094 Blob: *blobs[i], 3095 Created: now, 3096 }, 3097 }, 3098 PullSource: pullSource, 3099 Created: now, 3100 State: models.PullOpen, 3101 3102 DependentOn: parentAtUri, 3103 Repo: repo, 3104 } 3105 3106 stack = append(stack, &pull) 3107 3108 parent := pull.AtUri() 3109 parentAtUri = &parent 3110 } 3111 3112 return stack, nil 3113} 3114 3115func gz(s string) io.Reader { 3116 var b bytes.Buffer 3117 w := gzip.NewWriter(&b) 3118 w.Write([]byte(s)) 3119 w.Close() 3120 return &b 3121} 3122 3123func ptrPullState(s models.PullState) *models.PullState { return &s }