package pulls import ( "cmp" "context" "database/sql" "encoding/json" "errors" "fmt" "log/slog" "net/http" "net/url" "slices" "sort" "strings" "tangled.org/core/api/tangled" "tangled.org/core/appview/db" "tangled.org/core/appview/knotcompat" "tangled.org/core/appview/models" "tangled.org/core/appview/pages" "tangled.org/core/appview/pages/markup/sanitizer" "tangled.org/core/consts" gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" "tangled.org/core/types" "github.com/bluesky-social/indigo/atproto/syntax" ) func (s *Pulls) NewPull(w http.ResponseWriter, r *http.Request) { l := s.logger.With("handler", "NewPull") user := s.oauth.GetMultiAccountUser(r) if user != nil { l = l.With("user", user.Did) } f, err := s.repoResolver.Resolve(r) if err != nil { l.Error("failed to get repo and knot", "err", err) return } l = l.With("repo_at", f.RepoAt().String()) switch r.Method { case http.MethodGet: params, err := s.composeParams(r, f) if err != nil { l.Error("failed to build compose params", "err", err) s.pages.Error503(w) return } if err := s.pages.RepoNewPull(w, params); err != nil { l.Error("failed to render", "err", err) } case http.MethodPost: userDid := syntax.DID(user.Did) var ( title = r.FormValue("title") body = r.FormValue("body") targetBranch = r.FormValue("targetBranch") sourceRepoRaw = cmp.Or(r.FormValue("fork"), f.RepoDid) ) sourceRepoDid, err := syntax.ParseDID(sourceRepoRaw) if err != nil { s.pages.Notice(w, "pull", fmt.Sprintf("Source repo is invalid: %q", sourceRepoRaw)) return } sourceBranch := r.FormValue("sourceBranch") patch := r.FormValue("patch") if title == "" { s.pages.Notice(w, "pull", "Title is required") return } if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); st == "" { s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") return } if targetBranch == "" { s.pages.Notice(w, "pull", "Target branch is required.") return } // Validate we have at least one valid PR creation method if sourceBranch == "" && patch == "" { s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") return } // Can't mix branch-based and patch-based approaches if sourceBranch != "" && patch != "" { s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") return } var sourceRepo *models.Repo if sourceRepoDid == syntax.DID(f.RepoDid) { sourceRepo = f } else { var err error sourceRepo, err = db.GetRepoByDid(s.db, sourceRepoDid.String()) if err != nil { s.pages.Notice(w, "pull", fmt.Sprintf("Unknown source repository: %q", sourceRepoDid)) return } } if sourceBranch != "" { roles := s.acl.RolesInRepo(r.Context(), sourceRepo, userDid.String()) if !roles.IsPushAllowed() { s.pages.Notice(w, "pull", "Cannot select forbidden branch.") return } } if sourceRepoDid == syntax.DID(f.RepoDid) && sourceBranch == targetBranch { s.pages.Notice(w, "pull", "Source and target branch must be different.") return } if ok := knotcompat.KnotHasCapability(r.Context(), f.Knot, s.config.Core.Dev, consts.CapKeepCommit); !ok { s.pages.Notice(w, "pull", "Source repo's knot doesn't support ref-based pull requests. Try another way?") return } if sourceBranch != "" { s.handlePull(w, r, userDid, f, targetBranch, sourceRepo, sourceBranch, title, body) return } else if patch != "" { s.pages.Notice(w, "pull", "Patch based PR is currently unsupported.") return } } } func (s *Pulls) PullComposeDiffFragment(w http.ResponseWriter, r *http.Request) { l := s.logger.With("handler", "PullComposeDiffFragment") ctx := r.Context() var ( baseRepoRaw = r.URL.Query().Get("baseRepo") baseBranch = r.URL.Query().Get("base") // base branch name headRepoRaw = r.URL.Query().Get("headRepo") headBranch = r.URL.Query().Get("head") // head branch name unified = r.URL.Query().Get("view") == "unified" ) baseRepo, err := syntax.ParseDID(baseRepoRaw) if err != nil { http.Error(w, "invalid base repo DID", http.StatusBadRequest) return } headRepo, err := syntax.ParseDID(headRepoRaw) if err != nil { http.Error(w, "invalid head repo DID", http.StatusBadRequest) return } l.Debug( "compose diff fragment", "base.repo", baseRepo, "base.commit", baseBranch, "head.repo", headRepo, "head.commit", headBranch, ) // resolve branch to commit IDs base, err := s.resolveRev(ctx, baseRepo, baseBranch) if err != nil { l.Error("failed to resolve base branch", "branch", baseBranch, "repo", baseRepo, "err", err) s.renderComposeDiffErr(w, l, "Failed to resolve base branch.") return } head, err := s.resolveRev(ctx, headRepo, headBranch) if err != nil { l.Error("failed to resolve head branch", "branch", headBranch, "repo", headRepo, "err", err) s.renderComposeDiffErr(w, l, "Failed to resolve head branch.") return } var params pages.PullDiffFragmentParams params.BaseRepo = baseRepo params.HeadRepo = headRepo params.DiffBase = baseBranch params.DiffHead = headBranch params.DiffUrl = r.URL.Path params.Unified = unified params.Files, params.ErrorMsg = s.diffFragmentParams(ctx, l, baseRepo, base, headRepo, head, unified) if err := s.pages.PullComposeDiffFragment(w, params); err != nil { l.Error("failed to render", "err", err) } } func (s *Pulls) resolveRev(ctx context.Context, repo syntax.DID, rev string) (string, error) { out, err := s.gitmirror.ResolveRev(ctx, &gitmirrorv1.ResolveRevRequest{ Repo: repo.String(), Rev: []byte(rev), }) if err != nil { return "", err } return out.GetCommit(), nil } func (s *Pulls) renderComposeDiffErr(w http.ResponseWriter, l *slog.Logger, msg string) { if err := s.pages.PullComposeDiffFragment(w, pages.PullDiffFragmentParams{ErrorMsg: msg}); err != nil { l.Error("failed to render", "err", err) } } func (s *Pulls) RefreshCompose(w http.ResponseWriter, r *http.Request) { l := s.logger.With("handler", "RefreshCompose") f, err := s.repoResolver.Resolve(r) if err != nil { l.Error("failed to resolve repo", "err", err) s.pages.Error503(w) return } params, err := s.composeParams(r, f) if err != nil { l.Error("failed to build compose params", "err", err) s.pages.Error503(w) return } w.Header().Set("HX-Replace-Url", composeCanonicalURL(params)) s.pages.PullComposeHostFragment(w, params) } func composeCanonicalURL(params pages.RepoNewPullParams) string { base := fmt.Sprintf("/%s/pulls/new", params.RepoInfo.FullName()) q := url.Values{} if params.Source != "" && params.Source != pages.SourceBranch { q.Set("source", string(params.Source)) } if params.SourceBranch != "" { q.Set("sourceBranch", params.SourceBranch) } if params.TargetBranch != "" { q.Set("targetBranch", params.TargetBranch) } if params.Source == pages.SourceFork && params.Fork != "" { q.Set("fork", params.Fork) } if len(q) == 0 { return base } return base + "?" + q.Encode() } func (s *Pulls) composeParams(r *http.Request, repo *models.Repo) (pages.RepoNewPullParams, error) { l := s.logger.With("handler", "composeParams") user := s.oauth.GetMultiAccountUser(r) branches, err := s.listBranches(r.Context(), repo) if err != nil { return pages.RepoNewPullParams{}, fmt.Errorf("failed to list branches: %w", err) } var forks []models.Repo if user != nil { forks, err = db.GetForksByDid(s.db, user.Did) if err != nil { l.Warn("failed to list user forks", "err", err, "user", user.Did) } } forks = slices.DeleteFunc(forks, func(f models.Repo) bool { return f.RepoDid == "" }) f, err := s.repoResolver.Resolve(r) if err != nil { return pages.RepoNewPullParams{}, fmt.Errorf("failed to resolve repo: %w", err) } repoInfo := s.repoResolver.GetRepoInfo(r, user) source, ok := pages.ParseSource(r.FormValue("source")) if !ok { source = pages.SourceBranch if !repoInfo.Roles.IsPushAllowed() { source = pages.SourceFork } } sourceBranch := r.FormValue("sourceBranch") targetBranch := r.FormValue("targetBranch") fork := r.FormValue("fork") patch := r.FormValue("patch") if source == pages.SourceFork && fork == "" && len(forks) == 1 { fork = forks[0].RepoDid } var prefillErr error var forkBranches []types.Branch if source == pages.SourceFork && fork != "" { forkBranches, err = s.listForkBranches(r.Context(), fork) if err != nil { l.Warn("failed to list fork branches", "err", prefillErr, "fork", fork) prefillErr = errors.Join(prefillErr, err) } } sourceBranchList := sourceBranchChoices(branches) targetBranch = defaultTargetBranch(branches, targetBranch) sourceBranch = defaultSourceBranch(source, sourceBranch, sourceBranchList, forkBranches) var sourceRepo syntax.DID if fork != "" { sourceRepo = syntax.DID(fork) } else { sourceRepo = syntax.DID(repoInfo.RepoDid) } if sourceRepo == "" || sourceBranch == "" || targetBranch == "" { l.Error("params missing", "source", sourceRepo, "source.branch", sourceBranch, "target.branch", targetBranch) return pages.RepoNewPullParams{ BaseParams: pages.BaseParamsFromContext(r.Context()), RepoInfo: repoInfo, Branches: branches, SourceBranches: sourceBranchList, ForkBranches: forkBranches, Forks: forks, Source: source, SourceBranch: sourceBranch, TargetBranch: targetBranch, Fork: fork, Patch: patch, }, nil } var stepReviewParams pages.RepoNewPull_StepReviewParams commits, err := s.listCommits(r.Context(), sourceRepo, targetBranch, sourceBranch) if err != nil { prefillErr = errors.Join(prefillErr, err) } stepReviewParams.Commits = commits var prefillErrorMsg string if prefillErr != nil { prefillErrorMsg = prefillErr.Error() } labelDefs, err := s.pullLabelDefs(repo) if err != nil { l.Error("failed to load label definitions", "err", err) } labelState := labelStateFromForm(r.Form, labelDefs) title := r.FormValue("title") body := r.FormValue("body") titleDirty := r.FormValue("titleDirty") == "1" bodyDirty := r.FormValue("bodyDirty") == "1" if len(commits) == 1 { message := strings.SplitN(strings.TrimSpace(commits[0].Message), "\n\n", 2) if !titleDirty { title = message[0] } if !bodyDirty && len(message) > 1 && message[1] != "" { // TODO: strip trailers? body = message[1] } } l.Debug("label defs", "defs", labelDefs) var mergeCheckParams pages.MergeCheckParams if len(commits) > 0 { mergeCheckParams = s.composeMergeCheck(r.Context(), f, targetBranch, sourceRepo, commits[0].Hash.String()) } return pages.RepoNewPullParams{ BaseParams: pages.BaseParamsFromContext(r.Context()), RepoInfo: repoInfo, Branches: branches, SourceBranches: sourceBranchList, ForkBranches: forkBranches, Forks: forks, Source: source, SourceBranch: sourceBranch, TargetBranch: targetBranch, Fork: fork, Patch: patch, Title: title, Body: body, TitleDirty: titleDirty, BodyDirty: bodyDirty, StepReviewParams: &stepReviewParams, MergeCheck: mergeCheckParams, PrefillError: prefillErrorMsg, LabelDefs: labelDefs, LabelState: labelState, }, nil } func (s *Pulls) listBranches(ctx context.Context, repo *models.Repo) ([]types.Branch, error) { xrpcc := s.knotMirrorXRPC xrpcBytes, err := tangled.GitTempListBranches(ctx, xrpcc, "", 0, repo.RepoDid) if err != nil { return nil, err } var result types.RepoBranchesResponse if err := json.Unmarshal(xrpcBytes, &result); err != nil { return nil, err } return result.Branches, nil } func (s *Pulls) listForkBranches(ctx context.Context, forkRepoDid string) ([]types.Branch, error) { if forkRepoDid == "" { return nil, fmt.Errorf("fork not found") } forkRepo, err := db.GetForkByRepoDid(s.db, forkRepoDid) if errors.Is(err, sql.ErrNoRows) { return nil, fmt.Errorf("fork not found") } if err != nil { return nil, err } branches, err := s.listBranches(ctx, forkRepo) if err != nil { return nil, err } return sortBranchesByRecency(branches), nil } func sourceBranchChoices(branches []types.Branch) []types.Branch { withoutDefault := slices.DeleteFunc(slices.Clone(branches), func(b types.Branch) bool { return b.IsDefault }) return sortBranchesByRecency(withoutDefault) } func defaultTargetBranch(branches []types.Branch, current string) string { if slices.ContainsFunc(branches, func(b types.Branch) bool { return b.Reference.Name == current }) { return current } if idx := slices.IndexFunc(branches, func(b types.Branch) bool { return b.IsDefault }); idx >= 0 { return branches[idx].Reference.Name } return "" } func defaultSourceBranch(source pages.Source, current string, branchChoices, forkBranches []types.Branch) string { var candidates []types.Branch switch source { case pages.SourceFork: candidates = forkBranches case pages.SourceBranch: candidates = branchChoices default: return current } if slices.ContainsFunc(candidates, func(b types.Branch) bool { return b.Reference.Name == current }) { return current } if len(candidates) == 0 { return "" } return candidates[0].Reference.Name } func sortBranchesByRecency(branches []types.Branch) []types.Branch { out := slices.Clone(branches) sort.SliceStable(out, func(i, j int) bool { if out[i].Commit == nil || out[j].Commit == nil { return out[i].Commit != nil } return out[i].Commit.Committer.When.After(out[j].Commit.Committer.When) }) return out } func (s *Pulls) composeMergeCheck(ctx context.Context, targetRepo *models.Repo, targetBranch string, sourceRepoDid syntax.DID, sourceCommit string) pages.MergeCheckParams { l := s.logger.With("handler", "composeMergeCheck", "repo", targetRepo.RepoDid, "branch", targetBranch, "source", sourceCommit) targetSha, err := s.resolveRev(ctx, syntax.DID(targetRepo.RepoDid), targetBranch) if err != nil { l.Warn("failed to resolve target branch", "err", err) return pages.MergeCheckParams{Error: "merge check failed"} } out, err := s.gitmirror.MergeCheck(ctx, &gitmirrorv1.MergeCheckRequest{ Target: &gitmirrorv1.RepoCommit{Repo: targetRepo.RepoDid, Commit: []byte(targetSha)}, Source: &gitmirrorv1.RepoCommit{Repo: sourceRepoDid.String(), Commit: []byte(sourceCommit)}, }) if err != nil { l.Warn("failed to do merge-check", "err", err) return pages.MergeCheckParams{Error: "merge check failed"} } return pages.MergeCheckParams{ IsConflicted: out.IsConflicted, Conflicts: out.Conflicts, } } func bracketComponents(key, prefix string) ([]string, bool) { if !strings.HasPrefix(key, prefix) { return nil, false } rest := key[len(prefix):] var parts []string for len(rest) > 0 { if !strings.HasPrefix(rest, "[") { return nil, false } end := strings.Index(rest, "]") if end <= 0 { return nil, false } parts = append(parts, rest[1:end]) rest = rest[end+1:] } if len(parts) == 0 { return nil, false } return parts, true } func parseBracketedForm(form url.Values, prefix string) map[string]string { out := make(map[string]string) for key, vals := range form { parts, ok := bracketComponents(key, prefix) if !ok || len(parts) != 1 || parts[0] == "" || len(vals) == 0 { continue } out[parts[0]] = vals[0] } return out } func parseStackLabelForms(form url.Values) map[string]url.Values { out := make(map[string]url.Values) for key, vals := range form { parts, ok := bracketComponents(key, "stackLabel") if !ok || len(parts) != 2 || parts[0] == "" || parts[1] == "" { continue } cid, atUri := parts[0], parts[1] if _, ok := out[cid]; !ok { out[cid] = make(url.Values) } out[cid][atUri] = append(out[cid][atUri], vals...) } return out }