Monorepo for Tangled tangled.org
2

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 if comparison != nil && len(comparison.FormatPatch) > 0 { 293 first := comparison.FormatPatch[0] 294 if title == "" && first.PatchHeader != nil { 295 title = first.Title 296 } 297 if body == "" && first.PatchHeader != nil { 298 body = first.Body 299 } 300 } 301 302 isStacked := r.FormValue("mode") == "stack" && source != pages.SourcePatch 303 var stackedDiffs []pages.StackedDiff 304 if isStacked { 305 stackedDiffs = stackPerCommitDiffs(comparison, targetBranch, refreshUrl, stackSplits) 306 } 307 308 return pages.RepoNewPullParams{ 309 BaseParams: pages.BaseParamsFromContext(r.Context()), 310 RepoInfo: repoInfo, 311 Branches: branches, 312 SourceBranches: sourceBranchList, 313 ForkBranches: forkBranches, 314 Forks: forks, 315 Source: source, 316 SourceBranch: sourceBranch, 317 TargetBranch: targetBranch, 318 Fork: fork, 319 Patch: patch, 320 Title: title, 321 Body: body, 322 IsStacked: isStacked, 323 Comparison: comparison, 324 Diff: diff, 325 DiffOpts: diffOpts, 326 StackedDiffs: stackedDiffs, 327 MergeCheck: mergeCheck, 328 StackTitles: stackTitles, 329 StackBodies: stackBodies, 330 PrefillError: prefillErr, 331 LabelDefs: labelDefs, 332 LabelState: labelState, 333 StackLabelStates: stackLabelStates, 334 }, nil 335} 336 337func (s *Pulls) listBranches(ctx context.Context, repo *models.Repo) ([]types.Branch, error) { 338 xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 339 xrpcBytes, err := tangled.GitTempListBranches(ctx, xrpcc, "", 0, repo.RepoDid) 340 if err != nil { 341 return nil, err 342 } 343 var result types.RepoBranchesResponse 344 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 345 return nil, err 346 } 347 return result.Branches, nil 348} 349 350func (s *Pulls) listForkBranches(ctx context.Context, forkRepoDid string) ([]types.Branch, error) { 351 if forkRepoDid == "" { 352 return nil, fmt.Errorf("fork not found") 353 } 354 forkRepo, err := db.GetForkByRepoDid(s.db, forkRepoDid) 355 if errors.Is(err, sql.ErrNoRows) { 356 return nil, fmt.Errorf("fork not found") 357 } 358 if err != nil { 359 return nil, err 360 } 361 branches, err := s.listBranches(ctx, forkRepo) 362 if err != nil { 363 return nil, err 364 } 365 return sortBranchesByRecency(branches), nil 366} 367 368func sourceBranchChoices(branches []types.Branch) []types.Branch { 369 withoutDefault := slices.DeleteFunc(slices.Clone(branches), func(b types.Branch) bool { 370 return b.IsDefault 371 }) 372 return sortBranchesByRecency(withoutDefault) 373} 374 375func defaultTargetBranch(branches []types.Branch, current string) string { 376 if slices.ContainsFunc(branches, func(b types.Branch) bool { return b.Reference.Name == current }) { 377 return current 378 } 379 if idx := slices.IndexFunc(branches, func(b types.Branch) bool { return b.IsDefault }); idx >= 0 { 380 return branches[idx].Reference.Name 381 } 382 return "" 383} 384 385func defaultSourceBranch(source pages.Source, current string, branchChoices, forkBranches []types.Branch) string { 386 var candidates []types.Branch 387 switch source { 388 case pages.SourceFork: 389 candidates = forkBranches 390 case pages.SourceBranch: 391 candidates = branchChoices 392 default: 393 return current 394 } 395 if slices.ContainsFunc(candidates, func(b types.Branch) bool { return b.Reference.Name == current }) { 396 return current 397 } 398 if len(candidates) == 0 { 399 return "" 400 } 401 return candidates[0].Reference.Name 402} 403 404func sortBranchesByRecency(branches []types.Branch) []types.Branch { 405 out := slices.Clone(branches) 406 sort.SliceStable(out, func(i, j int) bool { 407 if out[i].Commit == nil || out[j].Commit == nil { 408 return out[i].Commit != nil 409 } 410 return out[i].Commit.Committer.When.After(out[j].Commit.Committer.When) 411 }) 412 return out 413} 414 415func (s *Pulls) prefetchComparison(r *http.Request, repo *models.Repo, source pages.Source, fork, targetBranch, sourceBranch, patch string) (*types.RepoFormatPatchResponse, *types.NiceDiff, error) { 416 var ( 417 comparison *types.RepoFormatPatchResponse 418 err error 419 ) 420 switch source { 421 case pages.SourcePatch: 422 if strings.TrimSpace(patch) == "" { 423 return nil, nil, nil 424 } 425 if verr := s.validator.ValidatePatch(&patch); verr != nil { 426 return nil, nil, fmt.Errorf("invalid patch: paste a valid git diff or format-patch") 427 } 428 comparison = parsePastedPatch(patch) 429 case pages.SourceBranch: 430 if targetBranch == "" || sourceBranch == "" { 431 return nil, nil, nil 432 } 433 comparison, err = s.fetchBranchComparison(r.Context(), repo, targetBranch, sourceBranch) 434 case pages.SourceFork: 435 if fork == "" || targetBranch == "" || sourceBranch == "" { 436 return nil, nil, nil 437 } 438 comparison, err = s.fetchForkComparison(r, fork, targetBranch, sourceBranch) 439 default: 440 return nil, nil, nil 441 } 442 if err != nil { 443 s.logger.With("handler", "prefetchComparison").Warn("failed to pre-fetch comparison", "err", err, "source", source) 444 return nil, nil, err 445 } 446 447 return comparison, deriveDiff(comparison, targetBranch), nil 448} 449 450func (s *Pulls) composeMergeCheck(ctx context.Context, repo *models.Repo, targetBranch string, comparison *types.RepoFormatPatchResponse) *types.MergeCheckResponse { 451 if comparison == nil || targetBranch == "" { 452 return nil 453 } 454 patch := comparison.CombinedPatchRaw 455 if patch == "" { 456 patch = comparison.FormatPatchRaw 457 } 458 if patch == "" { 459 return nil 460 } 461 462 xrpcc := s.knotClient(repo.Knot) 463 464 resp, err := tangled.RepoMergeCheck(ctx, xrpcc, &tangled.RepoMergeCheck_Input{ 465 Did: repo.Did, 466 Name: repo.Name, 467 Branch: targetBranch, 468 Patch: patch, 469 }) 470 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 471 s.logger.With("handler", "composeMergeCheck").Warn("failed to check mergeability", "xrpcerr", xrpcerr, "err", err, "target_branch", targetBranch) 472 return &types.MergeCheckResponse{Error: xrpcerr.Error()} 473 } 474 475 out := mergeCheckResponseFrom(resp) 476 return &out 477} 478 479func bracketComponents(key, prefix string) ([]string, bool) { 480 if !strings.HasPrefix(key, prefix) { 481 return nil, false 482 } 483 rest := key[len(prefix):] 484 var parts []string 485 for len(rest) > 0 { 486 if !strings.HasPrefix(rest, "[") { 487 return nil, false 488 } 489 end := strings.Index(rest, "]") 490 if end <= 0 { 491 return nil, false 492 } 493 parts = append(parts, rest[1:end]) 494 rest = rest[end+1:] 495 } 496 if len(parts) == 0 { 497 return nil, false 498 } 499 return parts, true 500} 501 502func parseBracketedForm(form url.Values, prefix string) map[string]string { 503 out := make(map[string]string) 504 for key, vals := range form { 505 parts, ok := bracketComponents(key, prefix) 506 if !ok || len(parts) != 1 || parts[0] == "" || len(vals) == 0 { 507 continue 508 } 509 out[parts[0]] = vals[0] 510 } 511 return out 512} 513 514func parseStackLabelForms(form url.Values) map[string]url.Values { 515 out := make(map[string]url.Values) 516 for key, vals := range form { 517 parts, ok := bracketComponents(key, "stackLabel") 518 if !ok || len(parts) != 2 || parts[0] == "" || parts[1] == "" { 519 continue 520 } 521 cid, atUri := parts[0], parts[1] 522 if _, ok := out[cid]; !ok { 523 out[cid] = make(url.Values) 524 } 525 out[cid][atUri] = append(out[cid][atUri], vals...) 526 } 527 return out 528} 529 530func parsePastedPatch(patch string) *types.RepoFormatPatchResponse { 531 if patch == "" { 532 return nil 533 } 534 response := &types.RepoFormatPatchResponse{FormatPatchRaw: patch} 535 if patchutil.IsFormatPatch(patch) { 536 if patches, err := patchutil.ExtractPatches(patch); err == nil { 537 response.FormatPatch = patches 538 } 539 } 540 return response 541} 542 543func (s *Pulls) fetchBranchComparison(ctx context.Context, repo *models.Repo, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { 544 xrpcc := s.knotClient(repo.Knot) 545 546 xrpcBytes, err := tangled.RepoCompare(ctx, xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch) 547 if err != nil { 548 return nil, err 549 } 550 551 var comparison types.RepoFormatPatchResponse 552 if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { 553 return nil, err 554 } 555 return &comparison, nil 556} 557 558func (s *Pulls) fetchForkComparison(r *http.Request, forkRepoDid, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { 559 if forkRepoDid == "" { 560 return nil, fmt.Errorf("fork not found") 561 } 562 fork, err := db.GetForkByRepoDid(s.db, forkRepoDid) 563 if errors.Is(err, sql.ErrNoRows) { 564 return nil, fmt.Errorf("fork not found") 565 } 566 if err != nil { 567 return nil, err 568 } 569 570 client, err := s.oauth.ServiceClient( 571 r, 572 oauth.WithService(fork.Knot), 573 oauth.WithLxm(tangled.RepoHiddenRefNSID), 574 oauth.WithDev(s.config.Core.Dev), 575 ) 576 if err != nil { 577 return nil, err 578 } 579 580 resp, err := tangled.RepoHiddenRef( 581 r.Context(), 582 client, 583 &tangled.RepoHiddenRef_Input{ 584 ForkRef: sourceBranch, 585 RemoteRef: targetBranch, 586 Repo: fork.RepoAt().String(), 587 }, 588 ) 589 if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 590 return nil, xrpcerr 591 } 592 if !resp.Success { 593 if resp.Error != nil { 594 return nil, fmt.Errorf("hidden ref failed: %s", *resp.Error) 595 } 596 return nil, fmt.Errorf("hidden ref failed") 597 } 598 599 hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) 600 forkXrpcc := s.knotClient(fork.Knot) 601 602 forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch) 603 if err != nil { 604 return nil, err 605 } 606 607 var comparison types.RepoFormatPatchResponse 608 if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { 609 return nil, err 610 } 611 return &comparison, nil 612} 613 614func stackPerCommitDiffs( 615 comparison *types.RepoFormatPatchResponse, 616 targetBranch, refreshUrl string, 617 stackSplits map[string]string, 618) []pages.StackedDiff { 619 if comparison == nil { 620 return nil 621 } 622 out := make([]pages.StackedDiff, len(comparison.FormatPatch)) 623 for i, p := range comparison.FormatPatch { 624 nd := patchutil.AsNiceDiff(p.Raw, targetBranch) 625 out[i].Diff = &nd 626 cid := p.ChangeIdOrEmpty() 627 if cid == "" { 628 continue 629 } 630 out[i].Opts = types.DiffOpts{ 631 Split: stackSplits[cid] == "split", 632 RefreshUrl: refreshUrl, 633 Target: fmt.Sprintf("#stack-diff-%s", cid), 634 Field: fmt.Sprintf("stackSplit[%s]", cid), 635 } 636 } 637 return out 638} 639 640func deriveDiff(comparison *types.RepoFormatPatchResponse, targetBranch string) *types.NiceDiff { 641 if comparison == nil { 642 return nil 643 } 644 raw := comparison.CombinedPatchRaw 645 if raw == "" { 646 raw = comparison.FormatPatchRaw 647 } 648 d := patchutil.AsNiceDiff(raw, targetBranch) 649 return &d 650}