diff --git a/examples/platform_hub/configure_version_control_settings.go b/examples/platform_hub/configure_version_control_settings.go new file mode 100644 index 00000000..b16fdcf7 --- /dev/null +++ b/examples/platform_hub/configure_version_control_settings.go @@ -0,0 +1,53 @@ +package examples + +import ( + "fmt" + "net/url" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/credentials" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/platformhubversioncontrolsettings" +) + +// ConfigurePlatformHubVersionControlSettingsExample provides an example of how to point +// Platform Hub at a Git repository using username/password credentials. +// +// If the repository is already configured, changing the URL will repoint Platform Hub at the new repository. +func ConfigurePlatformHubVersionControlSettingsExample() { + var ( + apiKey string = "API-YOUR_API_KEY" + octopusURL string = "https://your_octopus_url" + + // version control values + gitURL string = "https://github.com/your-org/your-repo.git" + gitUsername string = "your-username" + gitPassword string = "your-personal-access-token" + defaultBranch string = "main" + basePath string = ".octopus/" + ) + + apiURL, err := url.Parse(octopusURL) + if err != nil { + _ = fmt.Errorf("error parsing URL for Octopus API: %v", err) + return + } + + // Platform Hub is system-scoped, so no space ID is required + octopusClient, err := client.NewClient(nil, apiURL, apiKey, "") + if err != nil { + _ = fmt.Errorf("error creating API client: %v", err) + return + } + + gitCredentials := credentials.NewUsernamePassword(gitUsername, core.NewSensitiveValue(gitPassword)) + settings := platformhubversioncontrolsettings.NewResource(gitURL, gitCredentials, defaultBranch, basePath) + + updatedSettings, err := platformhubversioncontrolsettings.Update(octopusClient, settings) + if err != nil { + _ = fmt.Errorf("error updating Platform Hub version control settings: %v", err) + return + } + + fmt.Printf("Platform Hub configured against: (%s)\n", updatedSettings.URL) +} diff --git a/examples/platform_hub/configure_version_control_settings_with_github_connection.go b/examples/platform_hub/configure_version_control_settings_with_github_connection.go new file mode 100644 index 00000000..b9aa41ce --- /dev/null +++ b/examples/platform_hub/configure_version_control_settings_with_github_connection.go @@ -0,0 +1,71 @@ +package examples + +import ( + "fmt" + "net/url" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/credentials" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/githubconnections" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/platformhubgithubconnections" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/platformhubversioncontrolsettings" +) + +// ConfigurePlatformHubVersionControlSettingsWithGitHubConnectionExample provides an example +// of how to point Platform Hub at a repository reachable through a GitHub App connection. +// +// The repository listing supplies both the Git URL and the repository's own default branch, +// so neither needs to be provided. +func ConfigurePlatformHubVersionControlSettingsWithGitHubConnectionExample() { + var ( + apiKey string = "API-YOUR_API_KEY" + octopusURL string = "https://your_octopus_url" + + // version control values + connectionID string = "GitHubAppConnections-1" + repositoryName string = "your-org/your-repo" + basePath string = ".octopus/" + ) + + apiURL, err := url.Parse(octopusURL) + if err != nil { + _ = fmt.Errorf("error parsing URL for Octopus API: %v", err) + return + } + + // Platform Hub is system-scoped, so no space ID is required + octopusClient, err := client.NewClient(nil, apiURL, apiKey, "") + if err != nil { + _ = fmt.Errorf("error creating API client: %v", err) + return + } + + repositories, err := platformhubgithubconnections.GetRepositories(octopusClient, connectionID) + if err != nil { + _ = fmt.Errorf("error getting repositories for connection: %v", err) + return + } + + var repository *githubconnections.Repository + for _, r := range repositories { + if r.RepositoryName == repositoryName { + repository = r + break + } + } + if repository == nil { + _ = fmt.Errorf("repository (%s) is not accessible through connection (%s)", repositoryName, connectionID) + return + } + + gitCredentials := credentials.NewGitHubApp(connectionID) + settings := platformhubversioncontrolsettings.NewResource(repository.GitURL, gitCredentials, repository.DefaultBranch, basePath) + + updatedSettings, err := platformhubversioncontrolsettings.Update(octopusClient, settings) + if err != nil { + _ = fmt.Errorf("error updating Platform Hub version control settings: %v", err) + return + } + + fmt.Printf("Platform Hub configured against: (%s)\n", updatedSettings.URL) +} diff --git a/examples/platform_hub/get_github_connection_by_id.go b/examples/platform_hub/get_github_connection_by_id.go new file mode 100644 index 00000000..bc0ce50f --- /dev/null +++ b/examples/platform_hub/get_github_connection_by_id.go @@ -0,0 +1,53 @@ +package examples + +import ( + "fmt" + "net/url" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/platformhubgithubconnections" +) + +// GetPlatformHubGitHubConnectionByIDExample provides an example of how to get a single +// Platform Hub GitHub App connection and the repositories it grants access to. +func GetPlatformHubGitHubConnectionByIDExample() { + var ( + apiKey string = "API-YOUR_API_KEY" + octopusURL string = "https://your_octopus_url" + + // GitHub connection values + connectionID string = "GitHubAppConnections-1" + ) + + apiURL, err := url.Parse(octopusURL) + if err != nil { + _ = fmt.Errorf("error parsing URL for Octopus API: %v", err) + return + } + + // Platform Hub is system-scoped, so no space ID is required + octopusClient, err := client.NewClient(nil, apiURL, apiKey, "") + if err != nil { + _ = fmt.Errorf("error creating API client: %v", err) + return + } + + connection, err := platformhubgithubconnections.GetByID(octopusClient, connectionID) + if err != nil { + _ = fmt.Errorf("error getting GitHub connection: %v", err) + return + } + + fmt.Printf("connection: (%s) %s\n", connection.ID, connection.Installation.AccountLogin) + fmt.Printf("status: %s %s\n", connection.Status, connection.StatusUserMessage) + + for _, repository := range connection.Repositories { + fmt.Printf(" repository: %s (%s)\n", repository.RepositoryName, repository.GitURL) + } + + // repositories configured on the connection that GitHub no longer returns; they may have + // been deleted, renamed, or had access revoked + for _, repository := range connection.UnknownRepositories { + fmt.Printf(" unknown repository: %s (%s)\n", repository.RepositoryName, repository.RepositoryID) + } +} diff --git a/examples/platform_hub/get_version_control_settings.go b/examples/platform_hub/get_version_control_settings.go new file mode 100644 index 00000000..2820c5a6 --- /dev/null +++ b/examples/platform_hub/get_version_control_settings.go @@ -0,0 +1,53 @@ +package examples + +import ( + "fmt" + "net/url" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/credentials" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/platformhubversioncontrolsettings" +) + +// GetPlatformHubVersionControlSettingsExample provides an example of how to read the +// Platform Hub version control settings from Octopus Deploy through the Go API client. +func GetPlatformHubVersionControlSettingsExample() { + var ( + apiKey string = "API-YOUR_API_KEY" + octopusURL string = "https://your_octopus_url" + ) + + apiURL, err := url.Parse(octopusURL) + if err != nil { + _ = fmt.Errorf("error parsing URL for Octopus API: %v", err) + return + } + + // Platform Hub is system-scoped, so no space ID is required + octopusClient, err := client.NewClient(nil, apiURL, apiKey, "") + if err != nil { + _ = fmt.Errorf("error creating API client: %v", err) + return + } + + settings, err := platformhubversioncontrolsettings.Get(octopusClient) + if err != nil { + _ = fmt.Errorf("error getting Platform Hub version control settings: %v", err) + return + } + + fmt.Printf("URL: %s\n", settings.URL) + fmt.Printf("default branch: %s\n", settings.DefaultBranch) + fmt.Printf("base path: %s\n", settings.BasePath) + + switch creds := settings.Credentials.(type) { + case *credentials.Anonymous: + fmt.Println("credentials: anonymous") + case *credentials.UsernamePassword: + fmt.Printf("credentials: username/password (%s)\n", creds.Username) + case *credentials.GitHubApp: + fmt.Printf("credentials: GitHub App connection (%s)\n", creds.ID) + case nil: + fmt.Println("Platform Hub is not configured for version control") + } +} diff --git a/examples/platform_hub/list_github_connections.go b/examples/platform_hub/list_github_connections.go new file mode 100644 index 00000000..a3bf6d07 --- /dev/null +++ b/examples/platform_hub/list_github_connections.go @@ -0,0 +1,78 @@ +package examples + +import ( + "fmt" + "net/url" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/githubconnections" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/platformhubgithubconnections" +) + +// ListPlatformHubGitHubConnectionsExample provides an example of how to list the GitHub App +// connections available to Platform Hub, paging through the results. +func ListPlatformHubGitHubConnectionsExample() { + var ( + apiKey string = "API-YOUR_API_KEY" + octopusURL string = "https://your_octopus_url" + + // paging values; both skip and take are required by the API + take int = 30 + ) + + apiURL, err := url.Parse(octopusURL) + if err != nil { + _ = fmt.Errorf("error parsing URL for Octopus API: %v", err) + return + } + + // Platform Hub is system-scoped, so no space ID is required + octopusClient, err := client.NewClient(nil, apiURL, apiKey, "") + if err != nil { + _ = fmt.Errorf("error creating API client: %v", err) + return + } + + settings, err := githubconnections.GetSettings(octopusClient) + if err != nil { + _ = fmt.Errorf("error getting GitHub App settings: %v", err) + return + } + if !settings.CanUseGitHubApp { + fmt.Println("this Octopus instance cannot use GitHub App connections") + return + } + + var connections []*githubconnections.Connection + for { + page, err := platformhubgithubconnections.List(octopusClient, len(connections), take) + if err != nil { + _ = fmt.Errorf("error listing GitHub connections: %v", err) + return + } + + connections = append(connections, page.Connections...) + + if len(page.Connections) == 0 || len(connections) >= page.TotalResults { + break + } + } + + for _, connection := range connections { + fmt.Printf("connection: (%s) %s %s [%s]\n", connection.ID, connection.Installation.AccountType, connection.Installation.AccountLogin, connection.Status) + + if connection.Status != githubconnections.ConnectionStatusConnected { + continue + } + + repositories, err := platformhubgithubconnections.GetRepositories(octopusClient, connection.ID) + if err != nil { + _ = fmt.Errorf("error getting repositories for connection: %v", err) + return + } + + for _, repository := range repositories { + fmt.Printf(" repository: %s (%s), default branch: %s\n", repository.RepositoryName, repository.GitURL, repository.DefaultBranch) + } + } +} diff --git a/examples/process_templates/create_process_template.go b/examples/process_templates/create_process_template.go new file mode 100644 index 00000000..fc01d168 --- /dev/null +++ b/examples/process_templates/create_process_template.go @@ -0,0 +1,48 @@ +package examples + +import ( + "fmt" + "net/url" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/processtemplates" +) + +// CreateProcessTemplateExample provides an example of how to create a process template in +// Platform Hub through the Go API client. +// +// Steps and parameters cannot be set at creation time; the new template is empty, and should be +// configured after in Git or Octopus Deploy. +func CreateProcessTemplateExample() { + var ( + apiKey string = "API-YOUR_API_KEY" + octopusURL string = "https://your_octopus_url" + + // process template values + gitRef string = "refs/heads/main" + name string = "your-process-template-name" + description string = "your-process-template-description" + changeDescription string = "Add a process template" + ) + + apiURL, err := url.Parse(octopusURL) + if err != nil { + _ = fmt.Errorf("error parsing URL for Octopus API: %v", err) + return + } + + // Platform Hub is system-scoped, so no space ID is required + octopusClient, err := client.NewClient(nil, apiURL, apiKey, "") + if err != nil { + _ = fmt.Errorf("error creating API client: %v", err) + return + } + + processTemplate, err := processtemplates.Add(octopusClient, gitRef, name, description, changeDescription) + if err != nil { + _ = fmt.Errorf("error creating process template: %v", err) + return + } + + fmt.Printf("process template created: (%s) %s\n", processTemplate.Slug, processTemplate.Name) +} diff --git a/examples/process_templates/get_process_template_by_slug.go b/examples/process_templates/get_process_template_by_slug.go new file mode 100644 index 00000000..a04d9e23 --- /dev/null +++ b/examples/process_templates/get_process_template_by_slug.go @@ -0,0 +1,62 @@ +package examples + +import ( + "fmt" + "net/url" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/processtemplates" +) + +// GetProcessTemplateBySlugExample provides an example of how to get a single process template +// from Platform Hub through the Go API client. +func GetProcessTemplateBySlugExample() { + var ( + apiKey string = "API-YOUR_API_KEY" + octopusURL string = "https://your_octopus_url" + + // process template values + gitRef string = "refs/heads/main" + slug string = "your-process-template-slug" + ) + + apiURL, err := url.Parse(octopusURL) + if err != nil { + _ = fmt.Errorf("error parsing URL for Octopus API: %v", err) + return + } + + // Platform Hub is system-scoped, so no space ID is required + octopusClient, err := client.NewClient(nil, apiURL, apiKey, "") + if err != nil { + _ = fmt.Errorf("error creating API client: %v", err) + return + } + + processTemplate, err := processtemplates.GetBySlug(octopusClient, gitRef, slug) + if err != nil { + _ = fmt.Errorf("error getting process template: %v", err) + return + } + + fmt.Printf("process template: (%s) %s\n", processTemplate.Slug, processTemplate.Name) + fmt.Printf("description: %s\n", processTemplate.Description) + + for _, step := range processTemplate.Steps { + fmt.Printf(" step: %s\n", step.Name) + } + + for _, parameter := range processTemplate.Parameters { + fmt.Printf(" parameter: %s, optional: %t\n", parameter.Name, parameter.IsOptional) + + for _, value := range parameter.Values { + // a parameter value is sensitive when it is backed by a sensitive value rather + // than a plain string + if value.Value.IsSensitive { + fmt.Println(" default value: (sensitive)") + continue + } + fmt.Printf(" default value: %s\n", value.Value.Value) + } + } +} diff --git a/examples/process_templates/list_process_templates.go b/examples/process_templates/list_process_templates.go new file mode 100644 index 00000000..ae0c6c13 --- /dev/null +++ b/examples/process_templates/list_process_templates.go @@ -0,0 +1,51 @@ +package examples + +import ( + "fmt" + "net/url" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/processtemplates" +) + +// ListProcessTemplatesExample provides an example of how to list the process templates on a +// Git reference in Platform Hub through the Go API client. +func ListProcessTemplatesExample() { + var ( + apiKey string = "API-YOUR_API_KEY" + octopusURL string = "https://your_octopus_url" + + // process template values + gitRef string = "refs/heads/main" + ) + + apiURL, err := url.Parse(octopusURL) + if err != nil { + _ = fmt.Errorf("error parsing URL for Octopus API: %v", err) + return + } + + // Platform Hub is system-scoped, so no space ID is required + octopusClient, err := client.NewClient(nil, apiURL, apiKey, "") + if err != nil { + _ = fmt.Errorf("error creating API client: %v", err) + return + } + + query := processtemplates.ProcessTemplatesQuery{ + GitRef: gitRef, + Take: 30, + } + + results, err := processtemplates.List(octopusClient, query) + if err != nil { + _ = fmt.Errorf("error listing process templates: %v", err) + return + } + + for _, processTemplate := range results.ProcessTemplates { + fmt.Printf("process template: (%s) %s, %d step(s), %d parameter(s)\n", processTemplate.Slug, processTemplate.Name, len(processTemplate.Steps), len(processTemplate.Parameters)) + } + + fmt.Printf("showing %d of %d process template(s)\n", len(results.ProcessTemplates), results.TotalResults) +} diff --git a/pkg/githubconnections/connection.go b/pkg/githubconnections/connection.go new file mode 100644 index 00000000..fb7e83fc --- /dev/null +++ b/pkg/githubconnections/connection.go @@ -0,0 +1,31 @@ +package githubconnections + +// ConnectionStatus describes the health of a GitHub App connection. +type ConnectionStatus string + +const ( + ConnectionStatusConnected = ConnectionStatus("Connected") + ConnectionStatusConnectionNotFound = ConnectionStatus("ConnectionNotFound") + ConnectionStatusInstallationNotFound = ConnectionStatus("InstallationNotFound") + ConnectionStatusInstallationSuspended = ConnectionStatus("InstallationSuspended") + ConnectionStatusError = ConnectionStatus("Error") +) + +// Installation represents the GitHub App installation backing a connection. +type Installation struct { + InstallationID string `json:"InstallationId"` + AccountID string `json:"AccountId"` + AccountLogin string `json:"AccountLogin"` + AccountAvatarURL string `json:"AccountAvatarUrl"` + AccountType string `json:"AccountType"` + // AllRepositories is true when the installation can access every repository in the + // account, false when it is restricted to a selected set. + AllRepositories bool `json:"AllRepositories"` +} + +// Connection represents a GitHub App connection. +type Connection struct { + ID string `json:"Id"` + Status ConnectionStatus `json:"Status,omitempty"` + Installation *Installation `json:"Installation,omitempty"` +} diff --git a/pkg/githubconnections/repository.go b/pkg/githubconnections/repository.go new file mode 100644 index 00000000..667379b6 --- /dev/null +++ b/pkg/githubconnections/repository.go @@ -0,0 +1,20 @@ +package githubconnections + +// Repository represents a GitHub repository reachable through a GitHub App connection. +type Repository struct { + RepositoryID string `json:"RepositoryId"` + RepositoryName string `json:"RepositoryName"` + IsAdmin bool `json:"IsAdmin"` + IsPrivate bool `json:"IsPrivate"` + Visibility string `json:"Visibility"` + Language string `json:"Language,omitempty"` + GitURL string `json:"GitUrl"` + DefaultBranch string `json:"DefaultBranch"` +} + +// UnknownRepository represents a repository configured on a connection that has no +// matching repository returned from GitHub. +type UnknownRepository struct { + RepositoryID string `json:"RepositoryId"` + RepositoryName string `json:"RepositoryName,omitempty"` +} diff --git a/pkg/githubconnections/settings.go b/pkg/githubconnections/settings.go new file mode 100644 index 00000000..a814b2a3 --- /dev/null +++ b/pkg/githubconnections/settings.go @@ -0,0 +1,18 @@ +package githubconnections + +import ( + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/newclient" +) + +const settingsPath = "/api/github/app/settings" + +// Settings represents the server-wide GitHub App settings. +type Settings struct { + CanUseGitHubApp bool `json:"CanUseGitHubApp"` + CanUseTrustedFlow bool `json:"CanUseTrustedFlow"` +} + +// GetSettings returns the server's GitHub App settings. +func GetSettings(client newclient.Client) (*Settings, error) { + return newclient.Get[Settings](client.HttpSession(), settingsPath) +} diff --git a/pkg/githubconnections/settings_test.go b/pkg/githubconnections/settings_test.go new file mode 100644 index 00000000..001fa17d --- /dev/null +++ b/pkg/githubconnections/settings_test.go @@ -0,0 +1,33 @@ +package githubconnections + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/newclient" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGetSettings(t *testing.T) { + var requested string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requested = r.URL.RequestURI() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"CanUseGitHubApp":true,"CanUseTrustedFlow":false}`)) + })) + defer server.Close() + + baseURL, err := url.Parse(server.URL + "/") + require.NoError(t, err) + client := newclient.NewClient(&newclient.HttpSession{HttpClient: server.Client(), BaseURL: baseURL}) + + settings, err := GetSettings(client) + require.NoError(t, err) + + assert.Equal(t, "/api/githubconnections/app/settings", requested) + assert.True(t, settings.CanUseGitHubApp) + assert.False(t, settings.CanUseTrustedFlow) +} diff --git a/pkg/platformhubgithubconnections/service.go b/pkg/platformhubgithubconnections/service.go new file mode 100644 index 00000000..2f092fc0 --- /dev/null +++ b/pkg/platformhubgithubconnections/service.go @@ -0,0 +1,86 @@ +package platformhubgithubconnections + +import ( + "github.com/OctopusDeploy/go-octopusdeploy/v2/internal" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/githubconnections" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/newclient" +) + +const ( + connectionsTemplate = "/api/platformhub/github/connections{?skip,take}" + connectionTemplate = "/api/platformhub/github/connections/{id}" + repositoriesTemplate = "/api/platformhub/github/connections/{connectionId}/repositories" +) + +// ConnectionDetails represents a single Platform Hub GitHub App connection, including the +// repositories it grants access to which githubconnections.Connection doesn't include. +type ConnectionDetails struct { + ID string `json:"Id"` + Status githubconnections.ConnectionStatus `json:"Status"` + StatusUserMessage string `json:"StatusUserMessage,omitempty"` + Installation *githubconnections.Installation `json:"Installation,omitempty"` + Repositories []*githubconnections.Repository `json:"Repositories"` + UnknownRepositories []*githubconnections.UnknownRepository `json:"UnknownRepositories"` +} + +// ConnectionsQuery represents the query parameters for listing connections. Both skip and take are required. +type ConnectionsQuery struct { + Skip int `uri:"skip" json:"skip"` + Take int `uri:"take" json:"take"` +} + +// ConnectionsQueryResult is a paginated collection of Platform Hub GitHub App connections. +type ConnectionsQueryResult struct { + Connections []*githubconnections.Connection `json:"Connections"` + ItemsPerPage int `json:"ItemsPerPage"` + NumberOfPages int `json:"NumberOfPages"` + TotalResults int `json:"TotalResults"` +} + +// List returns a single page of Platform Hub GitHub App connections. +func List(client newclient.Client, skip int, take int) (*ConnectionsQueryResult, error) { + path, err := client.URITemplateCache().Expand(connectionsTemplate, ConnectionsQuery{Skip: skip, Take: take}) + if err != nil { + return nil, err + } + + return newclient.Get[ConnectionsQueryResult](client.HttpSession(), path) +} + +// GetByID returns the Platform Hub GitHub App connection with the given ID, along with the +// repositories it grants access to. +func GetByID(client newclient.Client, id string) (*ConnectionDetails, error) { + if internal.IsEmpty(id) { + return nil, internal.CreateInvalidParameterError("GetByID", "id") + } + + path, err := client.URITemplateCache().Expand(connectionTemplate, map[string]any{"id": id}) + if err != nil { + return nil, err + } + + return newclient.Get[ConnectionDetails](client.HttpSession(), path) +} + +type repositoriesResponse struct { + Repositories []*githubconnections.Repository `json:"Repositories"` +} + +// GetRepositories returns the GitHub repositories reachable through the given connection. +func GetRepositories(client newclient.Client, connectionID string) ([]*githubconnections.Repository, error) { + if internal.IsEmpty(connectionID) { + return nil, internal.CreateInvalidParameterError("GetRepositories", "connectionID") + } + + path, err := client.URITemplateCache().Expand(repositoriesTemplate, map[string]any{"connectionId": connectionID}) + if err != nil { + return nil, err + } + + response, err := newclient.Get[repositoriesResponse](client.HttpSession(), path) + if err != nil { + return nil, err + } + + return response.Repositories, nil +} diff --git a/pkg/platformhubgithubconnections/service_test.go b/pkg/platformhubgithubconnections/service_test.go new file mode 100644 index 00000000..e04db335 --- /dev/null +++ b/pkg/platformhubgithubconnections/service_test.go @@ -0,0 +1,157 @@ +package platformhubgithubconnections + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/githubconnections" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/newclient" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newTestClient returns a client pointed at a server that records the requested URI and +// replies with the given payloads, one per request in order. +func newTestClient(t *testing.T, requested *[]string, payloads ...string) newclient.Client { + t.Helper() + + call := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + *requested = append(*requested, r.URL.RequestURI()) + payload := payloads[len(payloads)-1] + if call < len(payloads) { + payload = payloads[call] + } + call++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(payload)) + })) + t.Cleanup(server.Close) + + baseURL, err := url.Parse(server.URL + "/") + require.NoError(t, err) + + return newclient.NewClient(&newclient.HttpSession{HttpClient: server.Client(), BaseURL: baseURL}) +} + +func TestList(t *testing.T) { + const payload = `{ + "Connections": [ + { + "Id": "GitHubAppConnections-1", + "Status": "Connected", + "Installation": { + "InstallationId": "12345", + "AccountId": "678", + "AccountLogin": "OctopusDeploy", + "AccountAvatarUrl": "https://avatars.githubusercontent.com/u/678", + "AccountType": "Organization", + "AllRepositories": false + } + }, + { "Id": "GitHubAppConnections-2", "Status": "InstallationSuspended" } + ], + "ItemsPerPage": 30, + "NumberOfPages": 1, + "TotalResults": 2 + }` + + var requested []string + client := newTestClient(t, &requested, payload) + + result, err := List(client, 0, 30) + require.NoError(t, err) + + // skip and take are [Required] on the server contract, so a zero skip must still be sent. + require.Len(t, requested, 1) + assert.Equal(t, "/api/platformhub/githubconnections/connections?skip=0&take=30", requested[0]) + + require.Len(t, result.Connections, 2) + assert.Equal(t, 2, result.TotalResults) + assert.Equal(t, "GitHubAppConnections-1", result.Connections[0].ID) + assert.Equal(t, githubconnections.ConnectionStatusConnected, result.Connections[0].Status) + require.NotNil(t, result.Connections[0].Installation) + assert.Equal(t, "OctopusDeploy", result.Connections[0].Installation.AccountLogin) + assert.Equal(t, "Organization", result.Connections[0].Installation.AccountType) + assert.False(t, result.Connections[0].Installation.AllRepositories) + assert.Equal(t, githubconnections.ConnectionStatusInstallationSuspended, result.Connections[1].Status) + assert.Nil(t, result.Connections[1].Installation) +} + +func TestGetByID(t *testing.T) { + const payload = `{ + "Id": "GitHubAppConnections-1", + "Status": "Connected", + "StatusUserMessage": "All good", + "Installation": { "InstallationId": "12345", "AccountLogin": "OctopusDeploy", "AccountType": "Organization" }, + "Repositories": [ + { + "RepositoryId": "R_1", + "RepositoryName": "hub", + "IsAdmin": true, + "IsPrivate": true, + "Visibility": "private", + "Language": "Go", + "GitUrl": "https://github.com/OctopusDeploy/hub.git", + "DefaultBranch": "main" + } + ], + "UnknownRepositories": [{ "RepositoryId": "R_2" }] + }` + + var requested []string + client := newTestClient(t, &requested, payload) + + connection, err := GetByID(client, "GitHubAppConnections-1") + require.NoError(t, err) + + assert.Equal(t, "/api/platformhub/githubconnections/connections/GitHubAppConnections-1", requested[0]) + assert.Equal(t, githubconnections.ConnectionStatusConnected, connection.Status) + assert.Equal(t, "All good", connection.StatusUserMessage) + require.Len(t, connection.Repositories, 1) + assert.Equal(t, "https://github.com/OctopusDeploy/hub.git", connection.Repositories[0].GitURL) + assert.Equal(t, "main", connection.Repositories[0].DefaultBranch) + require.Len(t, connection.UnknownRepositories, 1) + assert.Equal(t, "R_2", connection.UnknownRepositories[0].RepositoryID) +} + +func TestGetByIDWithEmptyID(t *testing.T) { + var requested []string + client := newTestClient(t, &requested, `{}`) + + connection, err := GetByID(client, "") + require.Error(t, err) + require.Nil(t, connection) + assert.Empty(t, requested) +} + +func TestGetRepositories(t *testing.T) { + const payload = `{ + "Repositories": [ + { "RepositoryId": "R_1", "RepositoryName": "hub", "GitUrl": "https://github.com/OctopusDeploy/hub.git", "DefaultBranch": "main" }, + { "RepositoryId": "R_2", "RepositoryName": "other", "GitUrl": "https://github.com/OctopusDeploy/other.git", "DefaultBranch": "trunk" } + ] + }` + + var requested []string + client := newTestClient(t, &requested, payload) + + repositories, err := GetRepositories(client, "GitHubAppConnections-1") + require.NoError(t, err) + + assert.Equal(t, "/api/platformhub/githubconnections/connections/GitHubAppConnections-1/repositories", requested[0]) + require.Len(t, repositories, 2) + assert.Equal(t, "trunk", repositories[1].DefaultBranch) +} + +func TestGetRepositoriesWithEmptyConnectionID(t *testing.T) { + var requested []string + client := newTestClient(t, &requested, `{}`) + + repositories, err := GetRepositories(client, "") + require.Error(t, err) + require.Nil(t, repositories) + assert.Empty(t, requested) +} diff --git a/pkg/platformhubversioncontrolsettings/resource.go b/pkg/platformhubversioncontrolsettings/resource.go index 79a4efe5..058ae83f 100644 --- a/pkg/platformhubversioncontrolsettings/resource.go +++ b/pkg/platformhubversioncontrolsettings/resource.go @@ -78,7 +78,15 @@ func (r *Resource) UnmarshalJSON(b []byte) error { return err } r.Credentials = usernamePasswordCredential + case credentials.GitCredentialTypeGitHubApp: + var gitHubAppCredential *credentials.GitHubApp + if err := json.Unmarshal(*credentialsRaw, &gitHubAppCredential); err != nil { + return err + } + r.Credentials = gitHubAppCredential } + // GitCredentialTypeReference isn't handled as using a reference credential + // (ReferencePlatformHubGitCredentialUsageResource) isn't fully supported yet return nil } diff --git a/pkg/platformhubversioncontrolsettings/resource_test.go b/pkg/platformhubversioncontrolsettings/resource_test.go new file mode 100644 index 00000000..2987ba75 --- /dev/null +++ b/pkg/platformhubversioncontrolsettings/resource_test.go @@ -0,0 +1,96 @@ +package platformhubversioncontrolsettings + +import ( + "encoding/json" + "testing" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/credentials" + "github.com/stretchr/testify/require" +) + +func TestResource_UnmarshalJSON_Anonymous(t *testing.T) { + const payload = `{ + "Url": "https://github.com/OctopusDeploy/hub.git", + "DefaultBranch": "main", + "BasePath": ".octopus/", + "Credentials": { "Type": "Anonymous" } + }` + + var resource Resource + require.NoError(t, json.Unmarshal([]byte(payload), &resource)) + + require.Equal(t, "https://github.com/OctopusDeploy/hub.git", resource.URL) + require.Equal(t, "main", resource.DefaultBranch) + require.Equal(t, ".octopus/", resource.BasePath) + + anonymous, ok := resource.Credentials.(*credentials.Anonymous) + require.True(t, ok) + require.Equal(t, credentials.GitCredentialTypeAnonymous, anonymous.Type()) +} + +func TestResource_UnmarshalJSON_UsernamePassword(t *testing.T) { + const payload = `{ + "Url": "https://github.com/OctopusDeploy/hub.git", + "DefaultBranch": "main", + "BasePath": ".octopus/", + "Credentials": { "Type": "UsernamePassword", "Username": "octobob", "Password": { "HasValue": true } } + }` + + var resource Resource + require.NoError(t, json.Unmarshal([]byte(payload), &resource)) + + usernamePassword, ok := resource.Credentials.(*credentials.UsernamePassword) + require.True(t, ok) + require.Equal(t, credentials.GitCredentialTypeUsernamePassword, usernamePassword.Type()) + require.Equal(t, "octobob", usernamePassword.Username) + require.NotNil(t, usernamePassword.Password) + require.True(t, usernamePassword.Password.HasValue) +} + +func TestResource_UnmarshalJSON_GitHubApp(t *testing.T) { + const payload = `{ + "Url": "https://github.com/OctopusDeploy/hub.git", + "DefaultBranch": "main", + "BasePath": ".octopus/", + "Credentials": { "Type": "GitHub", "Id": "GitHubAppConnections-1" } + }` + + var resource Resource + require.NoError(t, json.Unmarshal([]byte(payload), &resource)) + + gitHubApp, ok := resource.Credentials.(*credentials.GitHubApp) + require.True(t, ok) + require.Equal(t, credentials.GitCredentialTypeGitHubApp, gitHubApp.Type()) + require.Equal(t, "GitHubAppConnections-1", gitHubApp.ID) +} + +func TestResource_RoundTrip(t *testing.T) { + testCases := []struct { + name string + credentials credentials.GitCredential + }{ + {"anonymous", credentials.NewAnonymous()}, + {"username password", credentials.NewUsernamePassword("octobob", core.NewSensitiveValue("secret"))}, + {"githubconnections app", credentials.NewGitHubApp("GitHubAppConnections-1")}, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + original := NewResource("https://github.com/OctopusDeploy/hub.git", testCase.credentials, "main", ".octopus/") + + data, err := json.Marshal(original) + require.NoError(t, err) + + var actual Resource + require.NoError(t, json.Unmarshal(data, &actual)) + + require.Equal(t, original.URL, actual.URL) + require.Equal(t, original.DefaultBranch, actual.DefaultBranch) + require.Equal(t, original.BasePath, actual.BasePath) + require.NotNil(t, actual.Credentials) + require.Equal(t, original.Credentials.Type(), actual.Credentials.Type()) + require.Equal(t, original.Credentials, actual.Credentials) + }) + } +} diff --git a/pkg/processtemplates/process_template.go b/pkg/processtemplates/process_template.go new file mode 100644 index 00000000..e59e9e4d --- /dev/null +++ b/pkg/processtemplates/process_template.go @@ -0,0 +1,43 @@ +package processtemplates + +import ( + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/deployments" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/variables" +) + +// Icon represents the icon shown against a process template. +type Icon struct { + // ID is the Font Awesome icon identifier. + ID string `json:"Id"` + // Color is the icon background color, as a hex string. + Color string `json:"Color"` +} + +// ProcessTemplate represents a Platform Hub process template, including its steps and parameters. +type ProcessTemplate struct { + ID string `json:"Id,omitempty"` + Name string `json:"Name"` + GitRef string `json:"GitRef"` + Slug string `json:"Slug"` + Description string `json:"Description,omitempty"` + Icon *Icon `json:"Icon,omitempty"` + Steps []*deployments.DeploymentStep `json:"Steps,omitempty"` + Parameters []*Parameter `json:"Parameters,omitempty"` +} + +// Parameter represents a parameter in a process template. +type Parameter struct { + Name string `json:"Name"` + Label string `json:"Label,omitempty"` + HelpText string `json:"HelpText,omitempty"` + IsOptional bool `json:"IsOptional"` + DisplaySettings map[string]string `json:"DisplaySettings,omitempty"` + Values []*ParameterValue `json:"Values,omitempty"` +} + +// ParameterValue represents a scoped default value for a process template parameter. +type ParameterValue struct { + Value core.PropertyValue `json:"Value"` + Scope variables.VariableScope `json:"Scope"` +} diff --git a/pkg/processtemplates/service.go b/pkg/processtemplates/service.go new file mode 100644 index 00000000..5fab7491 --- /dev/null +++ b/pkg/processtemplates/service.go @@ -0,0 +1,90 @@ +package processtemplates + +import ( + "github.com/OctopusDeploy/go-octopusdeploy/v2/internal" + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/newclient" +) + +const ( + template = "/api/platformhub/{gitRef}/processtemplates{/slug}{?skip,take}" +) + +// ProcessTemplatesQuery represents query parameters for listing process templates. +type ProcessTemplatesQuery struct { + GitRef string `uri:"gitRef" json:"gitRef"` + Skip int `uri:"skip,omitempty" json:"skip,omitempty"` + Take int `uri:"take,omitempty" json:"take,omitempty"` +} + +// ProcessTemplatesQueryResult is a paginated collection of process templates. +type ProcessTemplatesQueryResult struct { + ProcessTemplates []*ProcessTemplate `json:"ProcessTemplates"` + TotalResults int `json:"TotalResults"` + ItemsPerPage int `json:"ItemsPerPage"` +} + +// List returns a paginated collection of process templates. +func List(client newclient.Client, query ProcessTemplatesQuery) (*ProcessTemplatesQueryResult, error) { + if internal.IsEmpty(query.GitRef) { + return nil, internal.CreateInvalidParameterError("List", "GitRef") + } + + path, err := client.URITemplateCache().Expand(template, query) + if err != nil { + return nil, err + } + + return newclient.Get[ProcessTemplatesQueryResult](client.HttpSession(), path) +} + +// GetBySlug returns the process template that matches the given slug on the given Git reference. +func GetBySlug(client newclient.Client, gitRef string, slug string) (*ProcessTemplate, error) { + if internal.IsEmpty(gitRef) { + return nil, internal.CreateInvalidParameterError("GetBySlug", "gitRef") + } + if internal.IsEmpty(slug) { + return nil, internal.CreateInvalidParameterError("GetBySlug", "slug") + } + + path, err := client.URITemplateCache().Expand(template, map[string]any{"gitRef": gitRef, "slug": slug}) + if err != nil { + return nil, err + } + + return newclient.Get[ProcessTemplate](client.HttpSession(), path) +} + +// createProcessTemplateCommand creates an empty process template. Steps and parameters +// cannot be set at creation time. +type createProcessTemplateCommand struct { + GitRef string `json:"GitRef"` + Name string `json:"Name"` + Description string `json:"Description,omitempty"` + // ChangeDescription becomes the git commit message for the create operation. + ChangeDescription string `json:"ChangeDescription,omitempty"` +} + +// Add creates an empty process template on the given Git reference. changeDescription +// becomes the git commit message. +func Add(client newclient.Client, gitRef string, name string, description string, changeDescription string) (*ProcessTemplate, error) { + if internal.IsEmpty(gitRef) { + return nil, internal.CreateInvalidParameterError("Add", "gitRef") + } + if internal.IsEmpty(name) { + return nil, internal.CreateInvalidParameterError("Add", "name") + } + + path, err := client.URITemplateCache().Expand(template, map[string]any{"gitRef": gitRef}) + if err != nil { + return nil, err + } + + command := createProcessTemplateCommand{ + GitRef: gitRef, + Name: name, + Description: description, + ChangeDescription: changeDescription, + } + + return newclient.Post[ProcessTemplate](client.HttpSession(), path, command) +} diff --git a/pkg/processtemplates/service_test.go b/pkg/processtemplates/service_test.go new file mode 100644 index 00000000..fa0ccb2e --- /dev/null +++ b/pkg/processtemplates/service_test.go @@ -0,0 +1,167 @@ +package processtemplates + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/newclient" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type recordedRequest struct { + uri string + body string +} + +func newTestClient(t *testing.T, recorded *[]recordedRequest, payload string) newclient.Client { + t.Helper() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + *recorded = append(*recorded, recordedRequest{uri: r.URL.RequestURI(), body: string(body)}) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(payload)) + })) + t.Cleanup(server.Close) + + baseURL, err := url.Parse(server.URL + "/") + require.NoError(t, err) + + return newclient.NewClient(&newclient.HttpSession{HttpClient: server.Client(), BaseURL: baseURL}) +} + +func TestList(t *testing.T) { + const payload = `{ + "ProcessTemplates": [ + { + "Id": "refs/heads/main:my-template", + "Name": "My Template", + "GitRef": "refs/heads/main", + "Slug": "my-template", + "Steps": [{ "Name": "Run a script", "Actions": [] }], + "Parameters": [] + } + ], + "TotalResults": 1, + "ItemsPerPage": 30 + }` + + var recorded []recordedRequest + client := newTestClient(t, &recorded, payload) + + result, err := List(client, ProcessTemplatesQuery{GitRef: "refs/heads/main", Take: 10}) + require.NoError(t, err) + + assert.Equal(t, "/api/platformhub/refs%2Fheads%2Fmain/processtemplates?take=10", recorded[0].uri) + require.Len(t, result.ProcessTemplates, 1) + require.Len(t, result.ProcessTemplates[0].Steps, 1) + assert.Equal(t, "Run a script", result.ProcessTemplates[0].Steps[0].Name) +} + +func TestGetBySlug(t *testing.T) { + const payload = `{ + "Id": "refs/heads/main:my-template", + "Name": "My Template", + "GitRef": "refs/heads/main", + "Slug": "my-template", + "Description": "Does a thing", + "Steps": [{ "Name": "Run a script" }], + "Parameters": [ + { + "Name": "Environment", + "Label": "Environment", + "HelpText": "Where to deploy", + "IsOptional": false, + "DisplaySettings": { "Octopus.ControlType": "SingleLineText" }, + "Values": [{ "Value": "Production", "Scope": { "Environment": ["Environments-1"] } }] + } + ] + }` + + var recorded []recordedRequest + client := newTestClient(t, &recorded, payload) + + template, err := GetBySlug(client, "refs/heads/main", "my-template") + require.NoError(t, err) + + assert.Equal(t, "/api/platformhub/refs%2Fheads%2Fmain/processtemplates/my-template", recorded[0].uri) + assert.Equal(t, "My Template", template.Name) + require.Len(t, template.Steps, 1) + require.Len(t, template.Parameters, 1) + + parameter := template.Parameters[0] + assert.Equal(t, "Environment", parameter.Name) + assert.Equal(t, "SingleLineText", parameter.DisplaySettings["Octopus.ControlType"]) + require.Len(t, parameter.Values, 1) + // PropertyValueResource serialises as a bare string when not sensitive. + assert.Equal(t, "Production", parameter.Values[0].Value.Value) + assert.False(t, parameter.Values[0].Value.IsSensitive) + assert.Equal(t, []string{"Environments-1"}, parameter.Values[0].Scope.Environments) +} + +func TestGetBySlugWithEmptyArguments(t *testing.T) { + var recorded []recordedRequest + client := newTestClient(t, &recorded, `{}`) + + _, err := GetBySlug(client, "", "my-template") + require.Error(t, err) + + _, err = GetBySlug(client, "refs/heads/main", "") + require.Error(t, err) + + assert.Empty(t, recorded) +} + +func TestAdd(t *testing.T) { + const payload = `{"Id":"refs/heads/main:my-template","Name":"My Template","GitRef":"refs/heads/main","Slug":"my-template"}` + + var recorded []recordedRequest + client := newTestClient(t, &recorded, payload) + + created, err := Add(client, "refs/heads/main", "My Template", "Does a thing", "Add My Template") + require.NoError(t, err) + + require.Len(t, recorded, 1) + assert.Equal(t, "/api/platformhub/refs%2Fheads%2Fmain/processtemplates", recorded[0].uri) + + var command map[string]any + require.NoError(t, json.Unmarshal([]byte(recorded[0].body), &command)) + assert.Equal(t, map[string]any{ + "GitRef": "refs/heads/main", + "Name": "My Template", + "Description": "Does a thing", + "ChangeDescription": "Add My Template", + }, command) + + assert.Equal(t, "my-template", created.Slug) +} + +func TestAddOmitsEmptyOptionalFields(t *testing.T) { + var recorded []recordedRequest + client := newTestClient(t, &recorded, `{"Name":"My Template","Slug":"my-template"}`) + + _, err := Add(client, "refs/heads/main", "My Template", "", "") + require.NoError(t, err) + + var command map[string]any + require.NoError(t, json.Unmarshal([]byte(recorded[0].body), &command)) + assert.Equal(t, map[string]any{"GitRef": "refs/heads/main", "Name": "My Template"}, command) +} + +func TestAddWithEmptyArguments(t *testing.T) { + var recorded []recordedRequest + client := newTestClient(t, &recorded, `{}`) + + _, err := Add(client, "", "My Template", "", "") + require.Error(t, err) + + _, err = Add(client, "refs/heads/main", "", "", "") + require.Error(t, err) + + assert.Empty(t, recorded) +}