Monorepo for Tangled tangled.org
5

Configure Feed

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

1package pulls 2 3import ( 4 "context" 5 "database/sql" 6 "encoding/json" 7 "errors" 8 "fmt" 9 "net/http" 10 "net/url" 11 "slices" 12 "sort" 13 "strings" 14 15 "tangled.org/core/api/tangled" 16 "tangled.org/core/appview/db" 17 "tangled.org/core/appview/models" 18 "tangled.org/core/appview/oauth" 19 "tangled.org/core/appview/pages" 20 "tangled.org/core/appview/pages/markup" 21 "tangled.org/core/appview/pages/repoinfo" 22 "tangled.org/core/appview/xrpcclient" 23 "tangled.org/core/patchutil" 24 "tangled.org/core/types" 25 26 "github.com/bluesky-social/indigo/atproto/syntax" 27 indigoxrpc "github.com/bluesky-social/indigo/xrpc" 28) 29 30func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) { 31 l := s.logger.With("handler", "NewPull") 32 33 user := s.oauth.GetMultiAccountUser(r) 34 if user != nil { 35 l = l.With("user", user.Did) 36 } 37 38 f, err := s.repoResolver.Resolve(r) 39 if err != nil { 40 l.Error("failed to get repo and knot", "err", err) 41 return 42 } 43 l = l.With("repo_at", f.RepoAt().String()) 44 45 switch r.Method { 46 case http.MethodGet: 47 params, err := s.composeParams(r, f) 48 if err != nil { 49 l.Error("failed to build compose params", "err", err) 50 s.pages.Error503(w) 51 return 52 } 53 s.pages.RepoNewPull(w, params) 54 55 case http.MethodPost: 56 title := r.FormValue("title") 57 body := r.FormValue("body") 58 targetBranch := r.FormValue("targetBranch") 59 fromFork := r.FormValue("fork") 60 sourceBranch := r.FormValue("sourceBranch") 61 patch := r.FormValue("patch") 62 userDid := syntax.DID(user.Did) 63 64 if targetBranch == "" { 65 s.pages.Notice(w, "pull", "Target branch is required.") 66 return 67 } 68 69 // Determine PR type based on input parameters 70 roles := repoinfo.RolesInRepo{Roles: s.enforcer.GetPermissionsInRepo(userDid.String(), f.Knot, f.RepoIdentifier())} 71 isPushAllowed := roles.IsPushAllowed() 72 isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" 73 isForkBased := fromFork != "" && sourceBranch != "" 74 isPatchBased := patch != "" && !isBranchBased && !isForkBased 75 isStacked := r.FormValue("mode") == "stack" && !isPatchBased 76 77 if isPatchBased && !patchutil.IsFormatPatch(patch) { 78 if title == "" { 79 s.pages.Notice(w, "pull", "Title is required for git-diff patches.") 80 return 81 } 82 sanitizer := markup.NewSanitizer() 83 if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { 84 s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 85 return 86 } 87 } 88 89 // Validate we have at least one valid PR creation method 90 if !isBranchBased && !isPatchBased && !isForkBased { 91 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 92 return 93 } 94 95 // Can't mix branch-based and patch-based approaches 96 if isBranchBased && patch != "" { 97 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 98 return 99 } 100 101 if isBranchBased && sourceBranch == targetBranch { 102 s.pages.Notice(w, "pull", "Source and target branch must be different.") 103 return 104 } 105 106 // TODO: make capabilities an xrpc call 107 caps := struct { 108 PullRequests struct { 109 FormatPatch bool 110 BranchSubmissions bool 111 ForkSubmissions bool 112 PatchSubmissions bool 113 } 114 }{ 115 PullRequests: struct { 116 FormatPatch bool 117 BranchSubmissions bool 118 ForkSubmissions bool 119 PatchSubmissions bool 120 }{ 121 FormatPatch: true, 122 BranchSubmissions: true, 123 ForkSubmissions: true, 124 PatchSubmissions: true, 125 }, 126 } 127 128 if !caps.PullRequests.FormatPatch { 129 s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") 130 return 131 } 132 133 stackTitles := parseBracketedForm(r.Form, "stackTitle") 134 stackBodies := parseBracketedForm(r.Form, "stackBody") 135 136 // Handle the PR creation based on the type 137 if isBranchBased { 138 if !caps.PullRequests.BranchSubmissions { 139 s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") 140 return 141 } 142 s.handleBranchBasedPull(w, r, f, userDid, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies) 143 } else if isForkBased { 144 if !caps.PullRequests.ForkSubmissions { 145 s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") 146 return 147 } 148 s.handleForkBasedPull(w, r, f, userDid, fromFork, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies) 149 } else if isPatchBased { 150 if !caps.PullRequests.PatchSubmissions { 151 s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") 152 return 153 } 154 s.handlePatchBasedPull(w, r, f, userDid, title, body, targetBranch, patch, isStacked, stackTitles, stackBodies) 155 } 156 return 157 } 158} 159 160func (s *Pulls) MarkdownPreview(w http.ResponseWriter, r *http.Request) { 161 body := r.FormValue("body") 162 s.pages.MarkdownPreviewFragment(w, body) 163} 164 165func (s *Pulls) RefreshCompose(w http.ResponseWriter, r *http.Request) { 166 l := s.logger.With("handler", "RefreshCompose") 167 168 f, err := s.repoResolver.Resolve(r) 169 if err != nil { 170 l.Error("failed to resolve repo", "err", err) 171 s.pages.Error503(w) 172 return 173 } 174 175 params, err := s.composeParams(r, f) 176 if err != nil { 177 l.Error("failed to build compose params", "err", err) 178 s.pages.Error503(w) 179 return 180 } 181 w.Header().Set("HX-Replace-Url", composeCanonicalURL(params)) 182 s.pages.PullComposeHostFragment(w, params) 183} 184 185func composeCanonicalURL(params pages.RepoNewPullParams) string { 186 base := fmt.Sprintf("/%s/pulls/new", params.RepoInfo.FullName()) 187 q := url.Values{} 188 if params.IsStacked { 189 q.Set("mode", "stack") 190 } 191 if params.Source != "" && params.Source != pages.SourceBranch { 192 q.Set("source", string(params.Source)) 193 } 194 if params.SourceBranch != "" { 195 q.Set("sourceBranch", params.SourceBranch) 196 } 197 if params.TargetBranch != "" { 198 q.Set("targetBranch", params.TargetBranch) 199 } 200 if params.Source == pages.SourceFork && params.Fork != "" { 201 q.Set("fork", params.Fork) 202 } 203 if len(q) == 0 { 204 return base 205 } 206 return base + "?" + q.Encode() 207} 208 209func (s *Pulls) composeParams(r *http.Request, repo *models.Repo) (pages.RepoNewPullParams, error) { 210 l := s.logger.With("handler", "composeParams") 211 user := s.oauth.GetMultiAccountUser(r) 212 213 branches, err := s.listBranches(r.Context(), repo) 214 if err != nil { 215 return pages.RepoNewPullParams{}, err 216 } 217 218 var forks []models.Repo 219 if user != nil { 220 forks, err = db.GetForksByDid(s.db, user.Did) 221 if err != nil { 222 l.Warn("failed to list user forks", "err", err, "user", user.Did) 223 } 224 } 225 forks = slices.DeleteFunc(forks, func(f models.Repo) bool { 226 return f.RepoDid == "" 227 }) 228 229 repoInfo := s.repoResolver.GetRepoInfo(r, user) 230 source, ok := pages.ParseSource(r.FormValue("source")) 231 if !ok { 232 source = pages.SourceBranch 233 if !repoInfo.Roles.IsPushAllowed() { 234 source = pages.SourceFork 235 } 236 } 237 238 sourceBranch := r.FormValue("sourceBranch") 239 targetBranch := r.FormValue("targetBranch") 240 fork := r.FormValue("fork") 241 patch := r.FormValue("patch") 242 243 if source == pages.SourceFork && fork == "" && len(forks) == 1 { 244 fork = forks[0].RepoDid 245 } 246 247 var forkBranches []types.Branch 248 var forkBranchesErr error 249 if source == pages.SourceFork && fork != "" { 250 forkBranches, forkBranchesErr = s.listForkBranches(r.Context(), fork) 251 if forkBranchesErr != nil { 252 l.Warn("failed to list fork branches", "err", forkBranchesErr, "fork", fork) 253 } 254 } 255 256 sourceBranchList := sourceBranchChoices(branches) 257 targetBranch = defaultTargetBranch(branches, targetBranch) 258 sourceBranch = defaultSourceBranch(source, sourceBranch, sourceBranchList, forkBranches) 259 260 comparison, diff, prefetchErr := s.prefetchComparison(r, repo, source, fork, targetBranch, sourceBranch, patch) 261 var prefillErr string 262 if joined := errors.Join(prefetchErr, forkBranchesErr); joined != nil { 263 prefillErr = joined.Error() 264 } 265 266 mergeCheck := s.composeMergeCheck(r.Context(), repo, targetBranch, comparison) 267 268 refreshUrl := fmt.Sprintf("/%s/pulls/new/refresh", repoInfo.FullName()) 269 var diffOpts types.DiffOpts 270 if r.FormValue("diff") == "split" { 271 diffOpts.Split = true 272 } 273 diffOpts.RefreshUrl = refreshUrl 274 diffOpts.Target = "#diff-area" 275 276 labelDefs, err := s.pullLabelDefs(repo) 277 if err != nil { 278 l.Warn("failed to load label definitions", "err", err) 279 } 280 labelState := labelStateFromForm(r.Form, labelDefs) 281 perCidLabelForms := parseStackLabelForms(r.Form) 282 stackLabelStates := make(map[string]models.LabelState, len(perCidLabelForms)) 283 for cid, perForm := range perCidLabelForms { 284 stackLabelStates[cid] = labelStateFromForm(perForm, labelDefs) 285 } 286 287 stackTitles := parseBracketedForm(r.Form, "stackTitle") 288 stackBodies := parseBracketedForm(r.Form, "stackBody") 289 stackSplits := parseBracketedForm(r.Form, "stackSplit") 290 291 title := r.FormValue("title") 292 body := r.FormValue("body") 293 if comparison != nil && len(comparison.FormatPatch) > 0 { 294 first := comparison.FormatPatch[0] 295 if title == "" && first.PatchHeader != nil { 296 title = first.Title 297 } 298 if body == "" && first.PatchHeader != nil { 299 body = first.Body 300 } 301 } 302 303 isStacked := r.FormValue("mode") == "stack" && source != pages.SourcePatch 304 var stackedDiffs []pages.StackedDiff 305 if isStacked { 306 stackedDiffs = stackPerCommitDiffs(comparison, targetBranch, refreshUrl, stackSplits) 307 } 308 309 return pages.RepoNewPullParams{ 310 LoggedInUser: user, 311 RepoInfo: repoInfo, 312 Branches: branches, 313 SourceBranches: sourceBranchList, 314 ForkBranches: forkBranches, 315 Forks: forks, 316 Source: source, 317 SourceBranch: sourceBranch, 318 TargetBranch: targetBranch, 319 Fork: fork, 320 Patch: patch, 321 Title: title, 322 Body: body, 323 IsStacked: isStacked, 324 Comparison: comparison, 325 Diff: diff, 326 DiffOpts: diffOpts, 327 StackedDiffs: stackedDiffs, 328 MergeCheck: mergeCheck, 329 StackTitles: stackTitles, 330 StackBodies: stackBodies, 331 PrefillError: prefillErr, 332 LabelDefs: labelDefs, 333 LabelState: labelState, 334 StackLabelStates: stackLabelStates, 335 }, nil 336} 337 338func (s *Pulls) listBranches(ctx context.Context, repo *models.Repo) ([]types.Branch, error) { 339 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 340 xrpcBytes, err := tangled.GitTempListBranches(ctx, xrpcc, "", 0, repo.RepoDid) 341 if err != nil { 342 return nil, err 343 } 344 var result types.RepoBranchesResponse 345 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 346 return nil, err 347 } 348 return result.Branches, nil 349} 350 351func (s *Pulls) listForkBranches(ctx context.Context, forkRepoDid string) ([]types.Branch, error) { 352 if forkRepoDid == "" { 353 return nil, fmt.Errorf("fork not found") 354 } 355 forkRepo, err := db.GetForkByRepoDid(s.db, forkRepoDid) 356 if errors.Is(err, sql.ErrNoRows) { 357 return nil, fmt.Errorf("fork not found") 358 } 359 if err != nil { 360 return nil, err 361 } 362 branches, err := s.listBranches(ctx, forkRepo) 363 if err != nil { 364 return nil, err 365 } 366 return sortBranchesByRecency(branches), nil 367} 368 369func sourceBranchChoices(branches []types.Branch) []types.Branch { 370 withoutDefault := slices.DeleteFunc(slices.Clone(branches), func(b types.Branch) bool { 371 return b.IsDefault 372 }) 373 return sortBranchesByRecency(withoutDefault) 374} 375 376func defaultTargetBranch(branches []types.Branch, current string) string { 377 if slices.ContainsFunc(branches, func(b types.Branch) bool { return b.Reference.Name == current }) { 378 return current 379 } 380 if idx := slices.IndexFunc(branches, func(b types.Branch) bool { return b.IsDefault }); idx >= 0 { 381 return branches[idx].Reference.Name 382 } 383 return "" 384} 385 386func defaultSourceBranch(source pages.Source, current string, branchChoices, forkBranches []types.Branch) string { 387 var candidates []types.Branch 388 switch source { 389 case pages.SourceFork: 390 candidates = forkBranches 391 case pages.SourceBranch: 392 candidates = branchChoices 393 default: 394 return current 395 } 396 if slices.ContainsFunc(candidates, func(b types.Branch) bool { return b.Reference.Name == current }) { 397 return current 398 } 399 if len(candidates) == 0 { 400 return "" 401 } 402 return candidates[0].Reference.Name 403} 404 405func sortBranchesByRecency(branches []types.Branch) []types.Branch { 406 out := slices.Clone(branches) 407 sort.SliceStable(out, func(i, j int) bool { 408 if out[i].Commit == nil || out[j].Commit == nil { 409 return out[i].Commit != nil 410 } 411 return out[i].Commit.Committer.When.After(out[j].Commit.Committer.When) 412 }) 413 return out 414} 415 416func (s *Pulls) prefetchComparison(r *http.Request, repo *models.Repo, source pages.Source, fork, targetBranch, sourceBranch, patch string) (*types.RepoFormatPatchResponse, *types.NiceDiff, error) { 417 var ( 418 comparison *types.RepoFormatPatchResponse 419 err error 420 ) 421 switch source { 422 case pages.SourcePatch: 423 if strings.TrimSpace(patch) == "" { 424 return nil, nil, nil 425 } 426 if verr := s.validator.ValidatePatch(&patch); verr != nil { 427 return nil, nil, fmt.Errorf("invalid patch: paste a valid git diff or format-patch") 428 } 429 comparison = parsePastedPatch(patch) 430 case pages.SourceBranch: 431 if targetBranch == "" || sourceBranch == "" { 432 return nil, nil, nil 433 } 434 comparison, err = s.fetchBranchComparison(r.Context(), repo, targetBranch, sourceBranch) 435 case pages.SourceFork: 436 if fork == "" || targetBranch == "" || sourceBranch == "" { 437 return nil, nil, nil 438 } 439 comparison, err = s.fetchForkComparison(r, fork, targetBranch, sourceBranch) 440 default: 441 return nil, nil, nil 442 } 443 if err != nil { 444 s.logger.With("handler", "prefetchComparison").Warn("failed to pre-fetch comparison", "err", err, "source", source) 445 return nil, nil, err 446 } 447 448 return comparison, deriveDiff(comparison, targetBranch), nil 449} 450 451func (s *Pulls) composeMergeCheck(ctx context.Context, repo *models.Repo, targetBranch string, comparison *types.RepoFormatPatchResponse) *types.MergeCheckResponse { 452 if comparison == nil || targetBranch == "" { 453 return nil 454 } 455 patch := comparison.CombinedPatchRaw 456 if patch == "" { 457 patch = comparison.FormatPatchRaw 458 } 459 if patch == "" { 460 return nil 461 } 462 463 xrpcc := s.knotClient(repo.Knot) 464 465 resp, err := tangled.RepoMergeCheck(ctx, xrpcc, &tangled.RepoMergeCheck_Input{ 466 Did: repo.Did, 467 Name: repo.Name, 468 Branch: targetBranch, 469 Patch: patch, 470 }) 471 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 472 s.logger.With("handler", "composeMergeCheck").Warn("failed to check mergeability", "xrpcerr", xrpcerr, "err", err, "target_branch", targetBranch) 473 return &types.MergeCheckResponse{Error: xrpcerr.Error()} 474 } 475 476 out := mergeCheckResponseFrom(resp) 477 return &out 478} 479 480func bracketComponents(key, prefix string) ([]string, bool) { 481 if !strings.HasPrefix(key, prefix) { 482 return nil, false 483 } 484 rest := key[len(prefix):] 485 var parts []string 486 for len(rest) > 0 { 487 if !strings.HasPrefix(rest, "[") { 488 return nil, false 489 } 490 end := strings.Index(rest, "]") 491 if end <= 0 { 492 return nil, false 493 } 494 parts = append(parts, rest[1:end]) 495 rest = rest[end+1:] 496 } 497 if len(parts) == 0 { 498 return nil, false 499 } 500 return parts, true 501} 502 503func parseBracketedForm(form url.Values, prefix string) map[string]string { 504 out := make(map[string]string) 505 for key, vals := range form { 506 parts, ok := bracketComponents(key, prefix) 507 if !ok || len(parts) != 1 || parts[0] == "" || len(vals) == 0 { 508 continue 509 } 510 out[parts[0]] = vals[0] 511 } 512 return out 513} 514 515func parseStackLabelForms(form url.Values) map[string]url.Values { 516 out := make(map[string]url.Values) 517 for key, vals := range form { 518 parts, ok := bracketComponents(key, "stackLabel") 519 if !ok || len(parts) != 2 || parts[0] == "" || parts[1] == "" { 520 continue 521 } 522 cid, atUri := parts[0], parts[1] 523 if _, ok := out[cid]; !ok { 524 out[cid] = make(url.Values) 525 } 526 out[cid][atUri] = append(out[cid][atUri], vals...) 527 } 528 return out 529} 530 531func parsePastedPatch(patch string) *types.RepoFormatPatchResponse { 532 if patch == "" { 533 return nil 534 } 535 response := &types.RepoFormatPatchResponse{FormatPatchRaw: patch} 536 if patchutil.IsFormatPatch(patch) { 537 if patches, err := patchutil.ExtractPatches(patch); err == nil { 538 response.FormatPatch = patches 539 } 540 } 541 return response 542} 543 544func (s *Pulls) fetchBranchComparison(ctx context.Context, repo *models.Repo, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { 545 xrpcc := s.knotClient(repo.Knot) 546 547 xrpcBytes, err := tangled.RepoCompare(ctx, xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch) 548 if err != nil { 549 return nil, err 550 } 551 552 var comparison types.RepoFormatPatchResponse 553 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 554 return nil, err 555 } 556 return &comparison, nil 557} 558 559func (s *Pulls) fetchForkComparison(r *http.Request, forkRepoDid, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { 560 if forkRepoDid == "" { 561 return nil, fmt.Errorf("fork not found") 562 } 563 fork, err := db.GetForkByRepoDid(s.db, forkRepoDid) 564 if errors.Is(err, sql.ErrNoRows) { 565 return nil, fmt.Errorf("fork not found") 566 } 567 if err != nil { 568 return nil, err 569 } 570 571 client, err := s.oauth.ServiceClient( 572 r, 573 oauth.WithService(fork.Knot), 574 oauth.WithLxm(tangled.RepoHiddenRefNSID), 575 oauth.WithDev(s.config.Core.Dev), 576 ) 577 if err != nil { 578 return nil, err 579 } 580 581 resp, err := tangled.RepoHiddenRef( 582 r.Context(), 583 client, 584 &tangled.RepoHiddenRef_Input{ 585 ForkRef: sourceBranch, 586 RemoteRef: targetBranch, 587 Repo: fork.RepoAt().String(), 588 }, 589 ) 590 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 591 return nil, xrpcerr 592 } 593 if !resp.Success { 594 if resp.Error != nil { 595 return nil, fmt.Errorf("hidden ref failed: %s", *resp.Error) 596 } 597 return nil, fmt.Errorf("hidden ref failed") 598 } 599 600 hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 601 forkXrpcc := s.knotClient(fork.Knot) 602 603 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch) 604 if err != nil { 605 return nil, err 606 } 607 608 var comparison types.RepoFormatPatchResponse 609 if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 610 return nil, err 611 } 612 return &comparison, nil 613} 614 615func stackPerCommitDiffs( 616 comparison *types.RepoFormatPatchResponse, 617 targetBranch, refreshUrl string, 618 stackSplits map[string]string, 619) []pages.StackedDiff { 620 if comparison == nil { 621 return nil 622 } 623 out := make([]pages.StackedDiff, len(comparison.FormatPatch)) 624 for i, p := range comparison.FormatPatch { 625 nd := patchutil.AsNiceDiff(p.Raw, targetBranch) 626 out[i].Diff = &nd 627 cid := p.ChangeIdOrEmpty() 628 if cid == "" { 629 continue 630 } 631 out[i].Opts = types.DiffOpts{ 632 Split: stackSplits[cid] == "split", 633 RefreshUrl: refreshUrl, 634 Target: fmt.Sprintf("#stack-diff-%s", cid), 635 Field: fmt.Sprintf("stackSplit[%s]", cid), 636 } 637 } 638 return out 639} 640 641func deriveDiff(comparison *types.RepoFormatPatchResponse, targetBranch string) *types.NiceDiff { 642 if comparison == nil { 643 return nil 644 } 645 raw := comparison.CombinedPatchRaw 646 if raw == "" { 647 raw = comparison.FormatPatchRaw 648 } 649 d := patchutil.AsNiceDiff(raw, targetBranch) 650 return &d 651}