Skip to content
Draft
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/entire/cli/agent_help_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ func refreshAgentHelpTrailsEnabledCacheIfStaleForScope(ctx context.Context, scop
if !scope.Supported {
return saveTrailsEnabledForScope(ctx, scope, false, time.Now())
}
client, notOnboarded, err := trailsCellClient(ctx, false, scope.Owner+"/"+scope.Repo)
client, notOnboarded, err := trailsCellClient(ctx, false, scope.Forge, scope.Owner, scope.Repo)
if notOnboarded {
// Definitive negative: cache it for trailEnablementCacheTTL rather than
// falling into the short refresh-failure backoff, which would re-pay the
Expand Down
20 changes: 7 additions & 13 deletions cmd/entire/cli/agent_help_cmd_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,6 @@ import (

const agentHelpTestRepo = "gh/acme/app"

// testTrailCellRoutedFullName is the "owner/repo" fullName the entire-api
// cell-routed client (trailRefreshAPIClient) is expected to receive when a
// repo-scoped trails probe routes correctly through it, shared by the tests
// covering the two legacy-BFF-routing fixes.
const testTrailCellRoutedFullName = "acme/widget"

// commandNames returns the Use-name of each command, for assertions.
func commandNames(cmds []*cobra.Command) []string {
names := make([]string, 0, len(cmds))
Expand Down Expand Up @@ -230,10 +224,10 @@ func TestRefreshAgentHelpTrailsEnabledCacheIfStaleForScope_RoutesThroughRepoCell
t.Chdir(t.TempDir())

previous := trailRefreshAPIClient
var gotFullName string
var gotForge, gotOwner, gotRepo string
wantErr := errors.New("cell client unavailable")
trailRefreshAPIClient = func(_ context.Context, _ bool, fullName string) (*api.Client, error) {
gotFullName = fullName
trailRefreshAPIClient = func(_ context.Context, _ bool, forge, owner, repo string) (*api.Client, error) {
gotForge, gotOwner, gotRepo = forge, owner, repo
return nil, wantErr
}
t.Cleanup(func() { trailRefreshAPIClient = previous })
Expand All @@ -242,16 +236,16 @@ func TestRefreshAgentHelpTrailsEnabledCacheIfStaleForScope_RoutesThroughRepoCell
Forge: "gh",
Owner: "acme",
Repo: "widget",
RepoKey: trailEnablementRepoKey("gh", "acme", "widget"),
APIBase: api.BaseURL(),
AuthKey: "test-auth-key",
Supported: true,
}
scope.RepoKey = trailEnablementRepoKey(scope.Forge, scope.Owner, scope.Repo)

err := refreshAgentHelpTrailsEnabledCacheIfStaleForScope(t.Context(), scope)

if gotFullName != testTrailCellRoutedFullName {
t.Fatalf("trailRefreshAPIClient fullName = %q, want %s", gotFullName, testTrailCellRoutedFullName)
if gotForge != scope.Forge || gotOwner != scope.Owner || gotRepo != scope.Repo {
t.Fatalf("trailRefreshAPIClient repo = (%q,%q,%q), want (gh,acme,widget)", gotForge, gotOwner, gotRepo)
}
if !errors.Is(err, wantErr) {
t.Fatalf("err = %v, want %v", err, wantErr)
Expand All @@ -272,7 +266,7 @@ func TestRefreshAgentHelpTrailsEnabledCacheIfStaleForScope_NotOnboardedSavesDisa
runGitInDir(t, ".", "remote", "add", "origin", "https://github.com/acme/widget.git")

previous := trailRefreshAPIClient
trailRefreshAPIClient = func(context.Context, bool, string) (*api.Client, error) {
trailRefreshAPIClient = func(context.Context, bool, string, string, string) (*api.Client, error) {
return nil, fmt.Errorf("resolve the Entire cell for acme/widget: %w", errRepoNotOnboarded)
}
t.Cleanup(func() { trailRefreshAPIClient = previous })
Expand Down
17 changes: 11 additions & 6 deletions cmd/entire/cli/api_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -74,17 +74,22 @@ func NewAuthenticatedEntireAPICellClient(ctx context.Context, insecureHTTP bool,
return auth.NewEntireAPICellClient(ctx, insecureHTTP, target) //nolint:wrapcheck // pass through contextual auth errors
}

// newTrailAPIClient dials the entire-api cell that owns the repository. It is a
// newTrailAPIClient dials the entire-api cell that owns the forge-qualified
// repository and returns its repo_id for repo-addressed trail reads. It is a
// package seam so tests can substitute a client pointed at a stub server.
var newTrailAPIClient = func(ctx context.Context, insecureHTTP bool, fullName string) (*api.Client, error) {
client, err := NewAuthenticatedEntireAPICellClient(ctx, insecureHTTP, fullName, "")
var newTrailAPIClient = func(ctx context.Context, insecureHTTP bool, forge, owner, repo string) (*api.Client, string, error) {
placement, err := resolveForgeRepoCellPlacement(ctx, forge, owner, repo)
if err != nil {
return nil, "", err
}
client, err := auth.NewEntireAPICellClient(ctx, insecureHTTP, placement.Target)
if errors.Is(err, clusterdiscovery.ErrNoAuthContext) {
// Preserve cluster discovery's detailed host/context hint while restoring
// the sentinel trail commands use for the standard login UX.
return nil, fmt.Errorf("%w: %w", auth.ErrNotLoggedIn, err)
return nil, "", fmt.Errorf("%w: %w", auth.ErrNotLoggedIn, err)
}
if err != nil {
return nil, err
return nil, "", err //nolint:wrapcheck // auth client returns contextual, user-facing errors
}
return client, nil
return client, placement.RepoID, nil
}
85 changes: 73 additions & 12 deletions cmd/entire/cli/cell_target.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,10 +42,21 @@ type cellCoreClient interface {
ListRepos(ctx context.Context, params coreapi.ListReposParams) (*coreapi.ListReposOutputBody, error)
}

type nativeRepoCellCoreClient interface {
nativeRepoResolverClient
ListClusters(ctx context.Context) (*coreapi.ListClustersOutputBody, error)
ListRepos(ctx context.Context, params coreapi.ListReposParams) (*coreapi.ListReposOutputBody, error)
}

// newCellCoreClient builds the control-plane client used for cell resolution.
// Swapped in tests.
var newCellCoreClient = func() (cellCoreClient, error) { return coreapi.New() }

// newNativeRepoCellCoreClient adds the project-scoped name lookup surface
// needed for /et/ repository identities. Kept separate from cellCoreClient so
// cached GitHub index clients do not need unrelated native lookup methods.
var newNativeRepoCellCoreClient = func() (nativeRepoCellCoreClient, error) { return coreapi.New() }

// resolveRepoCellTarget resolves the entire-api cell that HOSTS the given
// repo, plus that cell's jurisdiction, so a repo-scoped call (trails, experts)
// reaches the region that owns the repo — mirroring how the entire.io BFF
Expand Down Expand Up @@ -91,17 +102,25 @@ func resolveRepoCellTarget(ctx context.Context, fullName, ulid string) (*auth.Ce
return target, nil
}

owner, repo, ok := strings.Cut(strings.TrimSpace(fullName), "/")
if !ok || owner == "" || repo == "" {
return nil, fmt.Errorf("invalid repo %q: expected owner/repo", fullName)
}
placement, err := resolveRepoCellPlacement(ctx, owner, repo)
placement, err := resolveRepoCellPlacementByFullName(ctx, fullName)
if err != nil {
return nil, err
}
return placement.Target, nil
}

// resolveRepoCellPlacementByFullName parses an owner/repo name and resolves
// the processing placement. Keeping this boundary shared ensures callers that
// need both the cell and repo ID make exactly the same placement choice as
// callers that need only the cell.
func resolveRepoCellPlacementByFullName(ctx context.Context, fullName string) (repoCellPlacement, error) {
owner, repo, ok := strings.Cut(strings.TrimSpace(fullName), "/")
if !ok || owner == "" || repo == "" {
return repoCellPlacement{}, fmt.Errorf("invalid repo %q: expected owner/repo", fullName)
}
return resolveRepoCellPlacement(ctx, owner, repo)
}

// cellTargetForClusterHost maps a known cluster host to a cell apiUrl +
// jurisdiction via the coreapi cluster catalog, the authoritative source for a
// jurisdiction's cell URL. Used by the ULID path only: a GetRepo response
Expand Down Expand Up @@ -260,18 +279,60 @@ func lookupRepoIndexEntry(ctx context.Context, c cellCoreClient, fullName string
return entry, nil
}

// repoCellPlacement is a repo's processing placement: the id entire-api keys
// repo-scoped routes on, paired with the cell that actually holds it.
// repoCellPlacement is the identity entire-api keys repo-scoped routes on,
// paired with the cell that actually holds it. For a GitHub mirror RepoID is
// the processing placement ID; for an Entire-native repo it is core Repo.ID.
type repoCellPlacement struct {
// RepoID is the placement id (coreapi.RepoPlacement.ID), which entire-api
// uses as its repo_id — verified identical to the id the old
// mirrors-based lookup used (coreapi.Mirror.MirrorId) for the same
// placement.
// RepoID is the value entire-api uses as repo_id. For GitHub mirrors this is
// coreapi.RepoPlacement.ID (verified identical to the legacy
// coreapi.Mirror.MirrorId); for native repos it is coreapi.Repo.ID.
RepoID string
// Target is the cell hosting THIS placement.
// Target is the cell hosting this repo identity.
Target *auth.CellTarget
}

// resolveForgeRepoCellPlacement keeps the forge namespace in repository
// identity resolution. A native /et/<project>/<repo> and a legacy
// /gh/<owner>/<repo> can have the same two trailing path segments but are
// different repositories with different repo IDs and, potentially, cells.
func resolveForgeRepoCellPlacement(ctx context.Context, forge, owner, repo string) (repoCellPlacement, error) {
if forge == nativeCloneForge {
return resolveNativeRepoCellPlacement(ctx, owner, repo)
}
return resolveRepoCellPlacement(ctx, owner, repo)
}

// resolveNativeRepoCellPlacement resolves /et/<project>/<repo> through the
// native project-scoped repo lookup, then maps the repo's home cluster to its
// entire-api cell. It deliberately never consults the forge-blind repos index:
// that index can select a same-named /gh/ mirror instead.
func resolveNativeRepoCellPlacement(ctx context.Context, project, repoName string) (repoCellPlacement, error) {
ctx, cancel := context.WithTimeout(ctx, requiredCellResolveTimeout)
defer cancel()

c, err := newNativeRepoCellCoreClient()
if err != nil {
return repoCellPlacement{}, fmt.Errorf("control plane unavailable: %w", err)
}
repo, err := resolveNativeRepo(ctx, c, project, repoName)
if err != nil {
return repoCellPlacement{}, cellPlacementError(ctx, project+"/"+repoName, fmt.Errorf("resolve native repo %s/%s: %w", project, repoName, err))
}
repoID := strings.TrimSpace(repo.ID)
if repoID == "" {
return repoCellPlacement{}, fmt.Errorf("resolve native repo %s/%s: repo has no id", project, repoName)
}
clusterHost := strings.TrimSpace(repo.ClusterHost.Or(""))
if clusterHost == "" {
return repoCellPlacement{}, fmt.Errorf("resolve the Entire cell for %s/%s: repo has no cluster host", project, repoName)
}
target, err := cellTargetForClusterHost(ctx, c, clusterHost)
if err != nil {
return repoCellPlacement{}, cellPlacementError(ctx, repoID, fmt.Errorf("resolve the Entire cell for %s/%s: %w", project, repoName, err))
}
return repoCellPlacement{RepoID: repoID, Target: target}, nil
}

// resolveRepoCellPlacement resolves a GitHub repo to its processing
// placement's (repo_id, cell) pair for repo-scoped cell reads.
//
Expand Down
85 changes: 78 additions & 7 deletions cmd/entire/cli/cell_target_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,12 +79,16 @@ func TestMatchClusterBySlug(t *testing.T) {
// fakeCellCore is a stub control plane for resolveRepoCellTarget /
// resolveRepoCellPlacement tests.
type fakeCellCore struct {
repo *coreapi.Repo
repoErr error
clusters []coreapi.Cluster
clustersErr error
repos *coreapi.ListReposOutputBody
reposErr error
repo *coreapi.Repo
repoErr error
projects *coreapi.ListProjectsOutputBody
projectsErr error
projectRepos *coreapi.ListProjectReposOutputBody
projectErr error
clusters []coreapi.Cluster
clustersErr error
repos *coreapi.ListReposOutputBody
reposErr error
// blockUntilCtxDone makes ListRepos and GetRepo hang until the caller's
// deadline fires, standing in for a reachable-but-slow control plane —
// both, so the owner/repo and ULID paths can each be tested. Off by
Expand Down Expand Up @@ -114,6 +118,32 @@ func (f *fakeCellCore) GetRepo(ctx context.Context, _ coreapi.GetRepoParams) (*c
return f.repo, f.repoErr
}

func (f *fakeCellCore) ListProjects(ctx context.Context, _ coreapi.ListProjectsParams) (*coreapi.ListProjectsOutputBody, error) {
if err := f.waitIfBlocking(ctx); err != nil {
return nil, err
}
if f.projectsErr != nil {
return nil, f.projectsErr
}
if f.projects != nil {
return f.projects, nil
}
return &coreapi.ListProjectsOutputBody{}, nil
}

func (f *fakeCellCore) ListProjectRepos(ctx context.Context, _ coreapi.ListProjectReposParams) (*coreapi.ListProjectReposOutputBody, error) {
if err := f.waitIfBlocking(ctx); err != nil {
return nil, err
}
if f.projectErr != nil {
return nil, f.projectErr
}
if f.projectRepos != nil {
return f.projectRepos, nil
}
return &coreapi.ListProjectReposOutputBody{}, nil
}

func (f *fakeCellCore) ListClusters(context.Context) (*coreapi.ListClustersOutputBody, error) {
if f.clustersErr != nil {
return nil, f.clustersErr
Expand All @@ -140,8 +170,49 @@ func (f *fakeCellCore) ListRepos(ctx context.Context, params coreapi.ListReposPa
func withFakeCellCore(t *testing.T, f *fakeCellCore) {
t.Helper()
prev := newCellCoreClient
prevNative := newNativeRepoCellCoreClient
newCellCoreClient = func() (cellCoreClient, error) { return f, nil }
t.Cleanup(func() { newCellCoreClient = prev })
newNativeRepoCellCoreClient = func() (nativeRepoCellCoreClient, error) { return f, nil }
t.Cleanup(func() {
newCellCoreClient = prev
newNativeRepoCellCoreClient = prevNative
})
}

func TestResolveForgeRepoCellPlacement_NativeDoesNotSelectSameNamedGitHubMirror(t *testing.T) {
const (
projectID = "01NATIVEPROJECT00000000000"
nativeID = "01NATIVEREPOSITORY00000000"
legacyGHID = "01LEGACYGHMIRROR000000000"
)
withFakeCellCore(t, &fakeCellCore{
projects: &coreapi.ListProjectsOutputBody{Project: coreapi.NewOptProject(coreapi.Project{
ID: projectID, Name: "entirehq",
})},
projectRepos: &coreapi.ListProjectReposOutputBody{Repo: coreapi.NewOptRepo(coreapi.Repo{
ID: nativeID, Name: "marvin", OwningProjectId: projectID,
})},
repo: &coreapi.Repo{
ID: nativeID, Name: "marvin", OwningProjectId: projectID,
ClusterHost: coreapi.NewOptString("eu.entire.io"),
},
clusters: euClusters(),
// This is the forge-blind match the old implementation selected. The
// native resolver must never consult it.
repos: reposOutput(repoIndexFixture("entirehq/marvin", legacyGHID,
placementFixture{id: legacyGHID, slug: usClusterSlug})),
})

got, err := resolveForgeRepoCellPlacement(t.Context(), nativeCloneForge, "entirehq", "marvin")
if err != nil {
t.Fatal(err)
}
if got.RepoID != nativeID {
t.Fatalf("RepoID = %q, want native repo ID %q (not legacy mirror %q)", got.RepoID, nativeID, legacyGHID)
}
if got.Target.BaseURL != euCellAPIURL || got.Target.Jurisdiction != "eu" {
t.Fatalf("Target = %+v, want EU native repo cell", got.Target)
}
}

// euClusters is keyed by PublicUrl host, the join the ULID path uses
Expand Down
3 changes: 2 additions & 1 deletion cmd/entire/cli/explain.go
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,7 @@ Viewing specific items:

Checkpoints in another repo:
entire checkpoint explain <id> --repo owner/name
entire checkpoint explain <id> --repo et/project/repo
Explain a checkpoint owned by another repository — the
drill-down for a cross-repo 'entire search' hit. Reads it from
that repo's Entire API; nothing is written to this repo.
Expand Down Expand Up @@ -409,7 +410,7 @@ Note: --session filters the list view; the positional arg, --commit, and --check
cmd.Flags().BoolVar(&transcriptFlag, "transcript", false, "Stream stored checkpoint transcript bytes to stdout")
cmd.Flags().IntVar(&sessionIndex, "session-index", -1, "Session index within a multi-session checkpoint (0-based, defaults to latest)")
cmd.Flags().IntVar(&listLimit, "limit", 0, "Cap the list view at N checkpoints (default: 100). Only meaningful with --json.")
cmd.Flags().StringVar(&repoFlag, "repo", "", "Explain a checkpoint owned by another repo (owner/name or gh/owner/name), read from that repo's Entire API")
cmd.Flags().StringVar(&repoFlag, "repo", "", "Explain a checkpoint owned by another repo ("+explainRepoFlagShapes+"), read from that repo's Entire API")
cmd.Flags().BoolVar(&insecureHTTPFlag, "insecure-http-auth", false, "Allow plain-HTTP auth for --repo (local dev only)")
cmd.Flags().IntVar(&summaryTimeoutSecondsFlag, "summary-timeout-seconds", 0, "Hard deadline in seconds for --generate summary generation; overrides summary_timeout_seconds setting. 0 = use setting; if setting is also unset or 0, no automatic deadline applies.")

Expand Down
Loading
Loading