Monorepo for Tangled tangled.org
11

Configure Feed

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

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