Monorepo for Tangled
Something went wrong. Try again.
Go
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519package pulls
import ( "cmp" "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/knotcompat" "tangled.org/core/appview/models" "tangled.org/core/appview/pages" "tangled.org/core/appview/pages/markup/sanitizer" "tangled.org/core/consts" "tangled.org/core/types"
"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 } 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) MarkdownPreview(w http.ResponseWriter, r *http.Request) { body := r.FormValue("body") s.pages.MarkdownPreviewFragment(w, body)}
func (s *Pulls) PullComposeDiffFragment(w http.ResponseWriter, r *http.Request) { l := s.logger.With("handler", "PullComposeDiffFragment") ctx := r.Context()
var ( repoRaw = r.URL.Query().Get("repo") base = r.URL.Query().Get("base") // base commit ID head = r.URL.Query().Get("head") // head commit ID unified = r.URL.Query().Get("view") == "unified" ) repo, err := syntax.ParseDID(repoRaw) if err != nil { http.Error(w, "invalid repo DID", http.StatusBadRequest) return } l.Debug("compose diff fragment", "base", base, "head", head)
var params pages.PullDiffFragmentParams params.Repo = repo params.DiffBase = base params.DiffHead = head params.DiffUrl = r.URL.Path params.Unified = unified params.Files, params.ErrorMsg = s.diffFragmentParams(ctx, l, repo, base, head, unified) if err := s.pages.PullComposeDiffFragment(w, params); 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 == "" { // return params l.Error("what's wrong", "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, // Title: title, // Body: body, // StepReviewParams: &stepReviewParams, // MergeCheck: mergeCheckParams, // PrefillError: prefillErrorMsg, // LabelDefs: labelDefs, // LabelState: labelState, }, 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 := &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) composeMergeCheck(ctx context.Context, targetRepo *models.Repo, targetBranch string, sourceRepoDid syntax.DID, sourceCommit string) pages.MergeCheckParams { l := s.logger.With("handler", "composeMergeCheck", "repo", targetRepo, "branch", targetBranch, "source", sourceCommit) xrpcc := s.knotClient(targetRepo.Knot) out, err := tangled.GitMergeCheck(ctx, xrpcc, &tangled.GitMergeCheck_Input{ Repo: targetRepo.RepoDid, Branch: targetBranch, Source: &tangled.GitMergeCheck_Input_Source{ Repo: sourceRepoDid.String(), Commit: sourceCommit, }, }) if err != nil { l.Warn("failed to do merge-check", "err", err) return pages.MergeCheckParams{Error: "unimplemented"} } for _, conflict := range out.Conflicts { l.Debug("merge-check", "conflict", *conflict) } 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}