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