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