Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
33 changes: 25 additions & 8 deletions internal/controlplane/handlers_repositories.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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")
}
Comment on lines +161 to +165
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ListRepositoriesRequest.cursor is protovalidate-constrained to the pattern ^[[:word:]=]*$, which is not a UUID shape (notably excludes '-'). Parsing the cursor strictly as uuid.UUID can reject otherwise-valid client cursors, and will also fail round-tripping if the server returns a hyphenated UUID. Consider using an opaque cursor encoding that matches the proto constraints (e.g., base64 or hex).

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You may need to update the annotations in minder.proto, or consider blinding the cursor (e.g. base64 the contents) to make it less of an explicit contract.

}

// 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
}
Expand Down Expand Up @@ -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()
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resp.Cursor is set using nextCursor.String(), which produces a hyphenated UUID. Because request validation restricts cursor to ^[[:word:]=]*$, clients may not be able to send this cursor back on the next request. Return an encoding that matches the proto constraints (e.g., UUID hex without hyphens or base64).

Suggested change
resp.Cursor = nextCursor.String()
resp.Cursor = strings.ReplaceAll(nextCursor.String(), "-", "")

Copilot uses AI. Check for mistakes.
}

return &resp, nil
}
Expand Down
12 changes: 7 additions & 5 deletions internal/controlplane/handlers_repositories_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -175,9 +175,11 @@
{
Name: "List repositories succeeds with multiple results",
RepoServiceSetup: rf.NewRepoService(
rf.WithSuccessfulListRepositories(
simpleDbRepository(repoName, remoteRepoId),
simpleDbRepository(repoName2, remoteRepoId2),
rf.WithSuccessfulListRepositoriesPaginated(
[]*models.EntityWithProperties{

Check failure on line 179 in internal/controlplane/handlers_repositories_test.go

View workflow job for this annotation

GitHub Actions / lint / Run golangci-lint

File is not properly formatted (gci)
simpleDbRepository(repoName, remoteRepoId),
simpleDbRepository(repoName2, remoteRepoId2),
},
),
),
ProviderSetup: func(ctrl *gomock.Controller, mgr *mockmanager.MockProviderManager) {
Expand Down Expand Up @@ -246,14 +248,14 @@
{
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(),
},
Expand Down
30 changes: 30 additions & 0 deletions internal/repositories/mock/fixtures/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Comment on lines +153 to +155
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This fixture always returns uuid.Nil as nextCursor, so tests can’t cover the new behavior of returning a non-empty cursor and fetching subsequent pages. Consider accepting nextCursor as an argument and adding a test that asserts ListRepositoriesResponse.Cursor is set.

Copilot uses AI. Check for mistakes.
}

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()
}
}
16 changes: 16 additions & 0 deletions internal/repositories/mock/service.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

99 changes: 99 additions & 0 deletions internal/repositories/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@
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).
Expand Down Expand Up @@ -210,6 +220,95 @@
return ents, nil
}

func (r *repositoryService) ListRepositoriesPaginated(

Check failure on line 223 in internal/repositories/service.go

View workflow job for this annotation

GitHub Actions / lint / Run golangci-lint

cyclomatic complexity 17 of func `(*repositoryService).ListRepositoriesPaginated` is high (> 15) (gocyclo)
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
Comment on lines +233 to +234
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This method clamps limit to 1000, but the protobuf contract for ListRepositoriesRequest.limit is validated to lte: 100 via protovalidate. Consider aligning the server-side clamp/docs with the API contract (or update the proto + any clients in the same PR).

Suggested change
if limit > 1000 {
limit = 1000
if limit > 100 {
limit = 100

Copilot uses AI. Check for mistakes.
}

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)
Comment on lines +242 to +246
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rollback is gated on outErr, but outErr is only set around the initial query. If an error occurs later (e.g., in the properties loop), outErr can be nil and the deferred rollback won't run, leaving the transaction open. Use a named return error (like ListRepositories) or defer an unconditional rollback (ignoring ErrTxDone after Commit).

Copilot uses AI. Check for mistakes.
}
}
}()

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},
})
Comment on lines +255 to +259
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When cursor == uuid.Nil this calls GetEntitiesByType, which has no LIMIT and (in SQL) no ORDER BY. That means the first page still loads all repositories into memory (defeating the pagination goal) and the resulting nextCursor based on the last element is not stable (can skip/duplicate results). Use a DB-level ordered + limited query for the first page too.

Copilot uses AI. Check for mistakes.
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is the critical comment -- you should set a database limit to avoid doing a lot of extra backend work. Given the cursor-based query, you should be able to fetch using the natural order of the table (ORDER BY id) and then a WHERE clause on id > cursor.

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,
})
Comment on lines +264 to +268
Copy link

Copilot AI Apr 11, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cursor branch calls ListEntitiesAfterID with only entity_type + id + limit. That query is not scoped to provider_id/project_id, so this endpoint can return repositories from other projects/providers once a non-empty cursor is used (and potentially leak data). Add a scoped pagination query (entity_type + provider_id + project_id + id > cursor ORDER BY id LIMIT ...) and use it here.

Copilot uses AI. Check for mistakes.
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,
Expand Down
Loading