package pulls import ( "cmp" "context" "errors" "fmt" "io" "log/slog" "net/http" "strconv" "strings" "github.com/bluesky-social/indigo/atproto/syntax" indigoxrpc "github.com/bluesky-social/indigo/xrpc" "github.com/go-chi/chi/v5" "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "golang.org/x/sync/errgroup" "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" gitmirrorv1 "tangled.org/core/gitmirror/proto/gen" "tangled.org/core/hostutil" "tangled.org/core/orm" "tangled.org/core/types" ) func (s *Pulls) RedirectLatestVersion(w http.ResponseWriter, r *http.Request) { pull, ok := r.Context().Value("pull").(*models.Pull) if !ok { s.logger.Error("failed to get pull") s.pages.Error500(w) return } u := r.URL.JoinPath(strconv.Itoa(pull.LatestVersionNumber())) http.Redirect(w, r, u.String(), http.StatusFound) } func (s *Pulls) PullSingle(w http.ResponseWriter, r *http.Request) { if strings.Contains(chi.URLParam(r, "version"), "..") { s.PullInterDiff(w, r) } else { s.PullDiff(w, r) } } // PullDiff is router for /pulls/{pull}/{version}/{commit}..{commit} // // Examples: // - /pulls/123/latest // - /pulls/123/2/head // - /pulls/123/2/base..head // - /pulls/123/2/a53ab251e..d8add468c // - /pulls/123/2/d8add468c func (s *Pulls) PullDiff(w http.ResponseWriter, r *http.Request) { l := s.logger.With("handler", "PullDiff") ctx := r.Context() user := s.oauth.GetMultiAccountUser(r) if user != nil { l = l.With("user", user.Did) } pull, ok := r.Context().Value("pull").(*models.Pull) if !ok { l.Error("failed to get pull") http.Error(w, "failed to get PR", http.StatusInternalServerError) return } var version models.PullVersion var versionIdRaw = chi.URLParam(r, "version") if versionIdRaw == "latest" { version = pull.LatestVersion() } else { versionId, err := strconv.Atoi(versionIdRaw) if err != nil { // invalid version number. redirect http.Redirect(w, r, fmt.Sprintf("/%s/pulls/%d/latest", pull.RepoDid, pull.PullId), http.StatusSeeOther, ) return } var ok bool version, ok = pull.GetVersion(versionId) if !ok { // invalid version number. redirect http.Redirect(w, r, fmt.Sprintf("/%s/pulls/%d/latest", pull.RepoDid, pull.PullId), http.StatusSeeOther, ) return } } diffBase, diffHead, err := parseRange(chi.URLParam(r, "revspec")) if err != nil { http.Redirect(w, r, fmt.Sprintf("/%s/pulls/%d/%s", pull.RepoDid, pull.PullId, versionIdRaw), http.StatusSeeOther, ) return } // defer render var params pages.PullDiffParams params.PullPageBaseParams = s.makePullPageBaseParams(r, user, pull) params.VersionId = version.ID defer func() { if err := s.pages.PullDiff(w, params); err != nil { l.Error("Failed to render", "err", err) } }() // special cases // default to {current.base}..{current.head} if diffBase == "" || diffBase == "base" { params.IsDiffBase = true // NOTE: We fallback to target-branch for legacy reason. // Old PRs before ref-based-PR refactor doesn't have `version.base`. diffBase = cmp.Or(version.Base, pull.TargetBranch) } if diffHead == "" || diffHead == "head" { params.IsDiffHead = true diffHead = version.Head } params.DiffParams.Base = diffBase params.DiffParams.Head = diffHead commits, err := s.listCommits(ctx, pull.SourceRepo, version.Base, version.Head) if err != nil { l.Error("failed to list commits", "err", err) params.ErrorMsg = "Failed to list commits. Try again later." return } params.Commits = commits // commitId -> latest pipeline shas := make([]string, len(params.Commits)) for i, commit := range params.Commits { shas[i] = commit.Hash.String() } params.Pipelines = fetchPipelines(ctx, l, pull.Repo, shas) } // PullInterDiff is router for /pulls/{pull}/{version}..{version}/{change} // // Examples: // - /pulls/123/0..2/all // - /pulls/123/0..2/nrpytyzw func (s *Pulls) PullInterDiff(w http.ResponseWriter, r *http.Request) { l := s.logger.With("handler", "PullInterDiff") ctx := r.Context() user := s.oauth.GetMultiAccountUser(r) if user != nil { l = l.With("user", user.Did) } pull, ok := r.Context().Value("pull").(*models.Pull) if !ok { s.logger.Error("failed to get pull") s.pages.Error500(w) return } version1Raw, version2Raw, err := parseRange(chi.URLParam(r, "version")) if err != nil { http.Redirect(w, r, fmt.Sprintf("/%s/pulls/%d/0", pull.RepoDid, pull.PullId), http.StatusSeeOther, ) return } version1, err := strconv.Atoi(version1Raw) version2, err := strconv.Atoi(version2Raw) if err != nil { http.Redirect(w, r, fmt.Sprintf("/%s/pulls/%d/latest", pull.RepoDid, pull.PullId), http.StatusSeeOther, ) return } changeId := chi.URLParam(r, "revspec") if changeId == "all" { changeId = "" } // defer render var params pages.PullInterdiffParams params.PullPageBaseParams = s.makePullPageBaseParams(r, user, pull) params.Version1 = version1 params.Version2 = version2 params.ChangeId = changeId defer func() { if err := s.pages.PullInterdiff(w, params); err != nil { l.Error("Failed to render", "err", err) } }() var commits1, commits2 []types.Commit g, gctx := errgroup.WithContext(ctx) if changeId != "" { g.Go(func() error { commits1, err = s.listCommits(gctx, pull.SourceRepo, pull.Versions[version1].Base, pull.Versions[version1].Head) return err }) } g.Go(func() error { commits2, err = s.listCommits(gctx, pull.SourceRepo, pull.Versions[version2].Base, pull.Versions[version2].Head) return err }) if err := g.Wait(); err != nil { l.Error("failed to list commits", "err", err) params.ErrorMsg = "Failed to list commits. Try again later." return } params.Commits = commits2 // commitId -> latest pipeline shas := make([]string, len(params.Commits)) for i, commit := range params.Commits { shas[i] = commit.Hash.String() } params.Pipelines = fetchPipelines(ctx, l, pull.Repo, shas) if changeId != "" { // interdiff by change-id var from, to *types.Commit for _, commit := range commits1 { if commit.ChangeId == changeId { from = &commit break } } for _, commit := range commits2 { if commit.ChangeId == changeId { to = &commit break } } l.Debug("commits", "old", from, "new", to) switch { case to == nil: // can't find change-id from v2 branch. // NOTE: This can't happen because user selected from v2's commits params.ErrorMsg = "Can't find commit with given change-id." case from == nil: // new commit -> diff .. params.ActiveCommitId = to.Hash.String() params.DiffParams.Diff = &pages.DiffParams_Diff{ Base: to.FirstParentHash().String(), Head: to.Hash.String(), } default: // interdiff params.ActiveCommitId = to.Hash.String() // TODO: use merged tree of all parents params.DiffParams.Interdiff = &pages.DiffParams_Interdiff{ From: pages.DiffParams_Diff{ Base: from.FirstParentHash().String(), Head: from.Hash.String(), }, To: pages.DiffParams_Diff{ Base: to.FirstParentHash().String(), Head: to.Hash.String(), }, } } } else { // interdiff of two versions params.DiffParams.Interdiff = &pages.DiffParams_Interdiff{ From: pages.DiffParams_Diff{ Base: pull.Versions[version1].Base, Head: pull.Versions[version1].Head, }, To: pages.DiffParams_Diff{ Base: pull.Versions[version2].Base, Head: pull.Versions[version2].Head, }, } // TODO: if any of them is "", show error message } } func (s *Pulls) PullPatchRaw(w http.ResponseWriter, r *http.Request) { l := s.logger.With("handler", "RepoPullPatchRaw") pull, ok := r.Context().Value("pull").(*models.Pull) if !ok { l.Error("failed to get pull") s.pages.Notice(w, "pull-error", "Failed to edit patch. Try again later.") return } l = l.With("pull_id", pull.PullId) var version models.PullVersion var versionIdRaw = chi.URLParam(r, "version") if versionIdRaw == "latest" { version = pull.LatestVersion() } else { versionId, err := strconv.Atoi(versionIdRaw) if err != nil { http.Error(w, "bad version id", http.StatusBadRequest) return } var ok bool version, ok = pull.GetVersion(versionId) if !ok { http.Error(w, "unknown version", http.StatusNotFound) return } } xrpcc := s.knotMirrorXRPC rawOut, err := tangled.GitTempFormatPatch(r.Context(), xrpcc, version.Base, pull.RepoDid.String(), version.Head) if err != nil { http.Error(w, "Failed to compute patch", http.StatusInternalServerError) return } w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Write(rawOut) } func (s *Pulls) makePullPageBaseParams(r *http.Request, user *oauth.MultiAccountUser, pull *models.Pull) pages.PullPageBaseParams { l := s.logger ctx := r.Context() entities := []syntax.ATURI{pull.AtUri()} for _, v := range pull.Versions { for _, c := range v.Comments { entities = append(entities, c.FeedCommentAtUri()) } } reactions, err := db.ListReactionDisplayDataMap(s.db, entities, 20) if err != nil { l.Error("failed to get reactions", "err", err) } var userReactions map[syntax.ATURI]map[models.ReactionKind]bool if user != nil { userReactions, err = db.ListReactionStatusMap(s.db, entities, syntax.DID(user.Did)) if err != nil { s.logger.Error("failed to get user reactions", "err", err) } } labelDefs, err := db.GetLabelDefinitions( s.db, orm.FilterIn("at_uri", pull.Repo.Labels), orm.FilterContains("scope", tangled.RepoPullNSID), ) if err != nil { l.Error("failed to fetch labels", "err", err) } defs := make(map[string]*models.LabelDefinition) for _, l := range labelDefs { defs[l.AtUri().String()] = &l } vouchRelationships := make(map[syntax.DID]*models.VouchRelationship) vouchSkips := make(map[syntax.DID]bool) if user != nil { participants := pull.Participants() vouchRelationships, err = db.GetVouchRelationshipsBatch(s.db, syntax.DID(user.Did), participants) if err != nil { l.Error("failed to fetch vouch relationships", "err", err) } ownerDid := syntax.DID(pull.OwnerDid) skipped, err := db.IsVouchSkipped(s.db, user.Did, pull.OwnerDid.String()) if err != nil { l.Error("failed to check vouch skip", "err", err) } vouchSkips[ownerDid] = skipped } var isSubscribed *bool if user != nil { pullDbId := int64(pull.ID) sub, found, err2 := db.GetPullSubscription(s.db, user.Did, pullDbId) if err2 == nil { if found { isSubscribed = &sub } else { // Implicitly subscribed if author or participant. isAuthorOrParticipant := pull.OwnerDid == syntax.DID(user.Did) if !isAuthorOrParticipant { for _, p := range pull.Participants() { if p.String() == user.Did { isAuthorOrParticipant = true break } } } if isAuthorOrParticipant { t := true isSubscribed = &t } } } } params := pages.PullPageBaseParams{} params.BaseParams = pages.BaseParamsFromContext(ctx) params.RepoInfo = s.repoResolver.GetRepoInfo(r, user) params.Pull = pull params.Backlinks = nil params.LabelDefs = defs params.Reactions = reactions params.UserReacted = userReactions params.VouchRelationships = vouchRelationships params.VouchSkips = vouchSkips params.IsSubscribed = isSubscribed return params } func (s *Pulls) listCommits(ctx context.Context, repo syntax.DID, base, head string) ([]types.Commit, error) { s.logger.Debug("logging commits", "repo", repo, "base", base, "head", head) stream, err := s.gitmirror.CommitLog(ctx, &gitmirrorv1.CommitLogRequest{ Repo: repo.String(), Ranges: [][]byte{fmt.Appendf(nil, "%s..%s", base, head)}, AllRefs: false, }) if err != nil { return nil, err } var commits []types.Commit for { res, err := stream.Recv() if errors.Is(err, io.EOF) { break } if err != nil { return nil, err } for _, commit := range res.Commits { commits = append(commits, types.Commit{ Hash: plumbing.NewHash(commit.Oid), Author: object.Signature{ Name: string(commit.Author.GetName()), Email: string(commit.Author.GetEmail()), When: commit.Author.Date.AsTime(), }, Committer: object.Signature{ Name: string(commit.Committer.GetName()), Email: string(commit.Committer.GetEmail()), When: commit.Committer.Date.AsTime(), }, Message: string(commit.Message), ParentHashes: func() []plumbing.Hash { var parents []plumbing.Hash for _, hash := range commit.Parents { parents = append(parents, plumbing.NewHash(hash)) } return parents }(), ChangeId: commit.ExtraHeaders["change-id"], }) } } return commits, err } // SubscribePull handles subscribe/unsubscribe for a specific pull request. func (s *Pulls) SubscribePull(w http.ResponseWriter, r *http.Request) { l := s.logger.With("handler", "SubscribePull") user := s.oauth.GetMultiAccountUser(r) if user == nil { w.WriteHeader(http.StatusUnauthorized) return } pull, ok := r.Context().Value("pull").(*models.Pull) if !ok { l.Error("failed to get pull from context") w.WriteHeader(http.StatusNotFound) return } subscribe := r.FormValue("subscribe") != "false" pullDbId := int64(pull.ID) if err := db.UpsertPullSubscription(s.db, user.Did, pullDbId, subscribe); err != nil { l.Error("failed to update pull subscription", "err", err) w.WriteHeader(http.StatusInternalServerError) return } repoInfo := s.repoResolver.GetRepoInfo(r, user) s.pages.PullSubscribeFragment(w, pages.PullSubscribeParams{ RepoInfo: repoInfo, PullId: pull.PullId, IsSubscribed: &subscribe, }) } func (s *Pulls) fetchPipelines(ctx context.Context, spindle string, repoDid string, shas []string) (map[string]types.Pipeline, error) { if spindle == "" || len(shas) == 0 { return nil, nil } spindleUrl, err := hostutil.EnsureHttpScheme(spindle) if err != nil { return nil, err } xrpcc := &indigoxrpc.Client{Host: spindleUrl} out, err := tangled.CiQueryPipelines(ctx, xrpcc, shas, "", nil, 0, repoDid) if err != nil { return nil, err } return types.PipelinesByCommit(out.Pipelines), nil } func fetchPipelines(ctx context.Context, l *slog.Logger, f *models.Repo, shas []string) map[string]types.Pipeline { m := make(map[string]types.Pipeline) if f.Spindle == "" || len(shas) == 0 { return m } spindleUrl, err := hostutil.EnsureHttpScheme(f.Spindle) if err != nil { l.Error("invalid spindle host", "host", f.Spindle, "err", err) return m } xrpcc := &indigoxrpc.Client{Host: spindleUrl} out, err := tangled.CiQueryPipelines(ctx, xrpcc, shas, "", nil, 0, f.RepoDid) if err != nil { l.Error("failed to fetch pipelines", "err", err) return m } for _, pipeline := range out.Pipelines { if pipeline == nil { continue } m[pipeline.Commit] = types.Pipeline{CiPipeline: pipeline} } return m } // parseRange parses .. string func parseRange(input string) (base string, head string, err error) { input = strings.TrimSpace(input) if input == "" { return "", "", nil } if strings.Count(input, "..") > 1 || strings.Contains(input, "...") { return "", "", fmt.Errorf("invalid revspec format: %q", input) } // /{head} if !strings.Contains(input, "..") { return "", input, nil } // /{base}..{head} parts := strings.SplitN(input, "..", 2) base = strings.TrimSpace(parts[0]) head = strings.TrimSpace(parts[1]) if base == "" && head == "" { return "", "", fmt.Errorf("invalid empty range: \"..\"") } return base, head, nil }