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
16 kB 549 lines
1package pulls 2 3import ( 4 "cmp" 5 "context" 6 "database/sql" 7 "encoding/json" 8 "errors" 9 "fmt" 10 "log/slog" 11 "net/http" 12 "net/url" 13 "slices" 14 "sort" 15 "strings" 16 17 "tangled.org/core/api/tangled" 18 "tangled.org/core/appview/db" 19 "tangled.org/core/appview/knotcompat" 20 "tangled.org/core/appview/models" 21 "tangled.org/core/appview/pages" 22 "tangled.org/core/appview/pages/markup/sanitizer" 23 "tangled.org/core/consts" 24 gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" 25 "tangled.org/core/types" 26 27 "github.com/bluesky-social/indigo/atproto/syntax" 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 if err := s.pages.RepoNewPull(w, params); err != nil { 54 l.Error("failed to render", "err", err) 55 } 56 57 case http.MethodPost: 58 userDid := syntax.DID(user.Did) 59 var ( 60 title = r.FormValue("title") 61 body = r.FormValue("body") 62 targetBranch = r.FormValue("targetBranch") 63 sourceRepoRaw = cmp.Or(r.FormValue("fork"), f.RepoDid) 64 ) 65 sourceRepoDid, err := syntax.ParseDID(sourceRepoRaw) 66 if err != nil { 67 s.pages.Notice(w, "pull", fmt.Sprintf("Source repo is invalid: %q", sourceRepoRaw)) 68 return 69 } 70 sourceBranch := r.FormValue("sourceBranch") 71 patch := r.FormValue("patch") 72 73 if title == "" { 74 s.pages.Notice(w, "pull", "Title is required") 75 return 76 } 77 if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" { 78 s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") 79 return 80 } 81 82 if targetBranch == "" { 83 s.pages.Notice(w, "pull", "Target branch is required.") 84 return 85 } 86 87 // Validate we have at least one valid PR creation method 88 if sourceBranch == "" && patch == "" { 89 s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") 90 return 91 } 92 // Can't mix branch-based and patch-based approaches 93 if sourceBranch != "" && patch != "" { 94 s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") 95 return 96 } 97 98 var sourceRepo *models.Repo 99 if sourceRepoDid == syntax.DID(f.RepoDid) { 100 sourceRepo = f 101 } else { 102 var err error 103 sourceRepo, err = db.GetRepoByDid(s.db, sourceRepoDid.String()) 104 if err != nil { 105 s.pages.Notice(w, "pull", fmt.Sprintf("Unknown source repository: %q", sourceRepoDid)) 106 return 107 } 108 } 109 110 if sourceBranch != "" { 111 roles := s.acl.RolesInRepo(r.Context(), sourceRepo, userDid.String()) 112 if !roles.IsPushAllowed() { 113 s.pages.Notice(w, "pull", "Cannot select forbidden branch.") 114 return 115 } 116 } 117 118 if sourceRepoDid == syntax.DID(f.RepoDid) && sourceBranch == targetBranch { 119 s.pages.Notice(w, "pull", "Source and target branch must be different.") 120 return 121 } 122 123 if ok := knotcompat.KnotHasCapability(r.Context(), f.Knot, s.config.Core.Dev, consts.CapKeepCommit); !ok { 124 s.pages.Notice(w, "pull", "Source repo's knot doesn't support ref-based pull requests. Try another way?") 125 return 126 } 127 128 if sourceBranch != "" { 129 s.handlePull(w, r, userDid, f, targetBranch, sourceRepo, sourceBranch, title, body) 130 return 131 } else if patch != "" { 132 s.pages.Notice(w, "pull", "Patch based PR is currently unsupported.") 133 return 134 } 135 } 136} 137 138func (s *Pulls) PullComposeDiffFragment(w http.ResponseWriter, r *http.Request) { 139 l := s.logger.With("handler", "PullComposeDiffFragment") 140 ctx := r.Context() 141 142 var ( 143 baseRepoRaw = r.URL.Query().Get("baseRepo") 144 baseBranch = r.URL.Query().Get("base") // base branch name 145 headRepoRaw = r.URL.Query().Get("headRepo") 146 headBranch = r.URL.Query().Get("head") // head branch name 147 unified = r.URL.Query().Get("view") == "unified" 148 ) 149 baseRepo, err := syntax.ParseDID(baseRepoRaw) 150 if err != nil { 151 http.Error(w, "invalid base repo DID", http.StatusBadRequest) 152 return 153 } 154 headRepo, err := syntax.ParseDID(headRepoRaw) 155 if err != nil { 156 http.Error(w, "invalid head repo DID", http.StatusBadRequest) 157 return 158 } 159 l.Debug( 160 "compose diff fragment", 161 "base.repo", baseRepo, 162 "base.commit", baseBranch, 163 "head.repo", headRepo, 164 "head.commit", headBranch, 165 ) 166 167 // resolve branch to commit IDs 168 base, err := s.resolveRev(ctx, baseRepo, baseBranch) 169 if err != nil { 170 l.Error("failed to resolve base branch", "branch", baseBranch, "repo", baseRepo, "err", err) 171 s.renderComposeDiffErr(w, l, "Failed to resolve base branch.") 172 return 173 } 174 head, err := s.resolveRev(ctx, headRepo, headBranch) 175 if err != nil { 176 l.Error("failed to resolve head branch", "branch", headBranch, "repo", headRepo, "err", err) 177 s.renderComposeDiffErr(w, l, "Failed to resolve head branch.") 178 return 179 } 180 181 var params pages.PullDiffFragmentParams 182 params.BaseRepo = baseRepo 183 params.HeadRepo = headRepo 184 params.DiffBase = baseBranch 185 params.DiffHead = headBranch 186 params.DiffUrl = r.URL.Path 187 params.Unified = unified 188 params.Files, params.ErrorMsg = s.diffFragmentParams(ctx, l, baseRepo, base, headRepo, head, unified) 189 if err := s.pages.PullComposeDiffFragment(w, params); err != nil { 190 l.Error("failed to render", "err", err) 191 } 192} 193 194func (s *Pulls) resolveRev(ctx context.Context, repo syntax.DID, rev string) (string, error) { 195 out, err := s.gitmirror.ResolveRev(ctx, &gitmirrorv1.ResolveRevRequest{ 196 Repo: repo.String(), 197 Rev: []byte(rev), 198 }) 199 if err != nil { 200 return "", err 201 } 202 return out.GetCommit(), nil 203} 204 205func (s *Pulls) renderComposeDiffErr(w http.ResponseWriter, l *slog.Logger, msg string) { 206 if err := s.pages.PullComposeDiffFragment(w, pages.PullDiffFragmentParams{ErrorMsg: msg}); err != nil { 207 l.Error("failed to render", "err", err) 208 } 209} 210 211func (s *Pulls) RefreshCompose(w http.ResponseWriter, r *http.Request) { 212 l := s.logger.With("handler", "RefreshCompose") 213 214 f, err := s.repoResolver.Resolve(r) 215 if err != nil { 216 l.Error("failed to resolve repo", "err", err) 217 s.pages.Error503(w) 218 return 219 } 220 221 params, err := s.composeParams(r, f) 222 if err != nil { 223 l.Error("failed to build compose params", "err", err) 224 s.pages.Error503(w) 225 return 226 } 227 w.Header().Set("HX-Replace-Url", composeCanonicalURL(params)) 228 s.pages.PullComposeHostFragment(w, params) 229} 230 231func composeCanonicalURL(params pages.RepoNewPullParams) string { 232 base := fmt.Sprintf("/%s/pulls/new", params.RepoInfo.FullName()) 233 q := url.Values{} 234 if params.Source != "" && params.Source != pages.SourceBranch { 235 q.Set("source", string(params.Source)) 236 } 237 if params.SourceBranch != "" { 238 q.Set("sourceBranch", params.SourceBranch) 239 } 240 if params.TargetBranch != "" { 241 q.Set("targetBranch", params.TargetBranch) 242 } 243 if params.Source == pages.SourceFork && params.Fork != "" { 244 q.Set("fork", params.Fork) 245 } 246 if len(q) == 0 { 247 return base 248 } 249 return base + "?" + q.Encode() 250} 251 252func (s *Pulls) composeParams(r *http.Request, repo *models.Repo) (pages.RepoNewPullParams, error) { 253 l := s.logger.With("handler", "composeParams") 254 user := s.oauth.GetMultiAccountUser(r) 255 256 branches, err := s.listBranches(r.Context(), repo) 257 if err != nil { 258 return pages.RepoNewPullParams{}, fmt.Errorf("failed to list branches: %w", err) 259 } 260 261 var forks []models.Repo 262 if user != nil { 263 forks, err = db.GetForksByDid(s.db, user.Did) 264 if err != nil { 265 l.Warn("failed to list user forks", "err", err, "user", user.Did) 266 } 267 } 268 forks = slices.DeleteFunc(forks, func(f models.Repo) bool { 269 return f.RepoDid == "" 270 }) 271 272 f, err := s.repoResolver.Resolve(r) 273 if err != nil { 274 return pages.RepoNewPullParams{}, fmt.Errorf("failed to resolve repo: %w", err) 275 } 276 277 repoInfo := s.repoResolver.GetRepoInfo(r, user) 278 source, ok := pages.ParseSource(r.FormValue("source")) 279 if !ok { 280 source = pages.SourceBranch 281 if !repoInfo.Roles.IsPushAllowed() { 282 source = pages.SourceFork 283 } 284 } 285 286 sourceBranch := r.FormValue("sourceBranch") 287 targetBranch := r.FormValue("targetBranch") 288 fork := r.FormValue("fork") 289 patch := r.FormValue("patch") 290 291 if source == pages.SourceFork && fork == "" && len(forks) == 1 { 292 fork = forks[0].RepoDid 293 } 294 295 var prefillErr error 296 297 var forkBranches []types.Branch 298 if source == pages.SourceFork && fork != "" { 299 forkBranches, err = s.listForkBranches(r.Context(), fork) 300 if err != nil { 301 l.Warn("failed to list fork branches", "err", prefillErr, "fork", fork) 302 prefillErr = errors.Join(prefillErr, err) 303 } 304 } 305 306 sourceBranchList := sourceBranchChoices(branches) 307 targetBranch = defaultTargetBranch(branches, targetBranch) 308 sourceBranch = defaultSourceBranch(source, sourceBranch, sourceBranchList, forkBranches) 309 310 var sourceRepo syntax.DID 311 if fork != "" { 312 sourceRepo = syntax.DID(fork) 313 } else { 314 sourceRepo = syntax.DID(repoInfo.RepoDid) 315 } 316 317 if sourceRepo == "" || sourceBranch == "" || targetBranch == "" { 318 l.Error("params missing", "source", sourceRepo, "source.branch", sourceBranch, "target.branch", targetBranch) 319 return pages.RepoNewPullParams{ 320 BaseParams: pages.BaseParamsFromContext(r.Context()), 321 RepoInfo: repoInfo, 322 Branches: branches, 323 SourceBranches: sourceBranchList, 324 ForkBranches: forkBranches, 325 Forks: forks, 326 Source: source, 327 SourceBranch: sourceBranch, 328 TargetBranch: targetBranch, 329 Fork: fork, 330 Patch: patch, 331 }, nil 332 } 333 334 var stepReviewParams pages.RepoNewPull_StepReviewParams 335 336 commits, err := s.listCommits(r.Context(), sourceRepo, targetBranch, sourceBranch) 337 if err != nil { 338 prefillErr = errors.Join(prefillErr, err) 339 } 340 stepReviewParams.Commits = commits 341 342 var prefillErrorMsg string 343 if prefillErr != nil { 344 prefillErrorMsg = prefillErr.Error() 345 } 346 347 labelDefs, err := s.pullLabelDefs(repo) 348 if err != nil { 349 l.Error("failed to load label definitions", "err", err) 350 } 351 labelState := labelStateFromForm(r.Form, labelDefs) 352 353 title := r.FormValue("title") 354 body := r.FormValue("body") 355 titleDirty := r.FormValue("titleDirty") == "1" 356 bodyDirty := r.FormValue("bodyDirty") == "1" 357 if len(commits) == 1 { 358 message := strings.SplitN(strings.TrimSpace(commits[0].Message), "\n\n", 2) 359 if !titleDirty { 360 title = message[0] 361 } 362 if !bodyDirty && len(message) > 1 && message[1] != "" { 363 // TODO: strip trailers? 364 body = message[1] 365 } 366 } 367 368 l.Debug("label defs", "defs", labelDefs) 369 370 var mergeCheckParams pages.MergeCheckParams 371 if len(commits) > 0 { 372 mergeCheckParams = s.composeMergeCheck(r.Context(), f, targetBranch, sourceRepo, commits[0].Hash.String()) 373 } 374 375 return pages.RepoNewPullParams{ 376 BaseParams: pages.BaseParamsFromContext(r.Context()), 377 RepoInfo: repoInfo, 378 Branches: branches, 379 SourceBranches: sourceBranchList, 380 ForkBranches: forkBranches, 381 Forks: forks, 382 Source: source, 383 SourceBranch: sourceBranch, 384 TargetBranch: targetBranch, 385 Fork: fork, 386 Patch: patch, 387 Title: title, 388 Body: body, 389 TitleDirty: titleDirty, 390 BodyDirty: bodyDirty, 391 StepReviewParams: &stepReviewParams, 392 MergeCheck: mergeCheckParams, 393 PrefillError: prefillErrorMsg, 394 LabelDefs: labelDefs, 395 LabelState: labelState, 396 }, nil 397} 398 399func (s *Pulls) listBranches(ctx context.Context, repo *models.Repo) ([]types.Branch, error) { 400 xrpcc := s.knotMirrorXRPC 401 xrpcBytes, err := tangled.GitTempListBranches(ctx, xrpcc, "", 0, repo.RepoDid) 402 if err != nil { 403 return nil, err 404 } 405 var result types.RepoBranchesResponse 406 if err := json.Unmarshal(xrpcBytes, &result); err != nil { 407 return nil, err 408 } 409 return result.Branches, nil 410} 411 412func (s *Pulls) listForkBranches(ctx context.Context, forkRepoDid string) ([]types.Branch, error) { 413 if forkRepoDid == "" { 414 return nil, fmt.Errorf("fork not found") 415 } 416 forkRepo, err := db.GetForkByRepoDid(s.db, forkRepoDid) 417 if errors.Is(err, sql.ErrNoRows) { 418 return nil, fmt.Errorf("fork not found") 419 } 420 if err != nil { 421 return nil, err 422 } 423 branches, err := s.listBranches(ctx, forkRepo) 424 if err != nil { 425 return nil, err 426 } 427 return sortBranchesByRecency(branches), nil 428} 429 430func sourceBranchChoices(branches []types.Branch) []types.Branch { 431 withoutDefault := slices.DeleteFunc(slices.Clone(branches), func(b types.Branch) bool { 432 return b.IsDefault 433 }) 434 return sortBranchesByRecency(withoutDefault) 435} 436 437func defaultTargetBranch(branches []types.Branch, current string) string { 438 if slices.ContainsFunc(branches, func(b types.Branch) bool { return b.Reference.Name == current }) { 439 return current 440 } 441 if idx := slices.IndexFunc(branches, func(b types.Branch) bool { return b.IsDefault }); idx >= 0 { 442 return branches[idx].Reference.Name 443 } 444 return "" 445} 446 447func defaultSourceBranch(source pages.Source, current string, branchChoices, forkBranches []types.Branch) string { 448 var candidates []types.Branch 449 switch source { 450 case pages.SourceFork: 451 candidates = forkBranches 452 case pages.SourceBranch: 453 candidates = branchChoices 454 default: 455 return current 456 } 457 if slices.ContainsFunc(candidates, func(b types.Branch) bool { return b.Reference.Name == current }) { 458 return current 459 } 460 if len(candidates) == 0 { 461 return "" 462 } 463 return candidates[0].Reference.Name 464} 465 466func sortBranchesByRecency(branches []types.Branch) []types.Branch { 467 out := slices.Clone(branches) 468 sort.SliceStable(out, func(i, j int) bool { 469 if out[i].Commit == nil || out[j].Commit == nil { 470 return out[i].Commit != nil 471 } 472 return out[i].Commit.Committer.When.After(out[j].Commit.Committer.When) 473 }) 474 return out 475} 476 477func (s *Pulls) composeMergeCheck(ctx context.Context, targetRepo *models.Repo, targetBranch string, sourceRepoDid syntax.DID, sourceCommit string) pages.MergeCheckParams { 478 l := s.logger.With("handler", "composeMergeCheck", "repo", targetRepo.RepoDid, "branch", targetBranch, "source", sourceCommit) 479 480 targetSha, err := s.resolveRev(ctx, syntax.DID(targetRepo.RepoDid), targetBranch) 481 if err != nil { 482 l.Warn("failed to resolve target branch", "err", err) 483 return pages.MergeCheckParams{Error: "merge check failed"} 484 } 485 486 out, err := s.gitmirror.MergeCheck(ctx, &gitmirrorv1.MergeCheckRequest{ 487 Target: &gitmirrorv1.RepoCommit{Repo: targetRepo.RepoDid, Commit: []byte(targetSha)}, 488 Source: &gitmirrorv1.RepoCommit{Repo: sourceRepoDid.String(), Commit: []byte(sourceCommit)}, 489 }) 490 if err != nil { 491 l.Warn("failed to do merge-check", "err", err) 492 return pages.MergeCheckParams{Error: "merge check failed"} 493 } 494 return pages.MergeCheckParams{ 495 IsConflicted: out.IsConflicted, 496 Conflicts: out.Conflicts, 497 } 498} 499 500func bracketComponents(key, prefix string) ([]string, bool) { 501 if !strings.HasPrefix(key, prefix) { 502 return nil, false 503 } 504 rest := key[len(prefix):] 505 var parts []string 506 for len(rest) > 0 { 507 if !strings.HasPrefix(rest, "[") { 508 return nil, false 509 } 510 end := strings.Index(rest, "]") 511 if end <= 0 { 512 return nil, false 513 } 514 parts = append(parts, rest[1:end]) 515 rest = rest[end+1:] 516 } 517 if len(parts) == 0 { 518 return nil, false 519 } 520 return parts, true 521} 522 523func parseBracketedForm(form url.Values, prefix string) map[string]string { 524 out := make(map[string]string) 525 for key, vals := range form { 526 parts, ok := bracketComponents(key, prefix) 527 if !ok || len(parts) != 1 || parts[0] == "" || len(vals) == 0 { 528 continue 529 } 530 out[parts[0]] = vals[0] 531 } 532 return out 533} 534 535func parseStackLabelForms(form url.Values) map[string]url.Values { 536 out := make(map[string]url.Values) 537 for key, vals := range form { 538 parts, ok := bracketComponents(key, "stackLabel") 539 if !ok || len(parts) != 2 || parts[0] == "" || parts[1] == "" { 540 continue 541 } 542 cid, atUri := parts[0], parts[1] 543 if _, ok := out[cid]; !ok { 544 out[cid] = make(url.Values) 545 } 546 out[cid][atUri] = append(out[cid][atUri], vals...) 547 } 548 return out 549}