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