package pulls import ( "context" "database/sql" "encoding/json" "errors" "fmt" "net/http" "net/url" "slices" "sort" "strings" "tangled.org/core/api/tangled" "tangled.org/core/appview/db" "tangled.org/core/appview/models" "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" "tangled.org/core/appview/pages/markup/sanitizer" "tangled.org/core/patchutil" "tangled.org/core/types" "tangled.org/core/xrpc/xrpcclient" "github.com/bluesky-social/indigo/atproto/syntax" indigoxrpc "github.com/bluesky-social/indigo/xrpc" ) 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 } s.pages.RepoNewPull(w, params) case http.MethodPost: title := r.FormValue("title") body := r.FormValue("body") targetBranch := r.FormValue("targetBranch") fromFork := r.FormValue("fork") sourceBranch := r.FormValue("sourceBranch") patch := r.FormValue("patch") userDid := syntax.DID(user.Did) if targetBranch == "" { s.pages.Notice(w, "pull", "Target branch is required.") return } // Determine PR type based on input parameters roles := s.acl.RolesInRepo(r.Context(), f, userDid.String()) isPushAllowed := roles.IsPushAllowed() isBranchBased := isPushAllowed && sourceBranch != "" && fromFork == "" isForkBased := fromFork != "" && sourceBranch != "" isPatchBased := patch != "" && !isBranchBased && !isForkBased isStacked := r.FormValue("mode") == "stack" && !isPatchBased if isPatchBased && !patchutil.IsFormatPatch(patch) { if title == "" { s.pages.Notice(w, "pull", "Title is required for git-diff patches.") return } if st := strings.TrimSpace(sanitizer.SanitizeDescription(title)); (st) == "" { s.pages.Notice(w, "pull", "Title is empty after HTML sanitization") return } } // Validate we have at least one valid PR creation method if !isBranchBased && !isPatchBased && !isForkBased { s.pages.Notice(w, "pull", "Neither source branch nor patch supplied.") return } // Can't mix branch-based and patch-based approaches if isBranchBased && patch != "" { s.pages.Notice(w, "pull", "Cannot select both patch and source branch.") return } if isBranchBased && sourceBranch == targetBranch { s.pages.Notice(w, "pull", "Source and target branch must be different.") return } // TODO: make capabilities an xrpc call caps := struct { PullRequests struct { FormatPatch bool BranchSubmissions bool ForkSubmissions bool PatchSubmissions bool } }{ PullRequests: struct { FormatPatch bool BranchSubmissions bool ForkSubmissions bool PatchSubmissions bool }{ FormatPatch: true, BranchSubmissions: true, ForkSubmissions: true, PatchSubmissions: true, }, } if !caps.PullRequests.FormatPatch { s.pages.Notice(w, "pull", "This knot doesn't support format-patch. Unfortunately, there is no fallback for now.") return } stackTitles := parseBracketedForm(r.Form, "stackTitle") stackBodies := parseBracketedForm(r.Form, "stackBody") // Handle the PR creation based on the type if isBranchBased { if !caps.PullRequests.BranchSubmissions { s.pages.Notice(w, "pull", "This knot doesn't support branch-based pull requests. Try another way?") return } s.handleBranchBasedPull(w, r, f, userDid, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies) } else if isForkBased { if !caps.PullRequests.ForkSubmissions { s.pages.Notice(w, "pull", "This knot doesn't support fork-based pull requests. Try another way?") return } s.handleForkBasedPull(w, r, f, userDid, fromFork, title, body, targetBranch, sourceBranch, isStacked, stackTitles, stackBodies) } else if isPatchBased { if !caps.PullRequests.PatchSubmissions { s.pages.Notice(w, "pull", "This knot doesn't support patch-based pull requests. Send your patch over email.") return } s.handlePatchBasedPull(w, r, f, userDid, title, body, targetBranch, patch, isStacked, stackTitles, stackBodies) } return } } 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.IsStacked { q.Set("mode", "stack") } 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{}, 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 == "" }) 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 forkBranches []types.Branch var forkBranchesErr error if source == pages.SourceFork && fork != "" { forkBranches, forkBranchesErr = s.listForkBranches(r.Context(), fork) if forkBranchesErr != nil { l.Warn("failed to list fork branches", "err", forkBranchesErr, "fork", fork) } } sourceBranchList := sourceBranchChoices(branches) targetBranch = defaultTargetBranch(branches, targetBranch) sourceBranch = defaultSourceBranch(source, sourceBranch, sourceBranchList, forkBranches) comparison, diff, prefetchErr := s.prefetchComparison(r, repo, source, fork, targetBranch, sourceBranch, patch) var prefillErr string if joined := errors.Join(prefetchErr, forkBranchesErr); joined != nil { prefillErr = joined.Error() } mergeCheck := s.composeMergeCheck(r.Context(), repo, targetBranch, comparison) refreshUrl := fmt.Sprintf("/%s/pulls/new/refresh", repoInfo.FullName()) var diffOpts types.DiffOpts if r.FormValue("diff") == "split" { diffOpts.Split = true } diffOpts.RefreshUrl = refreshUrl diffOpts.Target = "#diff-area" labelDefs, err := s.pullLabelDefs(repo) if err != nil { l.Warn("failed to load label definitions", "err", err) } labelState := labelStateFromForm(r.Form, labelDefs) perCidLabelForms := parseStackLabelForms(r.Form) stackLabelStates := make(map[string]models.LabelState, len(perCidLabelForms)) for cid, perForm := range perCidLabelForms { stackLabelStates[cid] = labelStateFromForm(perForm, labelDefs) } stackTitles := parseBracketedForm(r.Form, "stackTitle") stackBodies := parseBracketedForm(r.Form, "stackBody") stackSplits := parseBracketedForm(r.Form, "stackSplit") title := r.FormValue("title") body := r.FormValue("body") titleDirty := r.FormValue("titleDirty") == "1" bodyDirty := r.FormValue("bodyDirty") == "1" if comparison != nil && len(comparison.FormatPatch) > 0 { first := comparison.FormatPatch[0] if !titleDirty && first.PatchHeader != nil { title = first.Title } if !bodyDirty && first.PatchHeader != nil { body = first.Body } } isStacked := r.FormValue("mode") == "stack" && source != pages.SourcePatch var stackedDiffs []pages.StackedDiff if isStacked { stackedDiffs = stackPerCommitDiffs(comparison, targetBranch, refreshUrl, stackSplits) } 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, IsStacked: isStacked, Comparison: comparison, Diff: diff, DiffOpts: diffOpts, StackedDiffs: stackedDiffs, MergeCheck: mergeCheck, StackTitles: stackTitles, StackBodies: stackBodies, PrefillError: prefillErr, LabelDefs: labelDefs, LabelState: labelState, StackLabelStates: stackLabelStates, }, nil } func (s *Pulls) listBranches(ctx context.Context, repo *models.Repo) ([]types.Branch, error) { xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} 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) prefetchComparison(r *http.Request, repo *models.Repo, source pages.Source, fork, targetBranch, sourceBranch, patch string) (*types.RepoFormatPatchResponse, *types.NiceDiff, error) { var ( comparison *types.RepoFormatPatchResponse err error ) switch source { case pages.SourcePatch: if strings.TrimSpace(patch) == "" { return nil, nil, nil } if verr := validatePatch(&patch); verr != nil { return nil, nil, fmt.Errorf("invalid patch: paste a valid git diff or format-patch") } comparison = parsePastedPatch(patch) case pages.SourceBranch: if targetBranch == "" || sourceBranch == "" { return nil, nil, nil } comparison, err = s.fetchBranchComparison(r.Context(), repo, targetBranch, sourceBranch) case pages.SourceFork: if fork == "" || targetBranch == "" || sourceBranch == "" { return nil, nil, nil } comparison, err = s.fetchForkComparison(r, fork, targetBranch, sourceBranch) default: return nil, nil, nil } if err != nil { s.logger.With("handler", "prefetchComparison").Warn("failed to pre-fetch comparison", "err", err, "source", source) return nil, nil, err } return comparison, deriveDiff(comparison, targetBranch), nil } func (s *Pulls) composeMergeCheck(ctx context.Context, repo *models.Repo, targetBranch string, comparison *types.RepoFormatPatchResponse) *types.MergeCheckResponse { if comparison == nil || targetBranch == "" { return nil } patch := comparison.CombinedPatchRaw if patch == "" { patch = comparison.FormatPatchRaw } if patch == "" { return nil } xrpcc := s.knotClient(repo.Knot) resp, err := tangled.RepoMergeCheck(ctx, xrpcc, &tangled.RepoMergeCheck_Input{ Did: repo.Did, Name: repo.Name, Repo: repo.RepoDidPtr(), Branch: targetBranch, Patch: patch, }) if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { s.logger.With("handler", "composeMergeCheck").Warn("failed to check mergeability", "xrpcerr", xrpcerr, "err", err, "target_branch", targetBranch) return &types.MergeCheckResponse{Error: xrpcerr.Error()} } out := mergeCheckResponseFrom(resp) return &out } 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 } func parsePastedPatch(patch string) *types.RepoFormatPatchResponse { if patch == "" { return nil } response := &types.RepoFormatPatchResponse{FormatPatchRaw: patch} if patchutil.IsFormatPatch(patch) { if patches, err := patchutil.ExtractPatches(patch); err == nil { response.FormatPatch = patches } } return response } func (s *Pulls) fetchBranchComparison(ctx context.Context, repo *models.Repo, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { xrpcc := s.knotClient(repo.Knot) xrpcBytes, err := tangled.RepoCompare(ctx, xrpcc, repo.RepoIdentifier(), targetBranch, sourceBranch) if err != nil { return nil, err } var comparison types.RepoFormatPatchResponse if err := json.Unmarshal(xrpcBytes, &comparison); err != nil { return nil, err } return &comparison, nil } func (s *Pulls) fetchForkComparison(r *http.Request, forkRepoDid, targetBranch, sourceBranch string) (*types.RepoFormatPatchResponse, error) { if forkRepoDid == "" { return nil, fmt.Errorf("fork not found") } fork, 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 } client, err := s.oauth.ServiceClient( r, oauth.WithService(fork.Knot), oauth.WithLxm(tangled.RepoHiddenRefNSID), oauth.WithDev(s.config.Core.Dev), ) if err != nil { return nil, err } resp, err := tangled.RepoHiddenRef( r.Context(), client, &tangled.RepoHiddenRef_Input{ ForkRef: sourceBranch, RemoteRef: targetBranch, Repo: fork.RepoAt().String(), }, ) if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { return nil, xrpcerr } if !resp.Success { if resp.Error != nil { return nil, fmt.Errorf("hidden ref failed: %s", *resp.Error) } return nil, fmt.Errorf("hidden ref failed") } hiddenRef := fmt.Sprintf("hidden/%s/%s", sourceBranch, targetBranch) forkXrpcc := s.knotClient(fork.Knot) forkXrpcBytes, err := tangled.RepoCompare(r.Context(), forkXrpcc, fork.RepoIdentifier(), hiddenRef, sourceBranch) if err != nil { return nil, err } var comparison types.RepoFormatPatchResponse if err := json.Unmarshal(forkXrpcBytes, &comparison); err != nil { return nil, err } return &comparison, nil } func stackPerCommitDiffs( comparison *types.RepoFormatPatchResponse, targetBranch, refreshUrl string, stackSplits map[string]string, ) []pages.StackedDiff { if comparison == nil { return nil } out := make([]pages.StackedDiff, len(comparison.FormatPatch)) for i, p := range comparison.FormatPatch { nd := patchutil.AsNiceDiff(p.Raw, targetBranch) out[i].Diff = &nd cid := p.ChangeIdOrEmpty() if cid == "" { continue } out[i].Opts = types.DiffOpts{ Split: stackSplits[cid] == "split", RefreshUrl: refreshUrl, Target: fmt.Sprintf("#stack-diff-%s", cid), Field: fmt.Sprintf("stackSplit[%s]", cid), } } return out } func deriveDiff(comparison *types.RepoFormatPatchResponse, targetBranch string) *types.NiceDiff { if comparison == nil { return nil } raw := comparison.CombinedPatchRaw if raw == "" { raw = comparison.FormatPatchRaw } d := patchutil.AsNiceDiff(raw, targetBranch) return &d }