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