From 3150324e3109c47ed502b216754a925726d7a1cc Mon Sep 17 00:00:00 2001 From: Adam Harvey Date: Sun, 24 Oct 2021 20:26:44 -0400 Subject: [PATCH 01/12] Fix punctuation --- README.md | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/README.md b/README.md index f01bc0d73..fbc727f75 100644 --- a/README.md +++ b/README.md @@ -95,8 +95,7 @@ CircleCI host has been set. Setup complete. Your configuration has been saved. ``` - -If you are using this tool on `circleci.com`. accept the provided default `CircleCI Host`. +If you are using this tool on `circleci.com`, accept the provided default `CircleCI Host`. Server users will have to change the default value to your custom address (i.e. `circleci.my-org.com`). From 079896e0278e590b37194ca9a70c30b760f8b802 Mon Sep 17 00:00:00 2001 From: Adam Harvey Date: Sun, 24 Oct 2021 20:27:53 -0400 Subject: [PATCH 02/12] Fix id est to exempli gratia --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index fbc727f75..bb8c85cda 100644 --- a/README.md +++ b/README.md @@ -97,7 +97,7 @@ Setup complete. Your configuration has been saved. If you are using this tool on `circleci.com`, accept the provided default `CircleCI Host`. -Server users will have to change the default value to your custom address (i.e. `circleci.my-org.com`). +Server users will have to change the default value to your custom address (e.g., `circleci.my-org.com`). **Note**: Server does not yet support config processing and orbs, you will only be able to use `circleci local execute` (previously `circleci build`) for now. From c9cb0d84f52f7987df0f3a6cb6022c075197704c Mon Sep 17 00:00:00 2001 From: Adam Harvey Date: Sun, 24 Oct 2021 20:29:34 -0400 Subject: [PATCH 03/12] Fix broken links --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bb8c85cda..e7186785e 100644 --- a/README.md +++ b/README.md @@ -135,7 +135,7 @@ The following commands are affected: ## Platforms, Deployment and Package Managers -The tool is deployed through a number of channels. The primary release channel is through [GitHub Releases](https://github.com/CircleCI-Public/circleci-cli/releases). Green builds on the `master` branch will publish a new GitHub release. These releases contain binaries for macOS, Linux and Windows. These releases are published from (CircleCI)[https://app.circleci.com/pipelines/github/CircleCI-Public/circleci-cli] using (GoReleaser)[https://goreleaser.com/]. +The tool is deployed through a number of channels. The primary release channel is through [GitHub Releases](https://github.com/CircleCI-Public/circleci-cli/releases). Green builds on the `master` branch will publish a new GitHub release. These releases contain binaries for macOS, Linux and Windows. These releases are published from [CircleCI](https://app.circleci.com/pipelines/github/CircleCI-Public/circleci-cli) using [GoReleaser](https://goreleaser.com/). ### Homebrew From 7ddda802701c51e97be8b06be3f5db5c7d6fa214 Mon Sep 17 00:00:00 2001 From: Adam Harvey Date: Sun, 24 Oct 2021 20:36:19 -0400 Subject: [PATCH 04/12] Fix typo in UK English --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e7186785e..b00a78c00 100644 --- a/README.md +++ b/README.md @@ -139,7 +139,7 @@ The tool is deployed through a number of channels. The primary release channel i ### Homebrew -We publish the tool to [Homebrew](https://brew.sh/). The tool is [part of `homebrew-core`](https://github.com/Homebrew/homebrew-core/blob/master/Formula/circleci.rb), and therefore the maintainers of the tool are obligated to follow the guidelines for acceptable Homebrew formulae. You should [familairise yourself with the guidelines](https://docs.brew.sh/Acceptable-Formulae#we-dont-like-tools-that-upgrade-themselves) before making changes to the Homebrew deployment system. +We publish the tool to [Homebrew](https://brew.sh/). The tool is [part of `homebrew-core`](https://github.com/Homebrew/homebrew-core/blob/master/Formula/circleci.rb), and therefore the maintainers of the tool are obligated to follow the guidelines for acceptable Homebrew formulae. You should [familiarise yourself with the guidelines](https://docs.brew.sh/Acceptable-Formulae#we-dont-like-tools-that-upgrade-themselves) before making changes to the Homebrew deployment system. The particular considerations that we make are: From eb9f60fefcfee602ff58cba7a985b652a0cc54d0 Mon Sep 17 00:00:00 2001 From: Peter Burns Date: Mon, 6 Dec 2021 09:31:24 -0500 Subject: [PATCH 05/12] Add pre-commit hook for validating configuration --- .pre-commit-hooks.yaml | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .pre-commit-hooks.yaml diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml new file mode 100644 index 000000000..7aeda5c04 --- /dev/null +++ b/.pre-commit-hooks.yaml @@ -0,0 +1,6 @@ +--- +- id: config-validate + name: Validate CircleCI config + entry: circleci config validate --skip-update-check + language: golang + files: .circleci/config.yml From fdb06140c989cf5a1cf128df034a219030615ab5 Mon Sep 17 00:00:00 2001 From: Adam Harvey Date: Thu, 6 Oct 2022 23:17:37 -0400 Subject: [PATCH 06/12] Add setup key to packed orb examples Signed-off-by: Adam Harvey --- cmd/orb.go | 1 + cmd/orb_test.go | 31 +++++++++++++++++++++++++++---- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/cmd/orb.go b/cmd/orb.go index 2650a720e..c20f4e62b 100644 --- a/cmd/orb.go +++ b/cmd/orb.go @@ -961,6 +961,7 @@ type OrbSchema struct { type ExampleUsageSchema struct { Version string `yaml:"version,omitempty"` + Setup bool `yaml:"setup,omitempty"` Orbs interface{} `yaml:"orbs,omitempty"` Jobs interface{} `yaml:"jobs,omitempty"` Workflows interface{} `yaml:"workflows"` diff --git a/cmd/orb_test.go b/cmd/orb_test.go index f10100bb7..191287e8a 100644 --- a/cmd/orb_test.go +++ b/cmd/orb_test.go @@ -3311,6 +3311,20 @@ Windows Server 2010 - run: name: Say hello command: <> + +examples: + example: + description: | + An example of how to use the orb. + usage: + version: 2.1 + orbs: + orb-name: company/orb-name@1.2.3 + setup: true + workflows: + create-pipeline: + jobs: + orb-name: create-pipeline-x `)) script = clitest.OpenTmpFile(tempSettings.Home, filepath.Join("scripts", "script.sh")) script.Write([]byte(`echo Hello, world!`)) @@ -3330,10 +3344,7 @@ Windows Server 2010 It("Includes a script in the packed Orb file", func() { session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter) Expect(err).ShouldNot(HaveOccurred()) - - Eventually(session.Out).Should(gbytes.Say(`commands: - orb: - steps: + Eventually(session.Out).Should(gbytes.Say(`steps: - run: command: echo Hello, world! name: Say hello @@ -3341,5 +3352,17 @@ Windows Server 2010 Eventually(session).Should(gexec.Exit(0)) }) + It("Includes the setup key when an orb example uses a dynamic pipeline", func() { + session, err := gexec.Start(command, GinkgoWriter, GinkgoWriter) + Expect(err).ShouldNot(HaveOccurred()) + Eventually(session.Out).Should(gbytes.Say(`orbs: + orb-name: company/orb-name@1.2.3 + setup: true + version: 2.1 + workflows: +`)) + Eventually(session).Should(gexec.Exit(0)) + }) + }) }) From 6776657c4c1a39eea5712cd601692fe9b3ad83dd Mon Sep 17 00:00:00 2001 From: Chi Leung Date: Mon, 13 Mar 2023 23:31:52 +0000 Subject: [PATCH 07/12] Add --docker-socket-path flag --- local/local.go | 11 +++++++---- local/local_test.go | 2 +- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/local/local.go b/local/local.go index 373a88dd2..bf1fcb7f1 100644 --- a/local/local.go +++ b/local/local.go @@ -23,6 +23,7 @@ import ( var picardRepo = "circleci/picard" const DefaultConfigPath = ".circleci/config.yml" +const DefaultDockerSocketPath = "/var/run/docker.sock" func Execute(flags *pflag.FlagSet, cfg *settings.Config, args []string) error { var err error @@ -83,7 +84,8 @@ func Execute(flags *pflag.FlagSet, cfg *settings.Config, args []string) error { } job := args[0] - arguments := generateDockerCommand(processedConfigPath, image, pwd, job, processedArgs...) + dockerSocketPath, _ := flags.GetString("docker-socket-path") + arguments := generateDockerCommand(processedConfigPath, image, pwd, job, dockerSocketPath, processedArgs...) if cfg.Debug { _, err = fmt.Fprintf(os.Stderr, "Starting docker with args: %s", arguments) @@ -117,6 +119,7 @@ func AddFlagsForDocumentation(flags *pflag.FlagSet) { flags.String("branch", "", "Git branch") flags.String("repo-url", "", "Git Url") flags.StringArrayP("env", "e", nil, "Set environment variables, e.g. `-e VAR=VAL`") + flags.String("docker-socket-path", DefaultDockerSocketPath, "Path to the host's docker socket") } // Given the full set of flags that were passed to this command, return the path @@ -134,7 +137,7 @@ func buildAgentArguments(flags *pflag.FlagSet) ([]string, string) { // build a list of all supplied flags, that we will pass on to build-agent flags.Visit(func(flag *pflag.Flag) { - if flag.Name != "build-agent-version" && flag.Name != "org-slug" && flag.Name != "config" && flag.Name != "debug" && flag.Name != "org-id" { + if flag.Name != "build-agent-version" && flag.Name != "org-slug" && flag.Name != "config" && flag.Name != "debug" && flag.Name != "org-id" && flag.Name != "docker-socket-path" { result = append(result, unparseFlag(flags, flag)...) } }) @@ -275,10 +278,10 @@ func writeStringToTempFile(data string) (string, error) { return f.Name(), nil } -func generateDockerCommand(configPath, image, pwd string, job string, arguments ...string) []string { +func generateDockerCommand(configPath, image, pwd string, job string, dockerSocketPath string, arguments ...string) []string { const configPathInsideContainer = "/tmp/local_build_config.yml" core := []string{"docker", "run", "--interactive", "--tty", "--rm", - "--volume", "/var/run/docker.sock:/var/run/docker.sock", + "--volume", fmt.Sprintf("%s:/var/run/docker.sock", dockerSocketPath), "--volume", fmt.Sprintf("%s:%s", configPath, configPathInsideContainer), "--volume", fmt.Sprintf("%s:%s", pwd, pwd), "--volume", fmt.Sprintf("%s:/root/.circleci", settings.SettingsPath()), diff --git a/local/local_test.go b/local/local_test.go index c316f045b..9f6c0ade5 100644 --- a/local/local_test.go +++ b/local/local_test.go @@ -17,7 +17,7 @@ var _ = Describe("build", func() { It("can generate a command line", func() { home, err := os.UserHomeDir() Expect(err).NotTo(HaveOccurred()) - Expect(generateDockerCommand("/config/path", "docker-image-name", "/current/directory", "build", "extra-1", "extra-2")).To(ConsistOf( + Expect(generateDockerCommand("/config/path", "docker-image-name", "/current/directory", "build", "/var/run/docker.sock", "extra-1", "extra-2")).To(ConsistOf( "docker", "run", "--interactive", From d03951c6a960aa58c4be2b5c55407043a6dc6712 Mon Sep 17 00:00:00 2001 From: rlegan Date: Fri, 14 Apr 2023 15:31:18 +0200 Subject: [PATCH 08/12] Add warning on some CLI commands that orbs cannot be deleted once they are created --- cmd/orb.go | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/cmd/orb.go b/cmd/orb.go index 33a06c855..b0724c18c 100644 --- a/cmd/orb.go +++ b/cmd/orb.go @@ -719,6 +719,7 @@ func processOrb(opts orbOptions) error { } func publishOrb(opts orbOptions) error { + orbInformThatOrbCannotBeDeletedMessage() path := opts.args[0] ref := opts.args[1] namespace, orb, version, err := references.SplitIntoOrbNamespaceAndVersion(ref) @@ -794,6 +795,8 @@ func validateSegmentArg(label string) error { } func incrementOrb(opts orbOptions) error { + orbInformThatOrbCannotBeDeletedMessage() + ref := opts.args[1] segment := opts.args[2] @@ -822,6 +825,8 @@ func incrementOrb(opts orbOptions) error { } func promoteOrb(opts orbOptions) error { + orbInformThatOrbCannotBeDeletedMessage() + ref := opts.args[0] segment := opts.args[1] @@ -1104,6 +1109,8 @@ func initOrb(opts orbOptions) error { var err error fmt.Println("Note: This command is in preview. Please report any bugs! https://github.com/CircleCI-Public/circleci-cli/issues/new/choose") + orbInformThatOrbCannotBeDeletedMessage() + fullyAutomated := 0 prompt := &survey.Select{ Message: "Would you like to perform an automated setup of this orb?", @@ -1734,3 +1741,8 @@ func stringifyDiff(diff gotextdiff.Unified, colorOpt string) string { color.NoColor = oldNoColor return strings.Join(lines, "\n") } + +func orbInformThatOrbCannotBeDeletedMessage() { + fmt.Println("Once an orb is created it cannot be deleted. Orbs are semver compliant, and each published version is immutable. Publicly released orbs are potential dependencies for other projects.") + fmt.Println("Therefore, allowing orb deletion would make users susceptible to unexpected loss of functionality.") +} From d710994d37524e1fda4031dea4a0fc505e9704fa Mon Sep 17 00:00:00 2001 From: zbenhadi Date: Fri, 28 Apr 2023 11:01:17 +0200 Subject: [PATCH 09/12] Add org-slug and org-id flags to orb validate & process commands (#922) --- api/api.go | 11 +- api/collaborators/collaborators.go | 14 + api/collaborators/collaborators_rest.go | 64 ++++ api/collaborators/collaborators_rest_test.go | 380 +++++++++++++++++++ cmd/orb.go | 98 ++++- cmd/orb_test.go | 14 +- config/collaborators.go | 39 -- config/collaborators_test.go | 43 --- config/commands.go | 34 +- config/config.go | 21 +- 10 files changed, 590 insertions(+), 128 deletions(-) create mode 100644 api/collaborators/collaborators.go create mode 100644 api/collaborators/collaborators_rest.go create mode 100644 api/collaborators/collaborators_rest_test.go delete mode 100644 config/collaborators.go delete mode 100644 config/collaborators_test.go diff --git a/api/api.go b/api/api.go index ac29d7c1f..f94c64401 100644 --- a/api/api.go +++ b/api/api.go @@ -513,7 +513,7 @@ func WhoamiQuery(cl *graphql.Client) (*WhoamiResponse, error) { } // OrbQuery validated and processes an orb. -func OrbQuery(cl *graphql.Client, configPath string) (*ConfigResponse, error) { +func OrbQuery(cl *graphql.Client, configPath string, ownerId string) (*ConfigResponse, error) { var response OrbConfigResponse config, err := loadYaml(configPath) @@ -522,8 +522,8 @@ func OrbQuery(cl *graphql.Client, configPath string) (*ConfigResponse, error) { } query := ` - query ValidateOrb ($config: String!) { - orbConfig(orbYaml: $config) { + query ValidateOrb ($config: String!, $owner: UUID) { + orbConfig(orbYaml: $config, ownerId: $owner) { valid, errors { message }, sourceYaml, @@ -533,6 +533,11 @@ func OrbQuery(cl *graphql.Client, configPath string) (*ConfigResponse, error) { request := graphql.NewRequest(query) request.Var("config", config) + + if ownerId != "" { + request.Var("owner", ownerId) + } + request.SetToken(cl.Token) err = cl.Run(request, &response) diff --git a/api/collaborators/collaborators.go b/api/collaborators/collaborators.go new file mode 100644 index 000000000..6fecf61f9 --- /dev/null +++ b/api/collaborators/collaborators.go @@ -0,0 +1,14 @@ +package collaborators + +type CollaborationResult struct { + VcsType string `json:"vcs_type"` + OrgSlug string `json:"slug"` + OrgName string `json:"name"` + OrgId string `json:"id"` + AvatarUrl string `json:"avatar_url"` +} + +type CollaboratorsClient interface { + GetCollaborationBySlug(slug string) (*CollaborationResult, error) + GetOrgCollaborations() ([]CollaborationResult, error) +} diff --git a/api/collaborators/collaborators_rest.go b/api/collaborators/collaborators_rest.go new file mode 100644 index 000000000..e15f7774e --- /dev/null +++ b/api/collaborators/collaborators_rest.go @@ -0,0 +1,64 @@ +package collaborators + +import ( + "net/url" + "strings" + + "github.com/CircleCI-Public/circleci-cli/api/rest" + "github.com/CircleCI-Public/circleci-cli/settings" +) + +var ( + CollaborationsPath = "me/collaborations" +) + +type collaboratorsRestClient struct { + client *rest.Client +} + +// NewCollaboratorsRestClient returns a new collaboratorsRestClient satisfying the api.CollaboratorsClient +// interface via the REST API. +func NewCollaboratorsRestClient(config settings.Config) (*collaboratorsRestClient, error) { + client := &collaboratorsRestClient{ + client: rest.NewFromConfig(config.Host, &config), + } + return client, nil +} + +func (c *collaboratorsRestClient) GetOrgCollaborations() ([]CollaborationResult, error) { + req, err := c.client.NewRequest("GET", &url.URL{Path: CollaborationsPath}, nil) + if err != nil { + return nil, err + } + + var resp []CollaborationResult + _, err = c.client.DoRequest(req, &resp) + return resp, err +} + +func (c *collaboratorsRestClient) GetCollaborationBySlug(slug string) (*CollaborationResult, error) { + // Support for / as well as / for the slug + // requires splitting + collaborations, err := c.GetOrgCollaborations() + + if err != nil { + return nil, err + } + + slugParts := strings.Split(slug, "/") + + for _, v := range collaborations { + // The rest-api allways returns / as a slug + if v.OrgSlug == slug { + return &v, nil + } + + // Compare first part of argument slug with the VCSType + splitted := strings.Split(v.OrgSlug, "/") + if len(slugParts) >= 2 && len(splitted) >= 2 && slugParts[0] == v.VcsType && slugParts[1] == splitted[1] { + return &v, nil + } + } + + return nil, nil +} diff --git a/api/collaborators/collaborators_rest_test.go b/api/collaborators/collaborators_rest_test.go new file mode 100644 index 000000000..333539c68 --- /dev/null +++ b/api/collaborators/collaborators_rest_test.go @@ -0,0 +1,380 @@ +package collaborators_test + +import ( + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/CircleCI-Public/circleci-cli/api/collaborators" + "github.com/CircleCI-Public/circleci-cli/settings" + "github.com/CircleCI-Public/circleci-cli/version" + "gotest.tools/v3/assert" +) + +func getCollaboratorsRestClient(server *httptest.Server) (collaborators.CollaboratorsClient, error) { + client := &http.Client{} + + return collaborators.NewCollaboratorsRestClient(settings.Config{ + RestEndpoint: "api/v2", + Host: server.URL, + HTTPClient: client, + Token: "token", + }) +} + +func Test_collaboratorsRestClient_GetOrgCollaborations(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + want []collaborators.CollaborationResult + wantErr bool + }{ + { + name: "Should handle a successful request", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("circle-token"), "token") + assert.Equal(t, r.Header.Get("accept"), "application/json") + assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent()) + + assert.Equal(t, r.Method, "GET") + assert.Equal(t, r.URL.Path, "/api/v2/me/collaborations") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(` + [ + { + "vcs_type": "github", + "slug": "gh/example", + "name": "Example Org", + "id": "some-uuid-123", + "avatar_url": "http://placekitten.com/200/300" + }, + { + "vcs_type": "bitbucket", + "slug": "bb/other", + "name": "Other Org", + "id": "some-uuid-789", + "avatar_url": "http://placekitten.com/200/300" + } + ] + `)) + + assert.NilError(t, err) + }, + want: []collaborators.CollaborationResult{ + { + VcsType: "github", + OrgSlug: "gh/example", + OrgName: "Example Org", + OrgId: "some-uuid-123", + AvatarUrl: "http://placekitten.com/200/300", + }, + { + VcsType: "bitbucket", + OrgSlug: "bb/other", + OrgName: "Other Org", + OrgId: "some-uuid-789", + AvatarUrl: "http://placekitten.com/200/300", + }, + }, + wantErr: false, + }, + { + name: "Should handle an error request", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write([]byte(`{"message": "error"}`)) + assert.NilError(t, err) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(tt.handler) + defer server.Close() + + c, err := getCollaboratorsRestClient(server) + assert.NilError(t, err) + + got, err := c.GetOrgCollaborations() + if (err != nil) != tt.wantErr { + t.Errorf("collaboratorsRestClient.GetOrgCollaborations() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("collaboratorsRestClient.GetOrgCollaborations() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_collaboratorsRestClient_GetCollaborationBySlug(t *testing.T) { + type args struct { + slug string + } + tests := []struct { + name string + handler http.HandlerFunc + args args + want *collaborators.CollaborationResult + wantErr bool + }{ + { + name: "Should work with short-vcs notation (github)", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("circle-token"), "token") + assert.Equal(t, r.Header.Get("accept"), "application/json") + assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent()) + + assert.Equal(t, r.Method, "GET") + assert.Equal(t, r.URL.Path, "/api/v2/me/collaborations") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(` + [ + { + "vcs_type": "github", + "slug": "gh/example", + "name": "Example Org", + "id": "some-uuid-123", + "avatar_url": "http://placekitten.com/200/300" + }, + { + "vcs_type": "bitbucket", + "slug": "bb/other", + "name": "Other Org", + "id": "some-uuid-789", + "avatar_url": "http://placekitten.com/200/300" + } + ] + `)) + + assert.NilError(t, err) + }, + args: args{ + slug: "gh/example", + }, + want: &collaborators.CollaborationResult{ + VcsType: "github", + OrgSlug: "gh/example", + OrgName: "Example Org", + OrgId: "some-uuid-123", + AvatarUrl: "http://placekitten.com/200/300", + }, + wantErr: false, + }, + { + name: "Should work with vcs-name notation (github)", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("circle-token"), "token") + assert.Equal(t, r.Header.Get("accept"), "application/json") + assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent()) + + assert.Equal(t, r.Method, "GET") + assert.Equal(t, r.URL.Path, "/api/v2/me/collaborations") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(` + [ + { + "vcs_type": "github", + "slug": "gh/example", + "name": "Example Org", + "id": "some-uuid-123", + "avatar_url": "http://placekitten.com/200/300" + }, + { + "vcs_type": "bitbucket", + "slug": "bb/other", + "name": "Other Org", + "id": "some-uuid-789", + "avatar_url": "http://placekitten.com/200/300" + } + ] + `)) + + assert.NilError(t, err) + }, + args: args{ + slug: "github/example", + }, + want: &collaborators.CollaborationResult{ + VcsType: "github", + OrgSlug: "gh/example", + OrgName: "Example Org", + OrgId: "some-uuid-123", + AvatarUrl: "http://placekitten.com/200/300", + }, + wantErr: false, + }, + { + name: "Should work with vcs-short notation (bitbucket)", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("circle-token"), "token") + assert.Equal(t, r.Header.Get("accept"), "application/json") + assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent()) + + assert.Equal(t, r.Method, "GET") + assert.Equal(t, r.URL.Path, "/api/v2/me/collaborations") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(` + [ + { + "vcs_type": "github", + "slug": "gh/example", + "name": "Example Org", + "id": "some-uuid-123", + "avatar_url": "http://placekitten.com/200/300" + }, + { + "vcs_type": "bitbucket", + "slug": "bb/other", + "name": "Other Org", + "id": "some-uuid-789", + "avatar_url": "http://placekitten.com/200/300" + } + ] + `)) + + assert.NilError(t, err) + }, + args: args{ + slug: "bb/other", + }, + want: &collaborators.CollaborationResult{ + VcsType: "bitbucket", + OrgSlug: "bb/other", + OrgName: "Other Org", + OrgId: "some-uuid-789", + AvatarUrl: "http://placekitten.com/200/300", + }, + wantErr: false, + }, + { + name: "Should work with vcs-name notation (bitbucket)", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("circle-token"), "token") + assert.Equal(t, r.Header.Get("accept"), "application/json") + assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent()) + + assert.Equal(t, r.Method, "GET") + assert.Equal(t, r.URL.Path, "/api/v2/me/collaborations") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(` + [ + { + "vcs_type": "github", + "slug": "gh/example", + "name": "Example Org", + "id": "some-uuid-123", + "avatar_url": "http://placekitten.com/200/300" + }, + { + "vcs_type": "bitbucket", + "slug": "bb/other", + "name": "Other Org", + "id": "some-uuid-789", + "avatar_url": "http://placekitten.com/200/300" + } + ] + `)) + + assert.NilError(t, err) + }, + args: args{ + slug: "bitbucket/other", + }, + want: &collaborators.CollaborationResult{ + VcsType: "bitbucket", + OrgSlug: "bb/other", + OrgName: "Other Org", + OrgId: "some-uuid-789", + AvatarUrl: "http://placekitten.com/200/300", + }, + wantErr: false, + }, + { + name: "Should return nil if not found", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("circle-token"), "token") + assert.Equal(t, r.Header.Get("accept"), "application/json") + assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent()) + + assert.Equal(t, r.Method, "GET") + assert.Equal(t, r.URL.Path, "/api/v2/me/collaborations") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(` + [ + { + "vcs_type": "github", + "slug": "gh/example", + "name": "Example Org", + "id": "some-uuid-123", + "avatar_url": "http://placekitten.com/200/300" + }, + { + "vcs_type": "bitbucket", + "slug": "bb/other", + "name": "Other Org", + "id": "some-uuid-789", + "avatar_url": "http://placekitten.com/200/300" + } + ] + `)) + + assert.NilError(t, err) + }, + args: args{ + slug: "bad-slug", + }, + want: nil, + wantErr: false, + }, + { + name: "Should error if request errors", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write([]byte(`{"message": "error"}`)) + assert.NilError(t, err) + }, + args: args{ + slug: "bad-slug", + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(tt.handler) + defer server.Close() + + c, err := getCollaboratorsRestClient(server) + assert.NilError(t, err) + + got, err := c.GetCollaborationBySlug(tt.args.slug) + if (err != nil) != tt.wantErr { + t.Errorf("collaboratorsRestClient.GetCollaborationBySlug() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("collaboratorsRestClient.GetCollaborationBySlug() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/cmd/orb.go b/cmd/orb.go index b0724c18c..326b0a616 100644 --- a/cmd/orb.go +++ b/cmd/orb.go @@ -18,6 +18,7 @@ import ( "time" "github.com/CircleCI-Public/circleci-cli/api" + "github.com/CircleCI-Public/circleci-cli/api/collaborators" "github.com/CircleCI-Public/circleci-cli/api/graphql" "github.com/CircleCI-Public/circleci-cli/filetree" "github.com/CircleCI-Public/circleci-cli/process" @@ -43,9 +44,10 @@ import ( ) type orbOptions struct { - cfg *settings.Config - cl *graphql.Client - args []string + cfg *settings.Config + cl *graphql.Client + args []string + collaborators collaborators.CollaboratorsClient color string @@ -62,6 +64,11 @@ type orbOptions struct { integrationTesting bool } +type orbOrgOptions struct { + OrgID string + OrgSlug string +} + var orbAnnotations = map[string]string{ "": "The path to your orb (use \"-\" for STDIN)", "": "The namespace used for the orb (i.e. circleci)", @@ -93,9 +100,16 @@ func (ui createOrbTestUI) askUserToConfirm(message string) bool { } func newOrbCommand(config *settings.Config) *cobra.Command { + collaborators, err := collaborators.NewCollaboratorsRestClient(*config) + + if err != nil { + panic(err) + } + opts := orbOptions{ - cfg: config, - tty: createOrbInteractiveUI{}, + cfg: config, + tty: createOrbInteractiveUI{}, + collaborators: collaborators, } listCommand := &cobra.Command{ @@ -121,13 +135,23 @@ func newOrbCommand(config *settings.Config) *cobra.Command { validateCommand := &cobra.Command{ Use: "validate ", Short: "Validate an orb.yml", - RunE: func(_ *cobra.Command, _ []string) error { - return validateOrb(opts) + RunE: func(cmd *cobra.Command, _ []string) error { + orgID, _ := cmd.Flags().GetString("org-id") + orgSlug, _ := cmd.Flags().GetString("org-slug") + + org := orbOrgOptions{ + OrgID: orgID, + OrgSlug: orgSlug, + } + + return validateOrb(opts, org) }, Args: cobra.ExactArgs(1), Annotations: make(map[string]string), } validateCommand.Annotations[""] = orbAnnotations[""] + validateCommand.Flags().String("org-slug", "", "organization slug (for example: github/example-org), used when an orb depends on private orbs belonging to that org") + validateCommand.Flags().String("org-id", "", "organization id used when an orb depends on private orbs belonging to that org") processCommand := &cobra.Command{ Use: "process ", @@ -141,14 +165,24 @@ func newOrbCommand(config *settings.Config) *cobra.Command { opts.args = args opts.cl = graphql.NewClient(config.HTTPClient, config.Host, config.Endpoint, config.Token, config.Debug) }, - RunE: func(_ *cobra.Command, _ []string) error { - return processOrb(opts) + RunE: func(cmd *cobra.Command, _ []string) error { + orgID, _ := cmd.Flags().GetString("org-id") + orgSlug, _ := cmd.Flags().GetString("org-slug") + + org := orbOrgOptions{ + OrgID: orgID, + OrgSlug: orgSlug, + } + + return processOrb(opts, org) }, Args: cobra.ExactArgs(1), Annotations: make(map[string]string), } processCommand.Example = ` circleci orb process src/my-orb/@orb.yml` processCommand.Annotations[""] = orbAnnotations[""] + processCommand.Flags().String("org-slug", "", "organization slug (for example: github/example-org), used when an orb depends on private orbs belonging to that org") + processCommand.Flags().String("org-id", "", "organization id used when an orb depends on private orbs belonging to that org") publishCommand := &cobra.Command{ Use: "publish ", @@ -691,8 +725,14 @@ func listNamespaceOrbs(opts orbOptions) error { return logOrbs(*orbs, opts) } -func validateOrb(opts orbOptions) error { - _, err := api.OrbQuery(opts.cl, opts.args[0]) +func validateOrb(opts orbOptions, org orbOrgOptions) error { + orgId, err := (&opts).getOrgId(org) + + if err != nil { + return fmt.Errorf("failed to get the appropriate org-id: %s", err.Error()) + } + + _, err = api.OrbQuery(opts.cl, opts.args[0], orgId) if err != nil { return err @@ -707,8 +747,14 @@ func validateOrb(opts orbOptions) error { return nil } -func processOrb(opts orbOptions) error { - response, err := api.OrbQuery(opts.cl, opts.args[0]) +func processOrb(opts orbOptions, org orbOrgOptions) error { + orgId, err := (&opts).getOrgId(org) + + if err != nil { + return fmt.Errorf("failed to get the appropriate org-id: %s", err.Error()) + } + + response, err := api.OrbQuery(opts.cl, opts.args[0], orgId) if err != nil { return err @@ -1746,3 +1792,29 @@ func orbInformThatOrbCannotBeDeletedMessage() { fmt.Println("Once an orb is created it cannot be deleted. Orbs are semver compliant, and each published version is immutable. Publicly released orbs are potential dependencies for other projects.") fmt.Println("Therefore, allowing orb deletion would make users susceptible to unexpected loss of functionality.") } + +func (o *orbOptions) getOrgId(orgInfo orbOrgOptions) (string, error) { + if strings.TrimSpace(orgInfo.OrgID) != "" { + return orgInfo.OrgID, nil + } + + if strings.TrimSpace(orgInfo.OrgSlug) == "" { + return "", nil + } + + coll, err := o.collaborators.GetCollaborationBySlug(orgInfo.OrgSlug) + + if err != nil { + return "", err + } + + if coll == nil { + fmt.Println("Could not fetch a valid org-id from collaborators endpoint.") + fmt.Println("Check if you have access to this org by hitting https://circleci.com/api/v2/me/collaborations") + fmt.Println("Continuing on - private orb resolution will not work as intended") + + return "", nil + } + + return coll.OrgId, nil +} diff --git a/cmd/orb_test.go b/cmd/orb_test.go index e5a5f42b3..ded63dbe3 100644 --- a/cmd/orb_test.go +++ b/cmd/orb_test.go @@ -124,8 +124,8 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs } `json:"variables"` }{ Query: ` - query ValidateOrb ($config: String!) { - orbConfig(orbYaml: $config) { + query ValidateOrb ($config: String!, $owner: UUID) { + orbConfig(orbYaml: $config, ownerId: $owner) { valid, errors { message }, sourceYaml, @@ -184,7 +184,7 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs }` expectedRequestJson := ` { - "query": "\n\t\tquery ValidateOrb ($config: String!) {\n\t\t\torbConfig(orbYaml: $config) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", + "query": "\n\t\tquery ValidateOrb ($config: String!, $owner: UUID) {\n\t\t\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", "variables": { "config": "{}" } @@ -231,7 +231,7 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs }` expectedRequestJson := ` { - "query": "\n\t\tquery ValidateOrb ($config: String!) {\n\t\t\torbConfig(orbYaml: $config) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", + "query": "\n\t\tquery ValidateOrb ($config: String!, $owner: UUID) {\n\t\t\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", "variables": { "config": "some orb" } @@ -266,7 +266,7 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs }` expectedRequestJson := ` { - "query": "\n\t\tquery ValidateOrb ($config: String!) {\n\t\t\torbConfig(orbYaml: $config) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", + "query": "\n\t\tquery ValidateOrb ($config: String!, $owner: UUID) {\n\t\t\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", "variables": { "config": "some orb" } @@ -309,7 +309,7 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs }` expectedRequestJson := ` { - "query": "\n\t\tquery ValidateOrb ($config: String!) {\n\t\t\torbConfig(orbYaml: $config) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", + "query": "\n\t\tquery ValidateOrb ($config: String!, $owner: UUID) {\n\t\t\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", "variables": { "config": "some orb" } @@ -344,7 +344,7 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs }` expectedRequestJson := ` { - "query": "\n\t\tquery ValidateOrb ($config: String!) {\n\t\t\torbConfig(orbYaml: $config) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", + "query": "\n\t\tquery ValidateOrb ($config: String!, $owner: UUID) {\n\t\t\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", "variables": { "config": "some orb" } diff --git a/config/collaborators.go b/config/collaborators.go deleted file mode 100644 index 30da51d17..000000000 --- a/config/collaborators.go +++ /dev/null @@ -1,39 +0,0 @@ -package config - -import ( - "net/url" -) - -var ( - CollaborationsPath = "me/collaborations" -) - -type CollaborationResult struct { - VcsTye string `json:"vcs_type"` - OrgSlug string `json:"slug"` - OrgName string `json:"name"` - OrgId string `json:"id"` - AvatarUrl string `json:"avatar_url"` -} - -// GetOrgCollaborations - fetches all the collaborations for a given user. -func (c *ConfigCompiler) GetOrgCollaborations() ([]CollaborationResult, error) { - req, err := c.collaboratorRestClient.NewRequest("GET", &url.URL{Path: CollaborationsPath}, nil) - if err != nil { - return nil, err - } - - var resp []CollaborationResult - _, err = c.collaboratorRestClient.DoRequest(req, &resp) - return resp, err -} - -// GetOrgIdFromSlug - converts a slug into an orgID. -func GetOrgIdFromSlug(slug string, collaborations []CollaborationResult) string { - for _, v := range collaborations { - if v.OrgSlug == slug { - return v.OrgId - } - } - return "" -} diff --git a/config/collaborators_test.go b/config/collaborators_test.go deleted file mode 100644 index 115c4f8c9..000000000 --- a/config/collaborators_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package config - -import ( - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/CircleCI-Public/circleci-cli/settings" - "github.com/stretchr/testify/assert" -) - -func TestGetOrgCollaborations(t *testing.T) { - svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `[{"vcs_type":"circleci","slug":"gh/test","id":"2345"}]`) - })) - defer svr.Close() - compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient}) - - t.Run("assert compiler has correct host", func(t *testing.T) { - assert.Equal(t, "http://"+compiler.collaboratorRestClient.BaseURL.Host, svr.URL) - }) - - t.Run("getOrgCollaborations can parse response correctly", func(t *testing.T) { - collabs, err := compiler.GetOrgCollaborations() - assert.NoError(t, err) - assert.Equal(t, 1, len(collabs)) - assert.Equal(t, "circleci", collabs[0].VcsTye) - }) - - t.Run("can fetch orgID from a slug", func(t *testing.T) { - expected := "1234" - actual := GetOrgIdFromSlug("gh/test", []CollaborationResult{{OrgSlug: "gh/test", OrgId: "1234"}}) - assert.Equal(t, expected, actual) - }) - - t.Run("returns empty if no slug match", func(t *testing.T) { - expected := "" - actual := GetOrgIdFromSlug("gh/doesntexist", []CollaborationResult{{OrgSlug: "gh/test", OrgId: "1234"}}) - assert.Equal(t, expected, actual) - }) -} diff --git a/config/commands.go b/config/commands.go index 64504939f..ef3682145 100644 --- a/config/commands.go +++ b/config/commands.go @@ -39,27 +39,29 @@ func (c *ConfigCompiler) getOrgID( optsOrgID string, optsOrgSlug string, ) (string, error) { - if optsOrgID == "" && optsOrgSlug == "" { + if strings.TrimSpace(optsOrgID) != "" { + return optsOrgID, nil + } + + if strings.TrimSpace(optsOrgSlug) == "" { return "", nil } - var orgID string - if strings.TrimSpace(optsOrgID) != "" { - orgID = optsOrgID - } else { - orgs, err := c.GetOrgCollaborations() - if err != nil { - return "", err - } - orgID = GetOrgIdFromSlug(optsOrgSlug, orgs) - if orgID == "" { - fmt.Println("Could not fetch a valid org-id from collaborators endpoint.") - fmt.Println("Check if you have access to this org by hitting https://circleci.com/api/v2/me/collaborations") - fmt.Println("Continuing on - private orb resolution will not work as intended") - } + coll, err := c.collaborators.GetCollaborationBySlug(optsOrgSlug) + + if err != nil { + return "", err + } + + if coll == nil { + fmt.Println("Could not fetch a valid org-id from collaborators endpoint.") + fmt.Println("Check if you have access to this org by hitting https://circleci.com/api/v2/me/collaborations") + fmt.Println("Continuing on - private orb resolution will not work as intended") + + return "", nil } - return orgID, nil + return coll.OrgId, nil } func (c *ConfigCompiler) ProcessConfig(opts ProcessConfigOpts) error { diff --git a/config/config.go b/config/config.go index b8cb5921b..d76dcad51 100644 --- a/config/config.go +++ b/config/config.go @@ -6,6 +6,7 @@ import ( "net/url" "os" + "github.com/CircleCI-Public/circleci-cli/api/collaborators" "github.com/CircleCI-Public/circleci-cli/api/graphql" "github.com/CircleCI-Public/circleci-cli/api/rest" "github.com/CircleCI-Public/circleci-cli/settings" @@ -21,9 +22,9 @@ var ( ) type ConfigCompiler struct { - host string - compileRestClient *rest.Client - collaboratorRestClient *rest.Client + host string + compileRestClient *rest.Client + collaborators collaborators.CollaboratorsClient cfg *settings.Config legacyGraphQLClient *graphql.Client @@ -31,11 +32,17 @@ type ConfigCompiler struct { func New(cfg *settings.Config) *ConfigCompiler { hostValue := getCompileHost(cfg.Host) + collaboratorsClient, err := collaborators.NewCollaboratorsRestClient(*cfg) + + if err != nil { + panic(err) + } + configCompiler := &ConfigCompiler{ - host: hostValue, - compileRestClient: rest.NewFromConfig(hostValue, cfg), - collaboratorRestClient: rest.NewFromConfig(cfg.Host, cfg), - cfg: cfg, + host: hostValue, + compileRestClient: rest.NewFromConfig(hostValue, cfg), + collaborators: collaboratorsClient, + cfg: cfg, } configCompiler.legacyGraphQLClient = graphql.NewClient(cfg.HTTPClient, cfg.Host, cfg.Endpoint, cfg.Token, cfg.Debug) From cc8c4de9b02c6c5a46923cac013e19dc4ca30f58 Mon Sep 17 00:00:00 2001 From: zbenhadi Date: Tue, 2 May 2023 11:01:45 +0200 Subject: [PATCH 10/12] New Release (#924) * Add warning on some CLI commands that orbs cannot be deleted once they are created * Add org-slug and org-id flags to orb validate & process commands (#922) --------- Co-authored-by: rlegan Co-authored-by: JulesFaucherre Co-authored-by: Renaud <45293016+rlegan@users.noreply.github.com> --- api/api.go | 11 +- api/collaborators/collaborators.go | 14 + api/collaborators/collaborators_rest.go | 64 ++++ api/collaborators/collaborators_rest_test.go | 380 +++++++++++++++++++ cmd/orb.go | 110 +++++- cmd/orb_test.go | 14 +- config/collaborators.go | 39 -- config/collaborators_test.go | 43 --- config/commands.go | 34 +- config/config.go | 21 +- 10 files changed, 602 insertions(+), 128 deletions(-) create mode 100644 api/collaborators/collaborators.go create mode 100644 api/collaborators/collaborators_rest.go create mode 100644 api/collaborators/collaborators_rest_test.go delete mode 100644 config/collaborators.go delete mode 100644 config/collaborators_test.go diff --git a/api/api.go b/api/api.go index ac29d7c1f..f94c64401 100644 --- a/api/api.go +++ b/api/api.go @@ -513,7 +513,7 @@ func WhoamiQuery(cl *graphql.Client) (*WhoamiResponse, error) { } // OrbQuery validated and processes an orb. -func OrbQuery(cl *graphql.Client, configPath string) (*ConfigResponse, error) { +func OrbQuery(cl *graphql.Client, configPath string, ownerId string) (*ConfigResponse, error) { var response OrbConfigResponse config, err := loadYaml(configPath) @@ -522,8 +522,8 @@ func OrbQuery(cl *graphql.Client, configPath string) (*ConfigResponse, error) { } query := ` - query ValidateOrb ($config: String!) { - orbConfig(orbYaml: $config) { + query ValidateOrb ($config: String!, $owner: UUID) { + orbConfig(orbYaml: $config, ownerId: $owner) { valid, errors { message }, sourceYaml, @@ -533,6 +533,11 @@ func OrbQuery(cl *graphql.Client, configPath string) (*ConfigResponse, error) { request := graphql.NewRequest(query) request.Var("config", config) + + if ownerId != "" { + request.Var("owner", ownerId) + } + request.SetToken(cl.Token) err = cl.Run(request, &response) diff --git a/api/collaborators/collaborators.go b/api/collaborators/collaborators.go new file mode 100644 index 000000000..6fecf61f9 --- /dev/null +++ b/api/collaborators/collaborators.go @@ -0,0 +1,14 @@ +package collaborators + +type CollaborationResult struct { + VcsType string `json:"vcs_type"` + OrgSlug string `json:"slug"` + OrgName string `json:"name"` + OrgId string `json:"id"` + AvatarUrl string `json:"avatar_url"` +} + +type CollaboratorsClient interface { + GetCollaborationBySlug(slug string) (*CollaborationResult, error) + GetOrgCollaborations() ([]CollaborationResult, error) +} diff --git a/api/collaborators/collaborators_rest.go b/api/collaborators/collaborators_rest.go new file mode 100644 index 000000000..e15f7774e --- /dev/null +++ b/api/collaborators/collaborators_rest.go @@ -0,0 +1,64 @@ +package collaborators + +import ( + "net/url" + "strings" + + "github.com/CircleCI-Public/circleci-cli/api/rest" + "github.com/CircleCI-Public/circleci-cli/settings" +) + +var ( + CollaborationsPath = "me/collaborations" +) + +type collaboratorsRestClient struct { + client *rest.Client +} + +// NewCollaboratorsRestClient returns a new collaboratorsRestClient satisfying the api.CollaboratorsClient +// interface via the REST API. +func NewCollaboratorsRestClient(config settings.Config) (*collaboratorsRestClient, error) { + client := &collaboratorsRestClient{ + client: rest.NewFromConfig(config.Host, &config), + } + return client, nil +} + +func (c *collaboratorsRestClient) GetOrgCollaborations() ([]CollaborationResult, error) { + req, err := c.client.NewRequest("GET", &url.URL{Path: CollaborationsPath}, nil) + if err != nil { + return nil, err + } + + var resp []CollaborationResult + _, err = c.client.DoRequest(req, &resp) + return resp, err +} + +func (c *collaboratorsRestClient) GetCollaborationBySlug(slug string) (*CollaborationResult, error) { + // Support for / as well as / for the slug + // requires splitting + collaborations, err := c.GetOrgCollaborations() + + if err != nil { + return nil, err + } + + slugParts := strings.Split(slug, "/") + + for _, v := range collaborations { + // The rest-api allways returns / as a slug + if v.OrgSlug == slug { + return &v, nil + } + + // Compare first part of argument slug with the VCSType + splitted := strings.Split(v.OrgSlug, "/") + if len(slugParts) >= 2 && len(splitted) >= 2 && slugParts[0] == v.VcsType && slugParts[1] == splitted[1] { + return &v, nil + } + } + + return nil, nil +} diff --git a/api/collaborators/collaborators_rest_test.go b/api/collaborators/collaborators_rest_test.go new file mode 100644 index 000000000..333539c68 --- /dev/null +++ b/api/collaborators/collaborators_rest_test.go @@ -0,0 +1,380 @@ +package collaborators_test + +import ( + "net/http" + "net/http/httptest" + "reflect" + "testing" + + "github.com/CircleCI-Public/circleci-cli/api/collaborators" + "github.com/CircleCI-Public/circleci-cli/settings" + "github.com/CircleCI-Public/circleci-cli/version" + "gotest.tools/v3/assert" +) + +func getCollaboratorsRestClient(server *httptest.Server) (collaborators.CollaboratorsClient, error) { + client := &http.Client{} + + return collaborators.NewCollaboratorsRestClient(settings.Config{ + RestEndpoint: "api/v2", + Host: server.URL, + HTTPClient: client, + Token: "token", + }) +} + +func Test_collaboratorsRestClient_GetOrgCollaborations(t *testing.T) { + tests := []struct { + name string + handler http.HandlerFunc + want []collaborators.CollaborationResult + wantErr bool + }{ + { + name: "Should handle a successful request", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("circle-token"), "token") + assert.Equal(t, r.Header.Get("accept"), "application/json") + assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent()) + + assert.Equal(t, r.Method, "GET") + assert.Equal(t, r.URL.Path, "/api/v2/me/collaborations") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(` + [ + { + "vcs_type": "github", + "slug": "gh/example", + "name": "Example Org", + "id": "some-uuid-123", + "avatar_url": "http://placekitten.com/200/300" + }, + { + "vcs_type": "bitbucket", + "slug": "bb/other", + "name": "Other Org", + "id": "some-uuid-789", + "avatar_url": "http://placekitten.com/200/300" + } + ] + `)) + + assert.NilError(t, err) + }, + want: []collaborators.CollaborationResult{ + { + VcsType: "github", + OrgSlug: "gh/example", + OrgName: "Example Org", + OrgId: "some-uuid-123", + AvatarUrl: "http://placekitten.com/200/300", + }, + { + VcsType: "bitbucket", + OrgSlug: "bb/other", + OrgName: "Other Org", + OrgId: "some-uuid-789", + AvatarUrl: "http://placekitten.com/200/300", + }, + }, + wantErr: false, + }, + { + name: "Should handle an error request", + handler: func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write([]byte(`{"message": "error"}`)) + assert.NilError(t, err) + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(tt.handler) + defer server.Close() + + c, err := getCollaboratorsRestClient(server) + assert.NilError(t, err) + + got, err := c.GetOrgCollaborations() + if (err != nil) != tt.wantErr { + t.Errorf("collaboratorsRestClient.GetOrgCollaborations() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("collaboratorsRestClient.GetOrgCollaborations() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_collaboratorsRestClient_GetCollaborationBySlug(t *testing.T) { + type args struct { + slug string + } + tests := []struct { + name string + handler http.HandlerFunc + args args + want *collaborators.CollaborationResult + wantErr bool + }{ + { + name: "Should work with short-vcs notation (github)", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("circle-token"), "token") + assert.Equal(t, r.Header.Get("accept"), "application/json") + assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent()) + + assert.Equal(t, r.Method, "GET") + assert.Equal(t, r.URL.Path, "/api/v2/me/collaborations") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(` + [ + { + "vcs_type": "github", + "slug": "gh/example", + "name": "Example Org", + "id": "some-uuid-123", + "avatar_url": "http://placekitten.com/200/300" + }, + { + "vcs_type": "bitbucket", + "slug": "bb/other", + "name": "Other Org", + "id": "some-uuid-789", + "avatar_url": "http://placekitten.com/200/300" + } + ] + `)) + + assert.NilError(t, err) + }, + args: args{ + slug: "gh/example", + }, + want: &collaborators.CollaborationResult{ + VcsType: "github", + OrgSlug: "gh/example", + OrgName: "Example Org", + OrgId: "some-uuid-123", + AvatarUrl: "http://placekitten.com/200/300", + }, + wantErr: false, + }, + { + name: "Should work with vcs-name notation (github)", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("circle-token"), "token") + assert.Equal(t, r.Header.Get("accept"), "application/json") + assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent()) + + assert.Equal(t, r.Method, "GET") + assert.Equal(t, r.URL.Path, "/api/v2/me/collaborations") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(` + [ + { + "vcs_type": "github", + "slug": "gh/example", + "name": "Example Org", + "id": "some-uuid-123", + "avatar_url": "http://placekitten.com/200/300" + }, + { + "vcs_type": "bitbucket", + "slug": "bb/other", + "name": "Other Org", + "id": "some-uuid-789", + "avatar_url": "http://placekitten.com/200/300" + } + ] + `)) + + assert.NilError(t, err) + }, + args: args{ + slug: "github/example", + }, + want: &collaborators.CollaborationResult{ + VcsType: "github", + OrgSlug: "gh/example", + OrgName: "Example Org", + OrgId: "some-uuid-123", + AvatarUrl: "http://placekitten.com/200/300", + }, + wantErr: false, + }, + { + name: "Should work with vcs-short notation (bitbucket)", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("circle-token"), "token") + assert.Equal(t, r.Header.Get("accept"), "application/json") + assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent()) + + assert.Equal(t, r.Method, "GET") + assert.Equal(t, r.URL.Path, "/api/v2/me/collaborations") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(` + [ + { + "vcs_type": "github", + "slug": "gh/example", + "name": "Example Org", + "id": "some-uuid-123", + "avatar_url": "http://placekitten.com/200/300" + }, + { + "vcs_type": "bitbucket", + "slug": "bb/other", + "name": "Other Org", + "id": "some-uuid-789", + "avatar_url": "http://placekitten.com/200/300" + } + ] + `)) + + assert.NilError(t, err) + }, + args: args{ + slug: "bb/other", + }, + want: &collaborators.CollaborationResult{ + VcsType: "bitbucket", + OrgSlug: "bb/other", + OrgName: "Other Org", + OrgId: "some-uuid-789", + AvatarUrl: "http://placekitten.com/200/300", + }, + wantErr: false, + }, + { + name: "Should work with vcs-name notation (bitbucket)", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("circle-token"), "token") + assert.Equal(t, r.Header.Get("accept"), "application/json") + assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent()) + + assert.Equal(t, r.Method, "GET") + assert.Equal(t, r.URL.Path, "/api/v2/me/collaborations") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(` + [ + { + "vcs_type": "github", + "slug": "gh/example", + "name": "Example Org", + "id": "some-uuid-123", + "avatar_url": "http://placekitten.com/200/300" + }, + { + "vcs_type": "bitbucket", + "slug": "bb/other", + "name": "Other Org", + "id": "some-uuid-789", + "avatar_url": "http://placekitten.com/200/300" + } + ] + `)) + + assert.NilError(t, err) + }, + args: args{ + slug: "bitbucket/other", + }, + want: &collaborators.CollaborationResult{ + VcsType: "bitbucket", + OrgSlug: "bb/other", + OrgName: "Other Org", + OrgId: "some-uuid-789", + AvatarUrl: "http://placekitten.com/200/300", + }, + wantErr: false, + }, + { + name: "Should return nil if not found", + handler: func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, r.Header.Get("circle-token"), "token") + assert.Equal(t, r.Header.Get("accept"), "application/json") + assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent()) + + assert.Equal(t, r.Method, "GET") + assert.Equal(t, r.URL.Path, "/api/v2/me/collaborations") + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, err := w.Write([]byte(` + [ + { + "vcs_type": "github", + "slug": "gh/example", + "name": "Example Org", + "id": "some-uuid-123", + "avatar_url": "http://placekitten.com/200/300" + }, + { + "vcs_type": "bitbucket", + "slug": "bb/other", + "name": "Other Org", + "id": "some-uuid-789", + "avatar_url": "http://placekitten.com/200/300" + } + ] + `)) + + assert.NilError(t, err) + }, + args: args{ + slug: "bad-slug", + }, + want: nil, + wantErr: false, + }, + { + name: "Should error if request errors", + handler: func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("content-type", "application/json") + w.WriteHeader(http.StatusInternalServerError) + _, err := w.Write([]byte(`{"message": "error"}`)) + assert.NilError(t, err) + }, + args: args{ + slug: "bad-slug", + }, + want: nil, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(tt.handler) + defer server.Close() + + c, err := getCollaboratorsRestClient(server) + assert.NilError(t, err) + + got, err := c.GetCollaborationBySlug(tt.args.slug) + if (err != nil) != tt.wantErr { + t.Errorf("collaboratorsRestClient.GetCollaborationBySlug() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("collaboratorsRestClient.GetCollaborationBySlug() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/cmd/orb.go b/cmd/orb.go index 33a06c855..326b0a616 100644 --- a/cmd/orb.go +++ b/cmd/orb.go @@ -18,6 +18,7 @@ import ( "time" "github.com/CircleCI-Public/circleci-cli/api" + "github.com/CircleCI-Public/circleci-cli/api/collaborators" "github.com/CircleCI-Public/circleci-cli/api/graphql" "github.com/CircleCI-Public/circleci-cli/filetree" "github.com/CircleCI-Public/circleci-cli/process" @@ -43,9 +44,10 @@ import ( ) type orbOptions struct { - cfg *settings.Config - cl *graphql.Client - args []string + cfg *settings.Config + cl *graphql.Client + args []string + collaborators collaborators.CollaboratorsClient color string @@ -62,6 +64,11 @@ type orbOptions struct { integrationTesting bool } +type orbOrgOptions struct { + OrgID string + OrgSlug string +} + var orbAnnotations = map[string]string{ "": "The path to your orb (use \"-\" for STDIN)", "": "The namespace used for the orb (i.e. circleci)", @@ -93,9 +100,16 @@ func (ui createOrbTestUI) askUserToConfirm(message string) bool { } func newOrbCommand(config *settings.Config) *cobra.Command { + collaborators, err := collaborators.NewCollaboratorsRestClient(*config) + + if err != nil { + panic(err) + } + opts := orbOptions{ - cfg: config, - tty: createOrbInteractiveUI{}, + cfg: config, + tty: createOrbInteractiveUI{}, + collaborators: collaborators, } listCommand := &cobra.Command{ @@ -121,13 +135,23 @@ func newOrbCommand(config *settings.Config) *cobra.Command { validateCommand := &cobra.Command{ Use: "validate ", Short: "Validate an orb.yml", - RunE: func(_ *cobra.Command, _ []string) error { - return validateOrb(opts) + RunE: func(cmd *cobra.Command, _ []string) error { + orgID, _ := cmd.Flags().GetString("org-id") + orgSlug, _ := cmd.Flags().GetString("org-slug") + + org := orbOrgOptions{ + OrgID: orgID, + OrgSlug: orgSlug, + } + + return validateOrb(opts, org) }, Args: cobra.ExactArgs(1), Annotations: make(map[string]string), } validateCommand.Annotations[""] = orbAnnotations[""] + validateCommand.Flags().String("org-slug", "", "organization slug (for example: github/example-org), used when an orb depends on private orbs belonging to that org") + validateCommand.Flags().String("org-id", "", "organization id used when an orb depends on private orbs belonging to that org") processCommand := &cobra.Command{ Use: "process ", @@ -141,14 +165,24 @@ func newOrbCommand(config *settings.Config) *cobra.Command { opts.args = args opts.cl = graphql.NewClient(config.HTTPClient, config.Host, config.Endpoint, config.Token, config.Debug) }, - RunE: func(_ *cobra.Command, _ []string) error { - return processOrb(opts) + RunE: func(cmd *cobra.Command, _ []string) error { + orgID, _ := cmd.Flags().GetString("org-id") + orgSlug, _ := cmd.Flags().GetString("org-slug") + + org := orbOrgOptions{ + OrgID: orgID, + OrgSlug: orgSlug, + } + + return processOrb(opts, org) }, Args: cobra.ExactArgs(1), Annotations: make(map[string]string), } processCommand.Example = ` circleci orb process src/my-orb/@orb.yml` processCommand.Annotations[""] = orbAnnotations[""] + processCommand.Flags().String("org-slug", "", "organization slug (for example: github/example-org), used when an orb depends on private orbs belonging to that org") + processCommand.Flags().String("org-id", "", "organization id used when an orb depends on private orbs belonging to that org") publishCommand := &cobra.Command{ Use: "publish ", @@ -691,8 +725,14 @@ func listNamespaceOrbs(opts orbOptions) error { return logOrbs(*orbs, opts) } -func validateOrb(opts orbOptions) error { - _, err := api.OrbQuery(opts.cl, opts.args[0]) +func validateOrb(opts orbOptions, org orbOrgOptions) error { + orgId, err := (&opts).getOrgId(org) + + if err != nil { + return fmt.Errorf("failed to get the appropriate org-id: %s", err.Error()) + } + + _, err = api.OrbQuery(opts.cl, opts.args[0], orgId) if err != nil { return err @@ -707,8 +747,14 @@ func validateOrb(opts orbOptions) error { return nil } -func processOrb(opts orbOptions) error { - response, err := api.OrbQuery(opts.cl, opts.args[0]) +func processOrb(opts orbOptions, org orbOrgOptions) error { + orgId, err := (&opts).getOrgId(org) + + if err != nil { + return fmt.Errorf("failed to get the appropriate org-id: %s", err.Error()) + } + + response, err := api.OrbQuery(opts.cl, opts.args[0], orgId) if err != nil { return err @@ -719,6 +765,7 @@ func processOrb(opts orbOptions) error { } func publishOrb(opts orbOptions) error { + orbInformThatOrbCannotBeDeletedMessage() path := opts.args[0] ref := opts.args[1] namespace, orb, version, err := references.SplitIntoOrbNamespaceAndVersion(ref) @@ -794,6 +841,8 @@ func validateSegmentArg(label string) error { } func incrementOrb(opts orbOptions) error { + orbInformThatOrbCannotBeDeletedMessage() + ref := opts.args[1] segment := opts.args[2] @@ -822,6 +871,8 @@ func incrementOrb(opts orbOptions) error { } func promoteOrb(opts orbOptions) error { + orbInformThatOrbCannotBeDeletedMessage() + ref := opts.args[0] segment := opts.args[1] @@ -1104,6 +1155,8 @@ func initOrb(opts orbOptions) error { var err error fmt.Println("Note: This command is in preview. Please report any bugs! https://github.com/CircleCI-Public/circleci-cli/issues/new/choose") + orbInformThatOrbCannotBeDeletedMessage() + fullyAutomated := 0 prompt := &survey.Select{ Message: "Would you like to perform an automated setup of this orb?", @@ -1734,3 +1787,34 @@ func stringifyDiff(diff gotextdiff.Unified, colorOpt string) string { color.NoColor = oldNoColor return strings.Join(lines, "\n") } + +func orbInformThatOrbCannotBeDeletedMessage() { + fmt.Println("Once an orb is created it cannot be deleted. Orbs are semver compliant, and each published version is immutable. Publicly released orbs are potential dependencies for other projects.") + fmt.Println("Therefore, allowing orb deletion would make users susceptible to unexpected loss of functionality.") +} + +func (o *orbOptions) getOrgId(orgInfo orbOrgOptions) (string, error) { + if strings.TrimSpace(orgInfo.OrgID) != "" { + return orgInfo.OrgID, nil + } + + if strings.TrimSpace(orgInfo.OrgSlug) == "" { + return "", nil + } + + coll, err := o.collaborators.GetCollaborationBySlug(orgInfo.OrgSlug) + + if err != nil { + return "", err + } + + if coll == nil { + fmt.Println("Could not fetch a valid org-id from collaborators endpoint.") + fmt.Println("Check if you have access to this org by hitting https://circleci.com/api/v2/me/collaborations") + fmt.Println("Continuing on - private orb resolution will not work as intended") + + return "", nil + } + + return coll.OrgId, nil +} diff --git a/cmd/orb_test.go b/cmd/orb_test.go index e5a5f42b3..ded63dbe3 100644 --- a/cmd/orb_test.go +++ b/cmd/orb_test.go @@ -124,8 +124,8 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs } `json:"variables"` }{ Query: ` - query ValidateOrb ($config: String!) { - orbConfig(orbYaml: $config) { + query ValidateOrb ($config: String!, $owner: UUID) { + orbConfig(orbYaml: $config, ownerId: $owner) { valid, errors { message }, sourceYaml, @@ -184,7 +184,7 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs }` expectedRequestJson := ` { - "query": "\n\t\tquery ValidateOrb ($config: String!) {\n\t\t\torbConfig(orbYaml: $config) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", + "query": "\n\t\tquery ValidateOrb ($config: String!, $owner: UUID) {\n\t\t\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", "variables": { "config": "{}" } @@ -231,7 +231,7 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs }` expectedRequestJson := ` { - "query": "\n\t\tquery ValidateOrb ($config: String!) {\n\t\t\torbConfig(orbYaml: $config) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", + "query": "\n\t\tquery ValidateOrb ($config: String!, $owner: UUID) {\n\t\t\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", "variables": { "config": "some orb" } @@ -266,7 +266,7 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs }` expectedRequestJson := ` { - "query": "\n\t\tquery ValidateOrb ($config: String!) {\n\t\t\torbConfig(orbYaml: $config) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", + "query": "\n\t\tquery ValidateOrb ($config: String!, $owner: UUID) {\n\t\t\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", "variables": { "config": "some orb" } @@ -309,7 +309,7 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs }` expectedRequestJson := ` { - "query": "\n\t\tquery ValidateOrb ($config: String!) {\n\t\t\torbConfig(orbYaml: $config) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", + "query": "\n\t\tquery ValidateOrb ($config: String!, $owner: UUID) {\n\t\t\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", "variables": { "config": "some orb" } @@ -344,7 +344,7 @@ See a full explanation and documentation on orbs here: https://circleci.com/docs }` expectedRequestJson := ` { - "query": "\n\t\tquery ValidateOrb ($config: String!) {\n\t\t\torbConfig(orbYaml: $config) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", + "query": "\n\t\tquery ValidateOrb ($config: String!, $owner: UUID) {\n\t\t\torbConfig(orbYaml: $config, ownerId: $owner) {\n\t\t\t\tvalid,\n\t\t\t\terrors { message },\n\t\t\t\tsourceYaml,\n\t\t\t\toutputYaml\n\t\t\t}\n\t\t}", "variables": { "config": "some orb" } diff --git a/config/collaborators.go b/config/collaborators.go deleted file mode 100644 index 30da51d17..000000000 --- a/config/collaborators.go +++ /dev/null @@ -1,39 +0,0 @@ -package config - -import ( - "net/url" -) - -var ( - CollaborationsPath = "me/collaborations" -) - -type CollaborationResult struct { - VcsTye string `json:"vcs_type"` - OrgSlug string `json:"slug"` - OrgName string `json:"name"` - OrgId string `json:"id"` - AvatarUrl string `json:"avatar_url"` -} - -// GetOrgCollaborations - fetches all the collaborations for a given user. -func (c *ConfigCompiler) GetOrgCollaborations() ([]CollaborationResult, error) { - req, err := c.collaboratorRestClient.NewRequest("GET", &url.URL{Path: CollaborationsPath}, nil) - if err != nil { - return nil, err - } - - var resp []CollaborationResult - _, err = c.collaboratorRestClient.DoRequest(req, &resp) - return resp, err -} - -// GetOrgIdFromSlug - converts a slug into an orgID. -func GetOrgIdFromSlug(slug string, collaborations []CollaborationResult) string { - for _, v := range collaborations { - if v.OrgSlug == slug { - return v.OrgId - } - } - return "" -} diff --git a/config/collaborators_test.go b/config/collaborators_test.go deleted file mode 100644 index 115c4f8c9..000000000 --- a/config/collaborators_test.go +++ /dev/null @@ -1,43 +0,0 @@ -package config - -import ( - "fmt" - "net/http" - "net/http/httptest" - "testing" - - "github.com/CircleCI-Public/circleci-cli/settings" - "github.com/stretchr/testify/assert" -) - -func TestGetOrgCollaborations(t *testing.T) { - svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - fmt.Fprintf(w, `[{"vcs_type":"circleci","slug":"gh/test","id":"2345"}]`) - })) - defer svr.Close() - compiler := New(&settings.Config{Host: svr.URL, HTTPClient: http.DefaultClient}) - - t.Run("assert compiler has correct host", func(t *testing.T) { - assert.Equal(t, "http://"+compiler.collaboratorRestClient.BaseURL.Host, svr.URL) - }) - - t.Run("getOrgCollaborations can parse response correctly", func(t *testing.T) { - collabs, err := compiler.GetOrgCollaborations() - assert.NoError(t, err) - assert.Equal(t, 1, len(collabs)) - assert.Equal(t, "circleci", collabs[0].VcsTye) - }) - - t.Run("can fetch orgID from a slug", func(t *testing.T) { - expected := "1234" - actual := GetOrgIdFromSlug("gh/test", []CollaborationResult{{OrgSlug: "gh/test", OrgId: "1234"}}) - assert.Equal(t, expected, actual) - }) - - t.Run("returns empty if no slug match", func(t *testing.T) { - expected := "" - actual := GetOrgIdFromSlug("gh/doesntexist", []CollaborationResult{{OrgSlug: "gh/test", OrgId: "1234"}}) - assert.Equal(t, expected, actual) - }) -} diff --git a/config/commands.go b/config/commands.go index 64504939f..ef3682145 100644 --- a/config/commands.go +++ b/config/commands.go @@ -39,27 +39,29 @@ func (c *ConfigCompiler) getOrgID( optsOrgID string, optsOrgSlug string, ) (string, error) { - if optsOrgID == "" && optsOrgSlug == "" { + if strings.TrimSpace(optsOrgID) != "" { + return optsOrgID, nil + } + + if strings.TrimSpace(optsOrgSlug) == "" { return "", nil } - var orgID string - if strings.TrimSpace(optsOrgID) != "" { - orgID = optsOrgID - } else { - orgs, err := c.GetOrgCollaborations() - if err != nil { - return "", err - } - orgID = GetOrgIdFromSlug(optsOrgSlug, orgs) - if orgID == "" { - fmt.Println("Could not fetch a valid org-id from collaborators endpoint.") - fmt.Println("Check if you have access to this org by hitting https://circleci.com/api/v2/me/collaborations") - fmt.Println("Continuing on - private orb resolution will not work as intended") - } + coll, err := c.collaborators.GetCollaborationBySlug(optsOrgSlug) + + if err != nil { + return "", err + } + + if coll == nil { + fmt.Println("Could not fetch a valid org-id from collaborators endpoint.") + fmt.Println("Check if you have access to this org by hitting https://circleci.com/api/v2/me/collaborations") + fmt.Println("Continuing on - private orb resolution will not work as intended") + + return "", nil } - return orgID, nil + return coll.OrgId, nil } func (c *ConfigCompiler) ProcessConfig(opts ProcessConfigOpts) error { diff --git a/config/config.go b/config/config.go index b8cb5921b..d76dcad51 100644 --- a/config/config.go +++ b/config/config.go @@ -6,6 +6,7 @@ import ( "net/url" "os" + "github.com/CircleCI-Public/circleci-cli/api/collaborators" "github.com/CircleCI-Public/circleci-cli/api/graphql" "github.com/CircleCI-Public/circleci-cli/api/rest" "github.com/CircleCI-Public/circleci-cli/settings" @@ -21,9 +22,9 @@ var ( ) type ConfigCompiler struct { - host string - compileRestClient *rest.Client - collaboratorRestClient *rest.Client + host string + compileRestClient *rest.Client + collaborators collaborators.CollaboratorsClient cfg *settings.Config legacyGraphQLClient *graphql.Client @@ -31,11 +32,17 @@ type ConfigCompiler struct { func New(cfg *settings.Config) *ConfigCompiler { hostValue := getCompileHost(cfg.Host) + collaboratorsClient, err := collaborators.NewCollaboratorsRestClient(*cfg) + + if err != nil { + panic(err) + } + configCompiler := &ConfigCompiler{ - host: hostValue, - compileRestClient: rest.NewFromConfig(hostValue, cfg), - collaboratorRestClient: rest.NewFromConfig(cfg.Host, cfg), - cfg: cfg, + host: hostValue, + compileRestClient: rest.NewFromConfig(hostValue, cfg), + collaborators: collaboratorsClient, + cfg: cfg, } configCompiler.legacyGraphQLClient = graphql.NewClient(cfg.HTTPClient, cfg.Host, cfg.Endpoint, cfg.Token, cfg.Debug) From 419c0887fc67434289b7c2d21d4d08dafd11adab Mon Sep 17 00:00:00 2001 From: JulesFaucherre Date: Wed, 3 May 2023 15:28:17 +0200 Subject: [PATCH 11/12] fix: Dockerfile maintainer is now Developer Experience --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index b5194eb1f..7cdf842f2 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,5 +1,7 @@ FROM cimg/go:1.20 +LABEL maintainer="Developer Experience Team " + ENV CIRCLECI_CLI_SKIP_UPDATE_CHECK true COPY ./dist/circleci-cli_linux_amd64/circleci /usr/local/bin From e255781a96f53249474336d5486bf720bbd10c4e Mon Sep 17 00:00:00 2001 From: Elle Nolan Date: Wed, 3 May 2023 16:11:25 -0400 Subject: [PATCH 12/12] Return correct error in ConfigQuery During some testing, it was discovered that attempting to validate a config while passing an invalid token fails with the following error: ``` $ circleci --token "invalid-token" config validate .circleci/config.yml -v ... Error: config compilation request returned an error: %!w() ``` The behavior would previously check if an error existed, but return a different one. After fixing that, the error prints correctly. --- config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/config/config.go b/config/config.go index d76dcad51..1affc5672 100644 --- a/config/config.go +++ b/config/config.go @@ -126,7 +126,7 @@ func (c *ConfigCompiler) ConfigQuery( return legacyResponse, nil } if originalErr != nil { - return nil, fmt.Errorf("config compilation request returned an error: %w", err) + return nil, fmt.Errorf("config compilation request returned an error: %w", originalErr) } if statusCode != 200 {