diff --git a/internal/controlplane/handlers_repositories.go b/internal/controlplane/handlers_repositories.go index 5fd823e857..448e11c6e0 100644 --- a/internal/controlplane/handlers_repositories.go +++ b/internal/controlplane/handlers_repositories.go @@ -135,9 +135,11 @@ func (s *Server) repoCreateInfoFromUpstreamEntityRef( // ListRepositories returns a list of repositories for a given project // This function will typically be called by the client to get a list of -// repositories that are registered present in the minder database +// repositories that are registered present in the minder database. +// Pagination is cursor-based: pass an empty cursor for the first page, +// then use the returned cursor for subsequent pages. func (s *Server) ListRepositories(ctx context.Context, - _ *pb.ListRepositoriesRequest) (*pb.ListRepositoriesResponse, error) { + in *pb.ListRepositoriesRequest) (*pb.ListRepositoriesResponse, error) { entityCtx := engcontext.EntityFromContext(ctx) projectID := entityCtx.Project.ID providerName := entityCtx.Provider.Name @@ -154,8 +156,24 @@ func (s *Server) ListRepositories(ctx context.Context, return nil, status.Errorf(codes.Internal, "cannot get provider: %v", err) } - // Fetch repositories using the service - repoEntities, err := s.repos.ListRepositories(ctx, projectID, provider.ID) + // Parse cursor from request (empty string means first page) + var cursor uuid.UUID + if in.GetCursor() != "" { + cursor, err = uuid.Parse(in.GetCursor()) + if err != nil { + return nil, util.UserVisibleError(codes.InvalidArgument, "invalid cursor format") + } + } + + // Use request limit if provided, otherwise default + limit := in.GetLimit() + if limit <= 0 { + limit = 100 + } + + // Fetch repositories using the paginated service method + repoEntities, nextCursor, err := s.repos.ListRepositoriesPaginated( + ctx, projectID, provider.ID, cursor, limit) if err != nil { return nil, err } @@ -191,11 +209,10 @@ func (s *Server) ListRepositories(ctx context.Context, results = append(results, pbRepo) } - // TODO: Implement cursor-based pagination using entity IDs - // For now, return all results without pagination - resp.Results = results - resp.Cursor = "" + if nextCursor != uuid.Nil { + resp.Cursor = nextCursor.String() + } return &resp, nil } diff --git a/internal/controlplane/handlers_repositories_test.go b/internal/controlplane/handlers_repositories_test.go index ce7f31e8ae..2feac499b4 100644 --- a/internal/controlplane/handlers_repositories_test.go +++ b/internal/controlplane/handlers_repositories_test.go @@ -175,9 +175,11 @@ func TestServer_ListRepositories(t *testing.T) { { Name: "List repositories succeeds with multiple results", RepoServiceSetup: rf.NewRepoService( - rf.WithSuccessfulListRepositories( - simpleDbRepository(repoName, remoteRepoId), - simpleDbRepository(repoName2, remoteRepoId2), + rf.WithSuccessfulListRepositoriesPaginated( + []*models.EntityWithProperties{ + simpleDbRepository(repoName, remoteRepoId), + simpleDbRepository(repoName2, remoteRepoId2), + }, ), ), ProviderSetup: func(ctrl *gomock.Controller, mgr *mockmanager.MockProviderManager) { @@ -246,14 +248,14 @@ func TestServer_ListRepositories(t *testing.T) { { Name: "List repositories succeeds with empty results", RepoServiceSetup: rf.NewRepoService( - rf.WithSuccessfulListRepositories(), + rf.WithSuccessfulListRepositoriesPaginated(nil), ), ExpectedResults: nil, }, { Name: "List repositories fails when repo service returns error", RepoServiceSetup: rf.NewRepoService( - rf.WithFailedListRepositories(errDefault), + rf.WithFailedListRepositoriesPaginated(errDefault), ), ExpectedError: errDefault.Error(), }, diff --git a/internal/repositories/mock/fixtures/service.go b/internal/repositories/mock/fixtures/service.go index ad34557208..134eaa8231 100644 --- a/internal/repositories/mock/fixtures/service.go +++ b/internal/repositories/mock/fixtures/service.go @@ -138,3 +138,33 @@ func WithFailedListRepositories(err error) func(RepoServiceMock) { Return(nil, err).AnyTimes() } } + +func WithSuccessfulListRepositoriesPaginated( + repositories []*models.EntityWithProperties, +) func(RepoServiceMock) { + return func(mock RepoServiceMock) { + mock.EXPECT(). + ListRepositoriesPaginated( + gomock.Any(), + gomock.Any(), + gomock.Any(), + gomock.Any(), + gomock.Any(), + ). + Return(repositories, uuid.Nil, nil).AnyTimes() + } +} + +func WithFailedListRepositoriesPaginated(err error) func(RepoServiceMock) { + return func(mock RepoServiceMock) { + mock.EXPECT(). + ListRepositoriesPaginated( + gomock.Any(), + gomock.Any(), + gomock.Any(), + gomock.Any(), + gomock.Any(), + ). + Return(nil, uuid.Nil, err).AnyTimes() + } +} diff --git a/internal/repositories/mock/service.go b/internal/repositories/mock/service.go index a06c3faa5d..89a4920979 100644 --- a/internal/repositories/mock/service.go +++ b/internal/repositories/mock/service.go @@ -132,3 +132,19 @@ func (mr *MockRepositoryServiceMockRecorder) ListRepositories(ctx, projectID, pr mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRepositories", reflect.TypeOf((*MockRepositoryService)(nil).ListRepositories), ctx, projectID, providerID) } + +// ListRepositoriesPaginated mocks base method. +func (m *MockRepositoryService) ListRepositoriesPaginated(ctx context.Context, projectID, providerID, cursor uuid.UUID, limit int64) ([]*models.EntityWithProperties, uuid.UUID, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ListRepositoriesPaginated", ctx, projectID, providerID, cursor, limit) + ret0, _ := ret[0].([]*models.EntityWithProperties) + ret1, _ := ret[1].(uuid.UUID) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// ListRepositoriesPaginated indicates an expected call of ListRepositoriesPaginated. +func (mr *MockRepositoryServiceMockRecorder) ListRepositoriesPaginated(ctx, projectID, providerID, cursor, limit any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListRepositoriesPaginated", reflect.TypeOf((*MockRepositoryService)(nil).ListRepositoriesPaginated), ctx, projectID, providerID, cursor, limit) +} diff --git a/internal/repositories/service.go b/internal/repositories/service.go index 1dea8b818a..610dafffd9 100644 --- a/internal/repositories/service.go +++ b/internal/repositories/service.go @@ -70,6 +70,16 @@ type RepositoryService interface { providerID uuid.UUID, ) ([]*models.EntityWithProperties, error) + // ListRepositoriesPaginated retrieves repositories with cursor-based pagination. + // If cursor is empty, returns the first page. limit defaults to 100. + ListRepositoriesPaginated( + ctx context.Context, + projectID uuid.UUID, + providerID uuid.UUID, + cursor uuid.UUID, + limit int64, + ) ([]*models.EntityWithProperties, uuid.UUID, error) + // GetRepositoryById retrieves a repository by its ID and project. GetRepositoryById(ctx context.Context, repositoryID uuid.UUID, projectID uuid.UUID) (*pb.Repository, error) // GetRepositoryByName retrieves a repository by its name, owner, project and provider (if specified). @@ -210,6 +220,95 @@ func (r *repositoryService) ListRepositories( return ents, nil } +func (r *repositoryService) ListRepositoriesPaginated( + ctx context.Context, + projectID uuid.UUID, + providerID uuid.UUID, + cursor uuid.UUID, + limit int64, +) ([]*models.EntityWithProperties, uuid.UUID, error) { + if limit <= 0 { + limit = 100 + } + if limit > 1000 { + limit = 1000 + } + + tx, err := r.store.BeginTransaction() + if err != nil { + return nil, uuid.Nil, fmt.Errorf("error starting transaction: %w", err) + } + + outErr := errors.New("outer error placeholder") + defer func() { + if outErr != nil { + if err := tx.Rollback(); err != nil { + log.Printf("error rolling back transaction: %v", err) + } + } + }() + + qtx := r.store.GetQuerierWithTransaction(tx) + + var repoEnts []db.EntityInstance + if cursor == uuid.Nil { + repoEnts, outErr = qtx.GetEntitiesByType(ctx, db.GetEntitiesByTypeParams{ + EntityType: db.EntitiesRepository, + ProviderID: providerID, + Projects: []uuid.UUID{projectID}, + }) + if outErr != nil { + return nil, uuid.Nil, fmt.Errorf("error fetching repositories: %w", outErr) + } + } else { + repoEnts, outErr = qtx.ListEntitiesAfterID(ctx, db.ListEntitiesAfterIDParams{ + EntityType: db.EntitiesRepository, + ID: cursor, + Limit: limit, + }) + if outErr != nil { + return nil, uuid.Nil, fmt.Errorf("error fetching repositories after cursor: %w", outErr) + } + } + + // Apply limit in memory for the non-cursor case (GetEntitiesByType has no limit) + hasMore := false + if cursor == uuid.Nil && int64(len(repoEnts)) > limit { + repoEnts = repoEnts[:limit] + hasMore = true + } + + ents := make([]*models.EntityWithProperties, 0, len(repoEnts)) + var lastID uuid.UUID + for _, ent := range repoEnts { + ewp, err := r.propSvc.EntityWithPropertiesByID(ctx, ent.ID, + service.CallBuilder().WithStoreOrTransaction(qtx)) + if err != nil { + return nil, uuid.Nil, fmt.Errorf("error fetching properties for repository: %w", err) + } + + if err := r.propSvc.RetrieveAllPropertiesForEntity(ctx, ewp, r.providerManager, + service.ReadBuilder().WithStoreOrTransaction(qtx).TolerateStaleData()); err != nil { + return nil, uuid.Nil, fmt.Errorf("error fetching properties for repository: %w", err) + } + + ents = append(ents, ewp) + lastID = ent.ID + } + + if err := tx.Commit(); err != nil { + return nil, uuid.Nil, fmt.Errorf("error committing transaction: %w", err) + } + outErr = nil + + var nextCursor uuid.UUID + if hasMore || cursor != uuid.Nil { + nextCursor = lastID + } + + return ents, nextCursor, nil +} + func (r *repositoryService) GetRepositoryById( ctx context.Context, repositoryID uuid.UUID,