This repository has no description
0

Configure Feed

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

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