Skip to content

Commit 56e186d

Browse files
authored
Merge pull request #1137 from CircleCI-Public/LIFE-1572-add-the-ability-to-create-a-pipeline-definition-via-the-cli
[LIFE-1572] Add the ability to create a pipeline definition via the cli
2 parents df8a877 + 5bc920d commit 56e186d

File tree

8 files changed

+737
-1
lines changed

8 files changed

+737
-1
lines changed

api/pipeline/pipeline.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package pipeline
2+
3+
type CreatePipelineInfo struct {
4+
Id string
5+
Name string
6+
CheckoutSourceRepoFullName string
7+
ConfigSourceRepoFullName string
8+
}
9+
10+
// PipelineClient is the interface to interact with pipeline and it's
11+
// components.
12+
type PipelineClient interface {
13+
CreatePipeline(projectID string, name string, description string, repoID string, configRepoID string, filePath string) (*CreatePipelineInfo, error)
14+
}

api/pipeline/pipeline_rest.go

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
package pipeline
2+
3+
import (
4+
"fmt"
5+
"net/url"
6+
7+
"github.com/CircleCI-Public/circleci-cli/api/rest"
8+
"github.com/CircleCI-Public/circleci-cli/settings"
9+
)
10+
11+
type pipelineRestClient struct {
12+
client *rest.Client
13+
}
14+
15+
var _ PipelineClient = &pipelineRestClient{}
16+
17+
type Repo struct {
18+
ExternalID string `json:"external_id"`
19+
}
20+
21+
type RepoResponse struct {
22+
ExternalID string `json:"external_id"`
23+
FullName string `json:"full_name"`
24+
}
25+
26+
type ConfigSource struct {
27+
Provider string `json:"provider"`
28+
Repo Repo `json:"repo"`
29+
FilePath string `json:"file_path"`
30+
}
31+
32+
type ConfigSourceResponse struct {
33+
Provider string `json:"provider"`
34+
Repo RepoResponse `json:"repo"`
35+
FilePath string `json:"file_path"`
36+
}
37+
38+
type CheckoutSource struct {
39+
Provider string `json:"provider"`
40+
Repo Repo `json:"repo"`
41+
}
42+
43+
type CheckoutSourceResponse struct {
44+
Provider string `json:"provider"`
45+
Repo RepoResponse `json:"repo"`
46+
}
47+
48+
type createPipelineDefinitionRequest struct {
49+
Name string `json:"name"`
50+
Description string `json:"description"`
51+
ConfigSource ConfigSource `json:"config_source"`
52+
CheckoutSource CheckoutSource `json:"checkout_source"`
53+
}
54+
55+
type createPipelineDefinitionResponse struct {
56+
ID string `json:"id"`
57+
Name string `json:"name"`
58+
Description string `json:"description"`
59+
ConfigSource ConfigSourceResponse `json:"config_source"`
60+
CheckoutSource CheckoutSourceResponse `json:"checkout_source"`
61+
}
62+
63+
// NewPipelineRestClient returns a new pipelineRestClient satisfying the api.PipelineInterface
64+
// interface via the REST API.
65+
func NewPipelineRestClient(config settings.Config) (*pipelineRestClient, error) {
66+
client := &pipelineRestClient{
67+
client: rest.NewFromConfig(config.Host, &config),
68+
}
69+
return client, nil
70+
}
71+
72+
func (c *pipelineRestClient) CreatePipeline(projectID string, name string, description string, repoID string, configRepoID string, filePath string) (*CreatePipelineInfo, error) {
73+
reqBody := createPipelineDefinitionRequest{
74+
Name: name,
75+
Description: description,
76+
ConfigSource: ConfigSource{
77+
Provider: "github_app",
78+
Repo: Repo{
79+
ExternalID: configRepoID,
80+
},
81+
FilePath: filePath,
82+
},
83+
CheckoutSource: CheckoutSource{
84+
Provider: "github_app",
85+
Repo: Repo{
86+
ExternalID: repoID,
87+
},
88+
},
89+
}
90+
91+
path := fmt.Sprintf("projects/%s/pipeline-definitions", projectID)
92+
req, err := c.client.NewRequest("POST", &url.URL{Path: path}, reqBody)
93+
if err != nil {
94+
return nil, err
95+
}
96+
97+
var resp createPipelineDefinitionResponse
98+
_, err = c.client.DoRequest(req, &resp)
99+
if err != nil {
100+
return nil, err
101+
}
102+
103+
return &CreatePipelineInfo{
104+
Id: resp.ID,
105+
Name: resp.Name,
106+
CheckoutSourceRepoFullName: resp.CheckoutSource.Repo.FullName,
107+
ConfigSourceRepoFullName: resp.ConfigSource.Repo.FullName,
108+
}, nil
109+
}

api/pipeline/pipeline_rest_test.go

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
package pipeline_test
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"net/http/httptest"
7+
"reflect"
8+
"testing"
9+
10+
"github.com/CircleCI-Public/circleci-cli/api/pipeline"
11+
"github.com/CircleCI-Public/circleci-cli/settings"
12+
"github.com/CircleCI-Public/circleci-cli/version"
13+
"gotest.tools/v3/assert"
14+
)
15+
16+
func getPipelineRestClient(server *httptest.Server) (pipeline.PipelineClient, error) {
17+
client := &http.Client{}
18+
19+
return pipeline.NewPipelineRestClient(settings.Config{
20+
RestEndpoint: "api/v2",
21+
Host: server.URL,
22+
HTTPClient: client,
23+
Token: "token",
24+
})
25+
}
26+
27+
func Test_pipelineRestClient_CreatePipeline(t *testing.T) {
28+
const (
29+
vcsType = "github"
30+
orgName = "test-org"
31+
projectID = "test-project-id"
32+
repoID = "test-repo-id"
33+
configRepoID = "test-config-repo-id"
34+
filePath = ".circleci/config.yml"
35+
testName = "test-pipeline"
36+
description = "test-description"
37+
)
38+
tests := []struct {
39+
name string
40+
handler http.HandlerFunc
41+
want *pipeline.CreatePipelineInfo
42+
wantErr bool
43+
}{
44+
{
45+
name: "Should handle a successful request with CreatePipeline",
46+
handler: func(w http.ResponseWriter, r *http.Request) {
47+
assert.Equal(t, r.Header.Get("circle-token"), "token")
48+
assert.Equal(t, r.Header.Get("accept"), "application/json")
49+
assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent())
50+
51+
assert.Equal(t, r.Method, "POST")
52+
assert.Equal(t, r.URL.Path, fmt.Sprintf("/api/v2/projects/%s/pipeline-definitions", projectID))
53+
54+
w.Header().Set("Content-Type", "application/json")
55+
w.WriteHeader(http.StatusOK)
56+
_, err := w.Write([]byte(`
57+
{
58+
"id": "123",
59+
"name": "test-pipeline",
60+
"description": "test-description",
61+
"checkout_source": {
62+
"provider": "github_app",
63+
"repo": {
64+
"external_id": "test-repo-id",
65+
"full_name": "test-repo"
66+
}
67+
},
68+
"config_source": {
69+
"provider": "github_app",
70+
"repo": {
71+
"external_id": "test-repo-id",
72+
"full_name": "test-repo"
73+
}
74+
}
75+
}`))
76+
assert.NilError(t, err)
77+
},
78+
want: &pipeline.CreatePipelineInfo{
79+
Id: "123",
80+
Name: testName,
81+
CheckoutSourceRepoFullName: "test-repo",
82+
ConfigSourceRepoFullName: "test-repo",
83+
},
84+
},
85+
{
86+
name: "Should handle an error request with CreatePipeline",
87+
handler: func(w http.ResponseWriter, _ *http.Request) {
88+
w.Header().Set("content-type", "application/json")
89+
w.WriteHeader(http.StatusInternalServerError)
90+
_, err := w.Write([]byte(`{"message": "error"}`))
91+
assert.NilError(t, err)
92+
},
93+
wantErr: true,
94+
},
95+
{
96+
name: "Should handle a successful request with CreatePipeline with configRepoID",
97+
handler: func(w http.ResponseWriter, r *http.Request) {
98+
assert.Equal(t, r.Header.Get("circle-token"), "token")
99+
assert.Equal(t, r.Header.Get("accept"), "application/json")
100+
assert.Equal(t, r.Header.Get("user-agent"), version.UserAgent())
101+
102+
assert.Equal(t, r.Method, "POST")
103+
assert.Equal(t, r.URL.Path, fmt.Sprintf("/api/v2/projects/%s/pipeline-definitions", projectID))
104+
105+
w.Header().Set("Content-Type", "application/json")
106+
w.WriteHeader(http.StatusOK)
107+
_, err := w.Write([]byte(`
108+
{
109+
"id": "123",
110+
"name": "test-pipeline",
111+
"description": "test-description",
112+
"checkout_source": {
113+
"provider": "github_app",
114+
"repo": {
115+
"external_id": "test-repo-id",
116+
"full_name": "test-repo"
117+
}
118+
},
119+
"config_source": {
120+
"provider": "github_app",
121+
"repo": {
122+
"external_id": "test-config-repo-id",
123+
"full_name": "test-config-repo"
124+
}
125+
}
126+
}`))
127+
assert.NilError(t, err)
128+
},
129+
want: &pipeline.CreatePipelineInfo{
130+
Id: "123",
131+
Name: testName,
132+
CheckoutSourceRepoFullName: "test-repo",
133+
ConfigSourceRepoFullName: "test-config-repo",
134+
},
135+
},
136+
}
137+
for _, tt := range tests {
138+
t.Run(tt.name, func(t *testing.T) {
139+
server := httptest.NewServer(tt.handler)
140+
defer server.Close()
141+
142+
p, err := getPipelineRestClient(server)
143+
assert.NilError(t, err)
144+
145+
got, err := p.CreatePipeline(projectID, testName, description, repoID, configRepoID, filePath)
146+
if (err != nil) != tt.wantErr {
147+
t.Errorf("pipelineRestClient.CreatePipeline() error = %v, wantErr %v", err, tt.wantErr)
148+
return
149+
}
150+
if !reflect.DeepEqual(got, tt.want) {
151+
t.Errorf("pipelineRestClient.CreatePipeline() = %v, want %v", got, tt.want)
152+
}
153+
})
154+
}
155+
}

cmd/pipeline/create.go

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
package pipeline
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/spf13/cobra"
7+
8+
"github.com/CircleCI-Public/circleci-cli/cmd/validator"
9+
)
10+
11+
func newCreateCommand(ops *pipelineOpts, preRunE validator.Validator) *cobra.Command {
12+
var name string
13+
var description string
14+
var repoID string
15+
var filePath string
16+
var configRepoID string
17+
18+
cmd := &cobra.Command{
19+
Use: "create <project-id> [--name <pipeline-name>] [--description <description>] [--repo-id <github-repo-id>] [--file-path <circleci-config-file-path>] [--config-repo-id <github-repo-id>]",
20+
Short: "Create a new pipeline for a CircleCI project.",
21+
Long: `Create a new pipeline for a CircleCI project.
22+
23+
All flags are optional - if not provided, you will be prompted interactively for the required values:
24+
--name Name of the pipeline
25+
--repo-id GitHub repository ID where the codebase you wish to build a pipeline for
26+
--file-path Path to the CircleCI config file
27+
--config-repo-id GitHub repository ID where the CircleCI config file is located (if different from code repository)
28+
--description Description of the pipeline (will not prompt if omitted)
29+
30+
Examples:
31+
# Minimal usage (will prompt for required values):
32+
circleci pipeline create 1662d941-6e28-43ab-bea2-aa678c098d4c
33+
34+
# Full usage with all flags:
35+
circleci pipeline create 1662d941-6e28-43ab-bea2-aa678c098d4c --name my-pipeline --description "My pipeline description" --repo-id 123456 --file-path .circleci/config.yml --config-repo-id 987654
36+
37+
Note: You will need our GitHub App installed in your repository.
38+
39+
Note: To get the repository id you can use https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#get-a-repository`,
40+
PreRunE: preRunE,
41+
RunE: func(cmd *cobra.Command, args []string) error {
42+
projectID := args[0]
43+
44+
if name == "" {
45+
namePrompt := "Enter a name for the pipeline"
46+
name = ops.reader.ReadStringFromUser(namePrompt)
47+
}
48+
49+
if repoID == "" {
50+
repoPrompt := "Enter the ID of your github repository"
51+
repoID = ops.reader.ReadStringFromUser(repoPrompt)
52+
}
53+
54+
if configRepoID == "" {
55+
yOrN := promptTillYOrN(ops.reader)
56+
if yOrN == "y" {
57+
configRepoIDPrompt := "Enter the ID of the GitHub repository where the CircleCI config file is located"
58+
configRepoID = ops.reader.ReadStringFromUser(configRepoIDPrompt)
59+
} else {
60+
configRepoID = repoID
61+
}
62+
}
63+
64+
if filePath == "" {
65+
filePathPrompt := "Enter the path to your circleci config file"
66+
filePath = ops.reader.ReadStringFromUser(filePathPrompt)
67+
}
68+
res, err := ops.pipelineClient.CreatePipeline(projectID, name, description, repoID, configRepoID, filePath)
69+
if err != nil {
70+
cmd.Println("\nThere was an error creating your pipeline. Do you have Github App installed in your repository?")
71+
return err
72+
}
73+
74+
cmd.Printf("Pipeline '%s' successfully created for repository '%s'\n", res.Name, res.CheckoutSourceRepoFullName)
75+
if res.CheckoutSourceRepoFullName != res.ConfigSourceRepoFullName {
76+
cmd.Printf("Config is successfully referenced from '%s' repository at path '%s'\n", res.ConfigSourceRepoFullName, filePath)
77+
}
78+
cmd.Println("You may view your new pipeline in your project settings: https://app.circleci.com/settings/project/<vcs>/<org>/<project>/configurations")
79+
return nil
80+
},
81+
Args: cobra.ExactArgs(1),
82+
}
83+
84+
cmd.Flags().StringVar(&name, "name", "", "Name of the pipeline to create")
85+
cmd.Flags().StringVar(&description, "description", "", "Description of the pipeline to create")
86+
cmd.Flags().StringVar(&repoID, "repo-id", "", "Repository ID of the codebase you wish to build a pipeline for")
87+
cmd.Flags().StringVar(&filePath, "file-path", "", "Path to the circleci config file to create")
88+
cmd.Flags().StringVar(&configRepoID, "config-repo-id", "", "Repository ID of the CircleCI config file")
89+
return cmd
90+
}
91+
92+
func promptTillYOrN(reader UserInputReader) string {
93+
for {
94+
input := reader.ReadStringFromUser("Does your CircleCI config file exist in a different repository? (y/n)")
95+
if input == "y" || input == "n" {
96+
return input
97+
}
98+
fmt.Println("Invalid input. Please enter 'y' or 'n'.")
99+
}
100+
}

0 commit comments

Comments
 (0)