diff --git a/docker-compose.yml b/docker-compose.yml --- a/docker-compose.yml +++ b/docker-compose.yml @@ -348,6 +348,7 @@ TANGLED_JETSTREAM_ENDPOINT: wss://jetstream.tngl.boltless.dev/subscribe TANGLED_REDIS_ADDR: redis:6379 TANGLED_KNOTMIRROR_URL: https://mirror.tngl.boltless.dev + TANGLED_KNOTMIRROR_V2_HOST: gitmirror:9000 TANGLED_CODESEARCH_ZOEKT_URL: https://zoekt.tngl.boltless.dev TANGLED_SSH_ENABLED: "true" TANGLED_SSH_LISTEN_ADDR: "0.0.0.0:3333" diff --git a/go.mod b/go.mod --- a/go.mod +++ b/go.mod @@ -84,6 +84,7 @@ golang.org/x/sync v0.20.0 golang.org/x/sys v0.45.0 golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da + google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 ) @@ -313,7 +314,6 @@ golang.org/x/tools v0.44.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect - google.golang.org/grpc v1.80.0 // indirect gopkg.in/fsnotify.v1 v1.4.7 // indirect gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect diff --git a/appview/config/config.go b/appview/config/config.go --- a/appview/config/config.go +++ b/appview/config/config.go @@ -60,7 +60,8 @@ } type KnotMirrorConfig struct { - Url string `env:"URL, default=https://mirror.tangled.network"` + Url string `env:"URL, default=https://mirror.tangled.network"` + V2Host string `env:"V2_HOST, required"` } type JetstreamConfig struct { diff --git a/appview/models/pull.go b/appview/models/pull.go --- a/appview/models/pull.go +++ b/appview/models/pull.go @@ -87,6 +87,13 @@ Repo *Repo } +func (p *Pull) SourceRepoDid() syntax.DID { + if p.PullSource != nil && p.PullSource.RepoDid != nil { + return *p.PullSource.RepoDid + } + return p.RepoDid +} + // NOTE: This method does not include patch blob in returned atproto record func (p Pull) AsRecord() tangled.RepoPull { mentions := make([]string, len(p.Mentions)) diff --git a/appview/pages/pages.go b/appview/pages/pages.go --- a/appview/pages/pages.go +++ b/appview/pages/pages.go @@ -1547,6 +1547,51 @@ IsSubscribed *bool } +type PullPageBaseParams struct { + BaseParams + Pull *models.Pull + + Backlinks []models.RichReferenceLink + Comments []models.Comment + Commits []types.Commit // all commits between .. + + LabelDefs map[string]*models.LabelDefinition + Reactions map[syntax.ATURI]map[models.ReactionKind]models.ReactionDisplayData + UserReacted map[syntax.ATURI]map[models.ReactionKind]bool + VouchRelationships map[syntax.DID]*models.VouchRelationship + VouchSkips map[syntax.DID]bool + + // diff, branch-delete-status, merge-check, resubmit-check, pipelines will be lazy-loaded. +} + +// /pulls/123/2/1a2b3c..d4e5f6 +type PullDiffParams struct { + PullPageBaseParams + Version int + BaseCommitId string + HeadCommitId string + + ErrorMsg string +} + +// /pulls/123/1..2/abcdef +type PullInterdiffParams struct { + PullPageBaseParams + Version1 int + Version2 int + ChangeId string // optional change-id filter + + ErrorMsg string +} + +func (p *Pages) PullDiff(w io.Writer, params PullDiffParams) error { + panic("unimplemented") +} + +func (p *Pages) PullInterdiff(w io.Writer, params PullInterdiffParams) error { + panic("unimplemented") +} + func (p *Pages) RepoSinglePull(w io.Writer, params RepoSinglePullParams) error { params.Active = "pulls" return p.executeRepo("repo/pulls/pull", w, params) diff --git a/appview/pulls/pull2.go b/appview/pulls/pull2.go new file mode 100644 --- /dev/null +++ b/appview/pulls/pull2.go @@ -0,0 +1,280 @@ +package pulls + +import ( + "context" + "fmt" + "net/http" + "strconv" + + "github.com/bluesky-social/indigo/atproto/syntax" + "github.com/bluesky-social/indigo/lex/util" + indigoxrpc "github.com/bluesky-social/indigo/xrpc" + "github.com/go-chi/chi/v5" + "golang.org/x/sync/errgroup" + "tangled.org/core/api/tangled" + "tangled.org/core/appview/models" + "tangled.org/core/appview/pages" + "tangled.org/core/types" +) + +// NOTE: parsing object in middleware is bad pattern +// you will have to check if object exist in context "just in case" +// so it's better to make helper function that can read the url pattern instead. + + +// A -- B -- C +// (master) (pr/123/0) +// +// A -- B -- C +// \ (pr/123/0) +// `-- D <- B' <- C' +// (master) (pr/123/1) + +// 1. rebase B<-C to D +// 2. compare tree of C and D + +// 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", "PullRound") + ctx := r.Context() + + pull, ok := r.Context().Value("pull").(*models.Pull) + if !ok { + l.Error("failed to get pull") + s.pages.Error500(w) + return + } + + var ( + version1 = 0 + version2 = 0 + changeId = chi.URLParam(r, "*") + ) + if changeId == "all" { + changeId = "" + } + + // defer render + var params pages.PullInterdiffParams + params.Pull = pull + params.Version1 = version1 + params.Version2 = version2 + params.ChangeId = changeId + defer s.pages.PullInterdiff(w, params) + + // 1. resolve target branch -> (branch, commit) + xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} + branch, err := tangled.GitTempGetBranch(ctx, xrpcc, pull.TargetBranch, pull.RepoDid.String()) + if err != nil { + panic("unimplemented") + } + + base := branch.Hash + head1 := "" // pull.Versions[version1].Head + head2 := "" // pull.Versions[version2].Head + + // 1. log commits from base..head1 and base..head2 + var commits1, commits2 []types.Commit + g, gctx := errgroup.WithContext(ctx) + g.Go(func() error { + commits1, err = getTempListCommits(gctx, xrpcc, pull.SourceRepoDid(), base, head1) + return err + }) + g.Go(func() error { + commits2, err = getTempListCommits(gctx, xrpcc, pull.SourceRepoDid(), base, head2) + return err + }) + if err := g.Wait(); err != nil { + params.ErrorMsg = "something something" + panic("unimplemented") + } + + if changeId != "" { + // interdiff by change-id + var old, new *types.Commit + for _, commit := range commits1 { + if commit.ChangeId == changeId { + old = &commit + break + } + } + for _, commit := range commits2 { + if commit.ChangeId == changeId { + new = &commit + break + } + } + _, _ = old, new + panic("unimplemented") + } else { + // interdiff of two commit ranges + panic("unimplemented") + } +} + +// 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", "PullRound") + ctx := r.Context() + + pull, ok := r.Context().Value("pull").(*models.Pull) + if !ok { + l.Error("failed to get pull") + s.pages.Error500(w) + return + } + + var err error + + var version int + var versionRaw = chi.URLParam(r, "version") + if versionRaw == "latest" { + version = pull.LastRoundNumber() + } else { + version, err = strconv.Atoi(versionRaw) + if err != nil { + // invalid version number. redirect + http.Redirect(w, r, + fmt.Sprintf("/%s/pulls/%d/latest", pull.Repo.RepoIdentifier(), pull.ID), + http.StatusSeeOther, + ) + return + } + } + + var range_ = chi.URLParam(r, "*") + base, head, err := parseRevRange(range_) + if err != nil { + http.Redirect(w, r, + fmt.Sprintf("/%s/pulls/%d/%s", pull.Repo.RepoIdentifier(), pull.ID, versionRaw), + http.StatusSeeOther, + ) + return + } + + // defer render + var params pages.PullDiffParams + params.Pull = pull + params.Version = version + defer s.pages.PullDiff(w, params) + + // 1. resolve target branch -> (branch, commit) + xrpcc := &indigoxrpc.Client{Host: s.config.KnotMirror.Url} + branch, err := tangled.GitTempGetBranch(ctx, xrpcc, pull.TargetBranch, pull.RepoDid.String()) + if err != nil { + l.Warn("Failed to resolve target branch", "branch", pull.TargetBranch, "err", err) + params.ErrorMsg = fmt.Sprintf("Failed to resolve target branch %q", pull.TargetBranch) + return + } + + if base == "base" { + base = branch.Hash + } + if head == "head" { + head = pull.HEAD() + } + + sourceRepoDid := pull.SourceRepoDid() + + // 2. list diverged commits using knotmirror (BASE..HEAD) -> ([]commit) + // - knotmirror needs on-demand fetch implementation for this + commits, err := getTempListCommits(ctx, xrpcc, sourceRepoDid, base, head) + if err != nil { + panic("unimplemented") + } + + // 3. list every commits in UI. They will be lazy-loaded + params.Commits = commits +} + +// htmx fragment. render diff between commits +func (s *Pulls) PullDiffFragment(w http.ResponseWriter, r *http.Request) { + // var ( + // 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" + // ) + + // 1. get commit object + // 2. get diff between parent..commit (knotmirror), parse that diff + // 3. fetch each file entries (& run syntax highlight) <- skip this part for stage 1. we will do this at stage 2. + // 4. render diff +} + +// htmx fragment. render interdiff between changes +func (s *Pulls) PullInterdiffFragment(w http.ResponseWriter, r *http.Request) { + // var ( + // base1 = r.URL.Query().Get("base1") // base1 commit ID + // base2 = r.URL.Query().Get("base2") // base2 commit ID + // head1 = r.URL.Query().Get("head1") // head1 commit ID + // head2 = r.URL.Query().Get("head2") // head1 commit ID + // unified = r.URL.Query().Get("view") == "unified" + // ) + + // 1. compute interdiff. (knotmirror) + // 2. return rich diff data. (knotmirror) + // 3. load old/new blobs & run syntax highlight + // 4. render diff +} + +// parseRevRange parses .. string. +// base and head will default to "base" and "head" when omitted. +func parseRevRange(range_ string) (base string, head string, err error) { + panic("unimplemented") +} + +// parseVersionRange parses .. string. +// Each versions will default to "base" and "latest" when omitted. +func parseVersionRange(range_ string) (base string, head string, err error) { + panic("unimplemented") +} + +func getTempListCommits(ctx context.Context, xrpcc util.LexClient, repo syntax.DID, base, head string) ([]types.Commit, error) { + panic("unimplemented") + // raw, err := tangled.GitTempListCommits(ctx, xrpcc, "", 1000, head, repo.String()) + // if err != nil { + // return nil, err + // } + // + // var xrpcResp types.RepoLogResponse + // if err := json.Unmarshal(raw, &xrpcResp); err != nil { + // return nil, fmt.Errorf("failed to decode XRPC response: %w", err) + // } + // + // return xrpcResp.Commits, nil +} + +// htmx fragment. render interdiff between commits +func (s *Pulls) PullInterDiffFragment(w http.ResponseWriter, r *http.Request) { + panic("unimplemented") +} + +// gitmirror +// - git.ListCommitsSinceMergeBase(repo, base, head) +// - git.Diff(repo, base, head, mode) +// - git.Interdiff(repo, + +// for interdiff, we want: from{start,end}, to{start,end} +// 1. squash from.start ~ from.end into one commit +// 2. rebase that commit to to.start.parent() +// 3. diff from_squashed.tree and to.end.tree + +// we want git log BASE..HEAD (only commits in HEAD) diverged=false +// and git diff BASE...HEAD (changes from HEAD since merge-base) absolute=false + +// commands.go:56 picks comparison type: +// - diff BASE..HEAD (COMPARISON_TYPE_ONLY_IN_HEAD) = direct +// - diff BASE...HEAD (COMPARISON_TYPE_INTERSECTION) = merge-base. Server resolves merge-base via g.MergeBase() first (diff.go:43) then diffs. +// we want second one. we should compute merge-base first. +// we can have diff --git a/appview/pulls/pulls.go b/appview/pulls/pulls.go --- a/appview/pulls/pulls.go +++ b/appview/pulls/pulls.go @@ -19,6 +19,7 @@ "tangled.org/core/appview/oauth" "tangled.org/core/appview/pages" "tangled.org/core/appview/reporesolver" + knotmirror "tangled.org/core/gitmirror/proto/gen" "tangled.org/core/idresolver" "tangled.org/core/ogre" "tangled.org/core/patchutil" @@ -49,6 +50,7 @@ indexer *pulls_indexer.Indexer ogreClient *ogre.Client diffCache *expirable.LRU[string, types.DiffRenderer] + gitmirror knotmirror.GitMirrorServiceClient } func New( @@ -63,6 +65,7 @@ acl *knotacl.Service, indexer *pulls_indexer.Indexer, logger *slog.Logger, + gitmirror knotmirror.GitMirrorServiceClient, ) *Pulls { return &Pulls{ oauth: oauth, @@ -78,6 +81,7 @@ indexer: indexer, ogreClient: ogre.NewClient(config.Ogre.Host), diffCache: expirable.NewLRU[string, types.DiffRenderer](diffCacheSize, nil, diffCacheTTL), + gitmirror: gitmirror, } } diff --git a/appview/state/router.go b/appview/state/router.go --- a/appview/state/router.go +++ b/appview/state/router.go @@ -416,6 +416,7 @@ s.aclService, s.indexer.Pulls, log.SubLogger(s.logger, "pulls"), + s.gitmirror, ) return pulls.Router(mw) } diff --git a/appview/state/state.go b/appview/state/state.go --- a/appview/state/state.go +++ b/appview/state/state.go @@ -11,6 +11,8 @@ "strings" "time" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" "tangled.org/core/api/tangled" "tangled.org/core/appview" "tangled.org/core/appview/bsky" @@ -37,6 +39,7 @@ "tangled.org/core/appview/reporesolver" "tangled.org/core/consts" "tangled.org/core/eventconsumer" + knotmirror "tangled.org/core/gitmirror/proto/gen" "tangled.org/core/idresolver" "tangled.org/core/jetstream" "tangled.org/core/log" @@ -75,6 +78,7 @@ logger *slog.Logger cfClient *cloudflare.Client codesearch *codesearch.CodeSearch + gitmirror knotmirror.GitMirrorServiceClient } func Make(ctx context.Context, config *config.Config) (*State, error) { @@ -111,6 +115,12 @@ if err != nil { return nil, fmt.Errorf("failed to create posthog client: %w", err) } + + conn, err := grpc.NewClient(config.KnotMirror.V2Host, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, fmt.Errorf("failed to create gitmirror client: %w", err) + } + gitmirror := knotmirror.NewGitMirrorServiceClient(conn) pages := pages.NewPages(config, res, d, rdb, log.SubLogger(logger, "pages")) knotcompat.UseNativeLatch(knotacl.NewLatch(d, log.SubLogger(logger, "knotacl-latch"))) @@ -243,6 +253,7 @@ logger: logger, cfClient: cfClient, codesearch: &codesearch.CodeSearch{Host: config.CodeSearch.ZoektUrl}, + gitmirror: gitmirror, } // fetch initial bluesky posts if configured