Provisioning: Implement connection repositories endpoint for GitHub

This change implements the `/repositories` subresource endpoint for Connection
resources, enabling listing of repositories accessible through a GitHub App
connection.

Changes:
- Add ListRepositories method to Connection interface
- Add ListInstallationRepositories to GitHub Client interface
- Implement GitHub client method to list installation repositories
  - Creates installation access token from JWT
  - Handles pagination up to 1000 repos
- Implement ListRepositories in GitHub Connection
- Update connectionRepositoriesConnector to use Connection.ListRepositories
- Add ConnectionGetter interface and GetConnection method to APIBuilder
- Add comprehensive tests for the new functionality
This commit is contained in:
Roberto Jimenez Sanchez
2026-01-13 09:56:40 +01:00
parent 91ab753368
commit a6a9358d5b
10 changed files with 478 additions and 19 deletions
@@ -22,6 +22,19 @@ type Client interface {
// Apps and installations
GetApp(ctx context.Context) (App, error)
GetAppInstallation(ctx context.Context, installationID string) (AppInstallation, error)
// Repositories
ListInstallationRepositories(ctx context.Context, installationID string) ([]Repository, error)
}
// Repository represents a GitHub repository accessible through an installation.
type Repository struct {
// Name of the repository
Name string
// Owner is the user or organization that owns the repository
Owner string
// URL of the repository (HTML URL)
URL string
}
// App represents a Github App.
@@ -91,3 +104,68 @@ func (r *githubClient) GetAppInstallation(ctx context.Context, installationID st
Enabled: installation.GetSuspendedAt().IsZero(),
}, nil
}
const (
maxRepositories = 1000 // Maximum number of repositories to fetch
)
// ListInstallationRepositories lists all repositories accessible by the specified GitHub App installation.
// It first creates an installation access token using the JWT, then uses that token to list repositories.
func (r *githubClient) ListInstallationRepositories(ctx context.Context, installationID string) ([]Repository, error) {
id, err := strconv.ParseInt(installationID, 10, 64)
if err != nil {
return nil, fmt.Errorf("invalid installation ID: %s", installationID)
}
// Create an installation access token
installationToken, _, err := r.gh.Apps.CreateInstallationToken(ctx, id, nil)
if err != nil {
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable {
return nil, ErrServiceUnavailable
}
return nil, fmt.Errorf("create installation token: %w", err)
}
// Create a new client with the installation token
tokenClient := github.NewClient(nil).WithAuthToken(installationToken.GetToken())
var allRepos []Repository
opts := &github.ListOptions{
Page: 1,
PerPage: 100,
}
for {
result, resp, err := tokenClient.Apps.ListRepos(ctx, opts)
if err != nil {
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) && ghErr.Response.StatusCode == http.StatusServiceUnavailable {
return nil, ErrServiceUnavailable
}
return nil, fmt.Errorf("list repositories: %w", err)
}
for _, repo := range result.Repositories {
allRepos = append(allRepos, Repository{
Name: repo.GetName(),
Owner: repo.GetOwner().GetLogin(),
URL: repo.GetHTMLURL(),
})
}
// Check if we've exceeded the maximum allowed repositories
if len(allRepos) > maxRepositories {
return nil, fmt.Errorf("too many repositories to fetch (more than %d)", maxRepositories)
}
// If there are no more pages, break
if resp.NextPage == 0 {
break
}
opts.Page = resp.NextPage
}
return allRepos, nil
}
@@ -134,6 +134,65 @@ func (_c *MockClient_GetAppInstallation_Call) RunAndReturn(run func(context.Cont
return _c
}
// ListInstallationRepositories provides a mock function with given fields: ctx, installationID
func (_m *MockClient) ListInstallationRepositories(ctx context.Context, installationID string) ([]Repository, error) {
ret := _m.Called(ctx, installationID)
if len(ret) == 0 {
panic("no return value specified for ListInstallationRepositories")
}
var r0 []Repository
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, string) ([]Repository, error)); ok {
return rf(ctx, installationID)
}
if rf, ok := ret.Get(0).(func(context.Context, string) []Repository); ok {
r0 = rf(ctx, installationID)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]Repository)
}
}
if rf, ok := ret.Get(1).(func(context.Context, string) error); ok {
r1 = rf(ctx, installationID)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockClient_ListInstallationRepositories_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListInstallationRepositories'
type MockClient_ListInstallationRepositories_Call struct {
*mock.Call
}
// ListInstallationRepositories is a helper method to define mock.On call
// - ctx context.Context
// - installationID string
func (_e *MockClient_Expecter) ListInstallationRepositories(ctx interface{}, installationID interface{}) *MockClient_ListInstallationRepositories_Call {
return &MockClient_ListInstallationRepositories_Call{Call: _e.mock.On("ListInstallationRepositories", ctx, installationID)}
}
func (_c *MockClient_ListInstallationRepositories_Call) Run(run func(ctx context.Context, installationID string)) *MockClient_ListInstallationRepositories_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(string))
})
return _c
}
func (_c *MockClient_ListInstallationRepositories_Call) Return(_a0 []Repository, _a1 error) *MockClient_ListInstallationRepositories_Call {
_c.Call.Return(_a0, _a1)
return _c
}
func (_c *MockClient_ListInstallationRepositories_Call) RunAndReturn(run func(context.Context, string) ([]Repository, error)) *MockClient_ListInstallationRepositories_Call {
_c.Call.Return(run)
return _c
}
// NewMockClient creates a new instance of MockClient. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
// The first argument is typically a *testing.T value.
func NewMockClient(t interface {
@@ -187,6 +187,31 @@ func toError(name string, list field.ErrorList) error {
)
}
// ListRepositories returns the list of repositories accessible through this GitHub App connection.
func (c *Connection) ListRepositories(ctx context.Context) ([]provisioning.ExternalRepository, error) {
if c.obj.Spec.GitHub == nil {
return nil, fmt.Errorf("github configuration is required")
}
ghClient := c.ghFactory.New(ctx, c.obj.Secure.Token.Create)
repos, err := ghClient.ListInstallationRepositories(ctx, c.obj.Spec.GitHub.InstallationID)
if err != nil {
return nil, fmt.Errorf("list installation repositories: %w", err)
}
result := make([]provisioning.ExternalRepository, 0, len(repos))
for _, repo := range repos {
result = append(result, provisioning.ExternalRepository{
Name: repo.Name,
Owner: repo.Owner,
URL: repo.URL,
})
}
return result, nil
}
var (
_ connection.Connection = (*Connection)(nil)
)
@@ -432,3 +432,120 @@ func TestConnection_Validate(t *testing.T) {
})
}
}
func TestConnection_ListRepositories(t *testing.T) {
t.Run("should list repositories successfully", func(t *testing.T) {
c := &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
Token: common.InlineSecureValue{
Create: common.NewSecretValue("test-token"),
},
},
}
mockFactory := NewMockGithubFactory(t)
mockClient := NewMockClient(t)
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
mockClient.EXPECT().ListInstallationRepositories(mock.Anything, "456").Return([]Repository{
{Name: "repo1", Owner: "owner1", URL: "https://github.com/owner1/repo1"},
{Name: "repo2", Owner: "owner2", URL: "https://github.com/owner2/repo2"},
}, nil)
conn := NewConnection(c, mockFactory)
repos, err := conn.ListRepositories(context.Background())
require.NoError(t, err)
require.Len(t, repos, 2)
assert.Equal(t, "repo1", repos[0].Name)
assert.Equal(t, "owner1", repos[0].Owner)
assert.Equal(t, "https://github.com/owner1/repo1", repos[0].URL)
assert.Equal(t, "repo2", repos[1].Name)
assert.Equal(t, "owner2", repos[1].Owner)
assert.Equal(t, "https://github.com/owner2/repo2", repos[1].URL)
})
t.Run("should return error when GitHub config is nil", func(t *testing.T) {
c := &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GitlabConnectionType,
},
}
mockFactory := NewMockGithubFactory(t)
conn := NewConnection(c, mockFactory)
_, err := conn.ListRepositories(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "github configuration is required")
})
t.Run("should return error when listing repositories fails", func(t *testing.T) {
c := &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
Token: common.InlineSecureValue{
Create: common.NewSecretValue("test-token"),
},
},
}
mockFactory := NewMockGithubFactory(t)
mockClient := NewMockClient(t)
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
mockClient.EXPECT().ListInstallationRepositories(mock.Anything, "456").Return(nil, assert.AnError)
conn := NewConnection(c, mockFactory)
_, err := conn.ListRepositories(context.Background())
require.Error(t, err)
assert.Contains(t, err.Error(), "list installation repositories")
})
t.Run("should return empty list when no repositories", func(t *testing.T) {
c := &provisioning.Connection{
ObjectMeta: metav1.ObjectMeta{Name: "test-connection"},
Spec: provisioning.ConnectionSpec{
Type: provisioning.GithubConnectionType,
GitHub: &provisioning.GitHubConnectionConfig{
AppID: "123",
InstallationID: "456",
},
},
Secure: provisioning.ConnectionSecure{
Token: common.InlineSecureValue{
Create: common.NewSecretValue("test-token"),
},
},
}
mockFactory := NewMockGithubFactory(t)
mockClient := NewMockClient(t)
mockFactory.EXPECT().New(mock.Anything, common.RawSecureValue("test-token")).Return(mockClient)
mockClient.EXPECT().ListInstallationRepositories(mock.Anything, "456").Return([]Repository{}, nil)
conn := NewConnection(c, mockFactory)
repos, err := conn.ListRepositories(context.Background())
require.NoError(t, err)
require.Len(t, repos, 0)
})
}