Monorepo for Tangled tangled.org
12

Configure Feed

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

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