diff --git a/.circleci/config.yml b/.circleci/config.yml index 625ca15c..2491a620 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -342,6 +342,9 @@ workflows: - lint - vulnerability-scan: context: org-global-employees + filters: + branches: + ignore: /^pull\/.*/ - deploy-test - docs: requires: diff --git a/api/job/job.go b/api/job/job.go new file mode 100644 index 00000000..82a61c03 --- /dev/null +++ b/api/job/job.go @@ -0,0 +1,138 @@ +package job + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + + "github.com/CircleCI-Public/circleci-cli/settings" +) + +type StepOutput struct { + Message string `json:"message"` + Time string `json:"time"` + Type string `json:"type"` + Truncated bool `json:"truncated"` +} + +type StepAction struct { + Name string `json:"name"` + OutputURL string `json:"output_url"` + Status string `json:"status"` + Type string `json:"type"` +} + +type Step struct { + Name string `json:"name"` + Actions []StepAction `json:"actions"` +} + +type JobDetails struct { + BuildNum int `json:"build_num"` + Status string `json:"status"` + Steps []Step `json:"steps"` +} + +type JobClient interface { + GetJobDetails(vcs string, org string, repo string, jobNumber int) (*JobDetails, error) + GetStepOutput(outputURL string) ([]StepOutput, error) +} + +type client struct { + host string + token string + httpClient *http.Client +} + +func NewClient(config settings.Config) *client { + httpClient := config.HTTPClient + if httpClient == nil { + httpClient = http.DefaultClient + } + return &client{ + host: strings.TrimRight(config.Host, "/"), + token: config.Token, + httpClient: httpClient, + } +} + +func (c *client) GetJobDetails(vcs string, org string, repo string, jobNumber int) (*JobDetails, error) { + requestURL := fmt.Sprintf("%s/api/v1.1/project/%s/%s/%s/%d", c.host, vcs, org, repo, jobNumber) + req, err := http.NewRequest(http.MethodGet, requestURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + if c.token != "" { + req.Header.Set("Circle-Token", c.token) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 400 { + var msg struct { + Message string `json:"message"` + } + if err := json.Unmarshal(body, &msg); err == nil && msg.Message != "" { + return nil, fmt.Errorf("failed to fetch job details: %s", msg.Message) + } + return nil, fmt.Errorf("failed to fetch job details: %s", strings.TrimSpace(string(body))) + } + + var details JobDetails + if err := json.Unmarshal(body, &details); err != nil { + return nil, err + } + return &details, nil +} + +func (c *client) GetStepOutput(outputURL string) ([]StepOutput, error) { + req, err := http.NewRequest(http.MethodGet, outputURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("failed to fetch step output (%s): %s", sanitizeURL(outputURL), strings.TrimSpace(string(body))) + } + + var outputs []StepOutput + if err := json.Unmarshal(body, &outputs); err != nil { + return nil, err + } + return outputs, nil +} + +func sanitizeURL(raw string) string { + u, err := url.Parse(raw) + if err != nil { + return "" + } + u.RawQuery = "" + u.Fragment = "" + return u.String() +} diff --git a/api/pipeline/pipeline.go b/api/pipeline/pipeline.go index 633eef22..010010f6 100644 --- a/api/pipeline/pipeline.go +++ b/api/pipeline/pipeline.go @@ -56,11 +56,48 @@ type PipelineRunOptions struct { ConfigFilePath string } +type Pipeline struct { + ID string `json:"id"` + ProjectSlug string `json:"project_slug"` + UpdatedAt string `json:"updated_at"` + Number int `json:"number"` + State string `json:"state"` + CreatedAt string `json:"created_at"` +} + +type ListPipelinesResponse struct { + NextPageToken string `json:"next_page_token"` + Items []Pipeline `json:"items"` +} + +type ListPipelinesOptions struct { + Branch string + PageToken string +} + +type Workflow struct { + PipelineID string `json:"pipeline_id"` + ID string `json:"id"` + Name string `json:"name"` + ProjectSlug string `json:"project_slug"` + Status string `json:"status"` + PipelineNumber int `json:"pipeline_number"` + CreatedAt string `json:"created_at"` + StoppedAt string `json:"stopped_at"` +} + +type ListWorkflowsResponse struct { + NextPageToken string `json:"next_page_token"` + Items []Workflow `json:"items"` +} + // PipelineClient is the interface to interact with pipeline and it's // components. type PipelineClient interface { CreatePipeline(projectID string, name string, description string, repoID string, configRepoID string, filePath string) (*CreatePipelineInfo, error) GetPipelineDefinition(options GetPipelineDefinitionOptions) (*PipelineDefinition, error) ListPipelineDefinitions(projectID string) ([]*PipelineDefinitionInfo, error) + ListPipelinesForProject(projectSlug string, options ListPipelinesOptions) (*ListPipelinesResponse, error) + ListWorkflowsByPipelineId(pipelineID string) ([]Workflow, error) PipelineRun(options PipelineRunOptions) (*PipelineRunResponse, error) } diff --git a/api/pipeline/pipelines_rest.go b/api/pipeline/pipelines_rest.go new file mode 100644 index 00000000..062ebf14 --- /dev/null +++ b/api/pipeline/pipelines_rest.go @@ -0,0 +1,64 @@ +package pipeline + +import ( + "fmt" + "net/url" +) + +func (c *pipelineRestClient) ListPipelinesForProject(projectSlug string, options ListPipelinesOptions) (*ListPipelinesResponse, error) { + path := fmt.Sprintf("project/%s/pipeline", projectSlug) + + params := url.Values{} + if options.Branch != "" { + params.Set("branch", options.Branch) + } + if options.PageToken != "" { + params.Set("page-token", options.PageToken) + } + + req, err := c.client.NewRequest("GET", &url.URL{Path: path, RawQuery: params.Encode()}, nil) + if err != nil { + return nil, err + } + + var resp ListPipelinesResponse + _, err = c.client.DoRequest(req, &resp) + if err != nil { + return nil, err + } + return &resp, nil +} + +func (c *pipelineRestClient) ListWorkflowsByPipelineId(pipelineID string) ([]Workflow, error) { + items := []Workflow{} + pageToken := "" + + for { + path := fmt.Sprintf("pipeline/%s/workflow", pipelineID) + + params := url.Values{} + if pageToken != "" { + params.Set("page-token", pageToken) + } + + req, err := c.client.NewRequest("GET", &url.URL{Path: path, RawQuery: params.Encode()}, nil) + if err != nil { + return nil, err + } + + var resp ListWorkflowsResponse + _, err = c.client.DoRequest(req, &resp) + if err != nil { + return nil, err + } + + items = append(items, resp.Items...) + + if resp.NextPageToken == "" { + break + } + pageToken = resp.NextPageToken + } + + return items, nil +} diff --git a/api/workflow/workflow.go b/api/workflow/workflow.go new file mode 100644 index 00000000..cdb36599 --- /dev/null +++ b/api/workflow/workflow.go @@ -0,0 +1,26 @@ +package workflow + +type Job struct { + CanceledBy string `json:"canceled_by"` + Dependencies []string `json:"dependencies"` + JobNumber int `json:"job_number"` + ID string `json:"id"` + StartedAt string `json:"started_at"` + Name string `json:"name"` + ApprovedBy string `json:"approved_by"` + ProjectSlug string `json:"project_slug"` + Status string `json:"status"` + Type string `json:"type"` + Requires map[string]interface{} `json:"requires"` + StoppedAt string `json:"stopped_at"` + ApprovalRequestID string `json:"approval_request_id"` +} + +type JobsResponse struct { + NextPageToken string `json:"next_page_token"` + Items []Job `json:"items"` +} + +type WorkflowClient interface { + ListWorkflowJobs(workflowID string) ([]Job, error) +} diff --git a/api/workflow/workflow_rest.go b/api/workflow/workflow_rest.go new file mode 100644 index 00000000..5e22441a --- /dev/null +++ b/api/workflow/workflow_rest.go @@ -0,0 +1,56 @@ +package workflow + +import ( + "fmt" + "net/url" + + "github.com/CircleCI-Public/circleci-cli/api/rest" + "github.com/CircleCI-Public/circleci-cli/settings" +) + +type workflowRestClient struct { + client *rest.Client +} + +var _ WorkflowClient = &workflowRestClient{} + +func NewWorkflowRestClient(config settings.Config) (*workflowRestClient, error) { + client := &workflowRestClient{ + client: rest.NewFromConfig(config.Host, &config), + } + return client, nil +} + +func (c *workflowRestClient) ListWorkflowJobs(workflowID string) ([]Job, error) { + items := []Job{} + pageToken := "" + + for { + path := fmt.Sprintf("workflow/%s/job", workflowID) + + params := url.Values{} + if pageToken != "" { + params.Set("page-token", pageToken) + } + + req, err := c.client.NewRequest("GET", &url.URL{Path: path, RawQuery: params.Encode()}, nil) + if err != nil { + return nil, err + } + + var resp JobsResponse + _, err = c.client.DoRequest(req, &resp) + if err != nil { + return nil, err + } + + items = append(items, resp.Items...) + + if resp.NextPageToken == "" { + break + } + pageToken = resp.NextPageToken + } + + return items, nil +} diff --git a/cmd/job/image_tag.go b/cmd/job/image_tag.go new file mode 100644 index 00000000..daf32e2a --- /dev/null +++ b/cmd/job/image_tag.go @@ -0,0 +1,139 @@ +package job + +import ( + "fmt" + "regexp" + "strconv" + "strings" + + "github.com/CircleCI-Public/circleci-cli/cmd/validator" + "github.com/CircleCI-Public/circleci-cli/slug" + "github.com/spf13/cobra" +) + +const defaultImageTagRegex = `(?m)\b([a-zA-Z0-9][a-zA-Z0-9./_-]*:[a-zA-Z0-9][a-zA-Z0-9._-]*)\b` + +func newImageTagCommand(ops *jobOpts, preRunE validator.Validator) *cobra.Command { + var stepSelectors []string + var regexStr string + + cmd := &cobra.Command{ + Use: "image-tag ", + Short: "Extract image tag(s) from job step output.", + Long: `Extract image tag(s) from job step output. + +By default, this searches the "Build Docker image" and "Push Docker images" steps. +You can use --step to override step selection and --regex to customize extraction. + +Examples: + circleci job image-tag gh/GetJobber/lakehouse-event-collector 12345 + circleci job image-tag gh/GetJobber/lakehouse-event-collector 12345 --step "Build Docker image" + circleci job image-tag gh/GetJobber/lakehouse-event-collector 12345 --regex 'IMAGE_TAG=([^\n]+)'`, + PreRunE: preRunE, + RunE: func(cmd *cobra.Command, args []string) error { + projectSlug := args[0] + jobNumber, err := strconv.Atoi(args[1]) + if err != nil { + return fmt.Errorf("invalid job-number %q: %w", args[1], err) + } + + project, err := slug.ParseProject(projectSlug) + if err != nil { + return err + } + v1vcs, err := project.V1VCS() + if err != nil { + return err + } + + details, err := ops.jobClient.GetJobDetails(v1vcs, project.Org, project.Repo, jobNumber) + if err != nil { + return err + } + + selectors := stepSelectors + if len(selectors) == 0 { + selectors = []string{"Build Docker image", "Push Docker images"} + } + + selected, err := selectSteps(details.Steps, selectors) + if err != nil { + return err + } + + var logText strings.Builder + for _, step := range selected { + for _, action := range step.Step.Actions { + if action.OutputURL == "" { + continue + } + output, err := ops.jobClient.GetStepOutput(action.OutputURL) + if err != nil { + return err + } + for _, line := range output { + logText.WriteString(line.Message) + } + } + } + + pattern := regexStr + if pattern == "" { + pattern = defaultImageTagRegex + } + + re, err := regexp.Compile(pattern) + if err != nil { + return err + } + + matches := re.FindAllStringSubmatch(logText.String(), -1) + if len(matches) == 0 { + return fmt.Errorf("no image tags matched regex") + } + + seen := map[string]struct{}{} + out := make([]string, 0, len(matches)) + for _, m := range matches { + value := firstNonEmpty(m[1:]) + if value == "" { + value = m[0] + } + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + + if len(out) == 0 { + return fmt.Errorf("no image tags found") + } + + for _, v := range out { + cmd.Println(v) + } + + return nil + }, + Args: cobra.ExactArgs(2), + } + + cmd.Flags().StringArrayVar(&stepSelectors, "step", nil, "Step name or 1-based index (repeatable)") + cmd.Flags().StringVar(®exStr, "regex", "", "Regex used to extract image tags (first capturing group is preferred)") + + return cmd +} + +func firstNonEmpty(values []string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} diff --git a/cmd/job/image_tag_test.go b/cmd/job/image_tag_test.go new file mode 100644 index 00000000..09af5c60 --- /dev/null +++ b/cmd/job/image_tag_test.go @@ -0,0 +1,104 @@ +package job + +import ( + "bytes" + "fmt" + "testing" + + jobapi "github.com/CircleCI-Public/circleci-cli/api/job" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +type stubImageTagClient struct { + details *jobapi.JobDetails + outputs map[string][]jobapi.StepOutput +} + +func (s *stubImageTagClient) GetJobDetails(vcs string, org string, repo string, jobNumber int) (*jobapi.JobDetails, error) { + return s.details, nil +} + +func (s *stubImageTagClient) GetStepOutput(outputURL string) ([]jobapi.StepOutput, error) { + if out, ok := s.outputs[outputURL]; ok { + return out, nil + } + return nil, fmt.Errorf("unexpected output url %q", outputURL) +} + +func TestJobImageTagCommand(t *testing.T) { + stub := &stubImageTagClient{ + details: &jobapi.JobDetails{ + Steps: []jobapi.Step{ + { + Name: "Build Docker image", + Actions: []jobapi.StepAction{ + {Name: "Build Docker image", OutputURL: "https://example.com/out/build"}, + }, + }, + { + Name: "Push Docker images", + Actions: []jobapi.StepAction{ + {Name: "Push Docker images", OutputURL: "https://example.com/out/push"}, + }, + }, + }, + }, + outputs: map[string][]jobapi.StepOutput{ + "https://example.com/out/build": { + {Message: "docker build -t repo/app:abc123 .\n"}, + }, + "https://example.com/out/push": { + {Message: "docker push repo/app:abc123\n"}, + {Message: "docker push repo/app:def456\n"}, + }, + }, + } + + opts := &jobOpts{jobClient: stub} + noValidator := func(_ *cobra.Command, _ []string) error { return nil } + + t.Run("extracts image tags from default steps", func(t *testing.T) { + cmd := newImageTagCommand(opts, noValidator) + cmd.SetArgs([]string{"gh/test-org/test-repo", "123"}) + + var outBuf, errBuf bytes.Buffer + cmd.SetOut(&outBuf) + cmd.SetErr(&errBuf) + + err := cmd.Execute() + assert.NoError(t, err) + assert.Equal(t, "repo/app:abc123\nrepo/app:def456\n", outBuf.String()+errBuf.String()) + }) + + t.Run("supports custom regex capture group", func(t *testing.T) { + custom := &stubImageTagClient{ + details: &jobapi.JobDetails{ + Steps: []jobapi.Step{ + { + Name: "Build Docker image", + Actions: []jobapi.StepAction{ + {Name: "Build Docker image", OutputURL: "https://example.com/out/custom"}, + }, + }, + }, + }, + outputs: map[string][]jobapi.StepOutput{ + "https://example.com/out/custom": { + {Message: "IMAGE_TAG=abc123\n"}, + }, + }, + } + + cmd := newImageTagCommand(&jobOpts{jobClient: custom}, noValidator) + cmd.SetArgs([]string{"gh/test-org/test-repo", "123", "--regex", `IMAGE_TAG=([^\n]+)`, "--step", "Build Docker image"}) + + var outBuf, errBuf bytes.Buffer + cmd.SetOut(&outBuf) + cmd.SetErr(&errBuf) + + err := cmd.Execute() + assert.NoError(t, err) + assert.Equal(t, "abc123\n", outBuf.String()+errBuf.String()) + }) +} diff --git a/cmd/job/job.go b/cmd/job/job.go new file mode 100644 index 00000000..9dc88d79 --- /dev/null +++ b/cmd/job/job.go @@ -0,0 +1,31 @@ +package job + +import ( + "github.com/spf13/cobra" + + jobapi "github.com/CircleCI-Public/circleci-cli/api/job" + "github.com/CircleCI-Public/circleci-cli/cmd/validator" + "github.com/CircleCI-Public/circleci-cli/settings" +) + +type jobOpts struct { + jobClient jobapi.JobClient +} + +func NewJobCommand(config *settings.Config, preRunE validator.Validator) *cobra.Command { + pos := jobOpts{} + + command := &cobra.Command{ + Use: "job", + Short: "Operate on jobs", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + pos.jobClient = jobapi.NewClient(*config) + return nil + }, + } + + command.AddCommand(newLogsCommand(&pos, preRunE)) + command.AddCommand(newImageTagCommand(&pos, preRunE)) + + return command +} diff --git a/cmd/job/logs.go b/cmd/job/logs.go new file mode 100644 index 00000000..d81db7ec --- /dev/null +++ b/cmd/job/logs.go @@ -0,0 +1,218 @@ +package job + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + + jobapi "github.com/CircleCI-Public/circleci-cli/api/job" + "github.com/CircleCI-Public/circleci-cli/cmd/validator" + "github.com/CircleCI-Public/circleci-cli/slug" + "github.com/spf13/cobra" +) + +type logsStep struct { + Index int `json:"index"` + Name string `json:"name"` + Actions []logsAction `json:"actions"` +} + +type logsAction struct { + Index int `json:"index"` + Name string `json:"name"` + Status string `json:"status,omitempty"` + Type string `json:"type,omitempty"` + Output []jobapi.StepOutput `json:"output"` +} + +type logsResponse struct { + ProjectSlug string `json:"project_slug"` + JobNumber int `json:"job_number"` + Steps []logsStep `json:"steps"` +} + +func newLogsCommand(ops *jobOpts, preRunE validator.Validator) *cobra.Command { + var stepSelectors []string + var jsonFormat bool + + cmd := &cobra.Command{ + Use: "logs ", + Short: "Fetch job logs by reading step outputs.", + Long: `Fetch job logs by reading step outputs. + +This command retrieves job step output via the job details endpoint (API v1.1) and +the presigned output URLs provided for each step action. + +Examples: + circleci job logs gh/GetJobber/lakehouse-event-collector 12345 + circleci job logs gh/GetJobber/lakehouse-event-collector 12345 --step "Build Docker image"`, + PreRunE: preRunE, + RunE: func(cmd *cobra.Command, args []string) error { + projectSlug := args[0] + jobNumber, err := strconv.Atoi(args[1]) + if err != nil { + return fmt.Errorf("invalid job-number %q: %w", args[1], err) + } + + project, err := slug.ParseProject(projectSlug) + if err != nil { + return err + } + v1vcs, err := project.V1VCS() + if err != nil { + return err + } + + details, err := ops.jobClient.GetJobDetails(v1vcs, project.Org, project.Repo, jobNumber) + if err != nil { + return err + } + + selected, err := selectSteps(details.Steps, stepSelectors) + if err != nil { + return err + } + + if jsonFormat { + response := logsResponse{ + ProjectSlug: projectSlug, + JobNumber: jobNumber, + Steps: make([]logsStep, 0, len(selected)), + } + + for _, step := range selected { + stepPayload := logsStep{ + Index: step.Index, + Name: step.Step.Name, + Actions: make([]logsAction, 0, len(step.Step.Actions)), + } + + for actionIndex, action := range step.Step.Actions { + if action.OutputURL == "" { + continue + } + + output, err := ops.jobClient.GetStepOutput(action.OutputURL) + if err != nil { + return err + } + + stepPayload.Actions = append(stepPayload.Actions, logsAction{ + Index: actionIndex, + Name: action.Name, + Status: action.Status, + Type: action.Type, + Output: output, + }) + } + + response.Steps = append(response.Steps, stepPayload) + } + + payload, err := json.Marshal(response) + if err != nil { + return err + } + cmd.Println(string(payload)) + return nil + } + + for _, step := range selected { + for _, action := range step.Step.Actions { + if action.OutputURL == "" { + continue + } + output, err := ops.jobClient.GetStepOutput(action.OutputURL) + if err != nil { + return err + } + for _, line := range output { + cmd.Print(line.Message) + } + } + } + + return nil + }, + Args: cobra.ExactArgs(2), + } + + cmd.Flags().StringArrayVar(&stepSelectors, "step", nil, "Step name or 1-based index (repeatable)") + cmd.Flags().BoolVar(&jsonFormat, "json", false, "Return output back in JSON format") + + return cmd +} + +type selectedStep struct { + Index int + Step jobapi.Step +} + +func selectSteps(steps []jobapi.Step, selectors []string) ([]selectedStep, error) { + if len(selectors) == 0 { + all := make([]selectedStep, 0, len(steps)) + for i, s := range steps { + all = append(all, selectedStep{Index: i, Step: s}) + } + return all, nil + } + + selected := make([]bool, len(steps)) + + for _, rawSelector := range selectors { + selector := strings.TrimSpace(rawSelector) + if selector == "" { + continue + } + + if index, err := strconv.Atoi(selector); err == nil { + if index <= 0 { + return nil, fmt.Errorf("invalid step index %d (expected 1..%d)", index, len(steps)) + } + zeroIdx := index - 1 + if zeroIdx >= len(steps) { + return nil, fmt.Errorf("invalid step index %d (expected 1..%d)", index, len(steps)) + } + selected[zeroIdx] = true + continue + } + + matched := false + for i := range steps { + if strings.EqualFold(steps[i].Name, selector) { + selected[i] = true + matched = true + } + } + + if matched { + continue + } + + needle := strings.ToLower(selector) + for i := range steps { + if strings.Contains(strings.ToLower(steps[i].Name), needle) { + selected[i] = true + matched = true + } + } + + if !matched { + return nil, fmt.Errorf("no step matched %q", selector) + } + } + + out := make([]selectedStep, 0, len(steps)) + for i, isSelected := range selected { + if isSelected { + out = append(out, selectedStep{Index: i, Step: steps[i]}) + } + } + + if len(out) == 0 { + return nil, fmt.Errorf("no steps selected") + } + + return out, nil +} diff --git a/cmd/job/logs_test.go b/cmd/job/logs_test.go new file mode 100644 index 00000000..06ce6f80 --- /dev/null +++ b/cmd/job/logs_test.go @@ -0,0 +1,140 @@ +package job + +import ( + "bytes" + "encoding/json" + "fmt" + "testing" + + jobapi "github.com/CircleCI-Public/circleci-cli/api/job" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +type stubJobClient struct { + details *jobapi.JobDetails + outputs map[string][]jobapi.StepOutput + + gotVCS string + gotOrg string + gotRepo string + gotJobNumber int +} + +func (s *stubJobClient) GetJobDetails(vcs string, org string, repo string, jobNumber int) (*jobapi.JobDetails, error) { + s.gotVCS = vcs + s.gotOrg = org + s.gotRepo = repo + s.gotJobNumber = jobNumber + return s.details, nil +} + +func (s *stubJobClient) GetStepOutput(outputURL string) ([]jobapi.StepOutput, error) { + if out, ok := s.outputs[outputURL]; ok { + return out, nil + } + return nil, fmt.Errorf("unexpected output url %q", outputURL) +} + +func TestJobLogsCommand(t *testing.T) { + secretOutputURL := "https://circleci.example/api/private/output/presigned/123?token=SECRET_TOKEN" + + stub := &stubJobClient{ + details: &jobapi.JobDetails{ + BuildNum: 123, + Status: "success", + Steps: []jobapi.Step{ + { + Name: "Checkout code", + Actions: []jobapi.StepAction{ + {Name: "Checkout code", OutputURL: "https://example.com/out/1"}, + }, + }, + { + Name: "Build Docker image", + Actions: []jobapi.StepAction{ + {Name: "Build Docker image", OutputURL: secretOutputURL, Status: "success", Type: "test"}, + }, + }, + }, + }, + outputs: map[string][]jobapi.StepOutput{ + "https://example.com/out/1": { + {Message: "checkout\n"}, + }, + secretOutputURL: { + {Message: "docker build -t repo/app:abc123 .\n"}, + }, + }, + } + + opts := &jobOpts{jobClient: stub} + noValidator := func(_ *cobra.Command, _ []string) error { return nil } + + t.Run("prints full job output by default", func(t *testing.T) { + cmd := newLogsCommand(opts, noValidator) + cmd.SetArgs([]string{"gh/test-org/test-repo", "123"}) + + var outBuf, errBuf bytes.Buffer + cmd.SetOut(&outBuf) + cmd.SetErr(&errBuf) + + err := cmd.Execute() + assert.NoError(t, err) + assert.Equal(t, "checkout\ndocker build -t repo/app:abc123 .\n", outBuf.String()+errBuf.String()) + + assert.Equal(t, "github", stub.gotVCS) + assert.Equal(t, "test-org", stub.gotOrg) + assert.Equal(t, "test-repo", stub.gotRepo) + assert.Equal(t, 123, stub.gotJobNumber) + }) + + t.Run("filters by step name (substring match)", func(t *testing.T) { + cmd := newLogsCommand(opts, noValidator) + cmd.SetArgs([]string{"gh/test-org/test-repo", "123", "--step", "Build"}) + + var outBuf, errBuf bytes.Buffer + cmd.SetOut(&outBuf) + cmd.SetErr(&errBuf) + + err := cmd.Execute() + assert.NoError(t, err) + assert.Equal(t, "docker build -t repo/app:abc123 .\n", outBuf.String()+errBuf.String()) + }) + + t.Run("filters by step index", func(t *testing.T) { + cmd := newLogsCommand(opts, noValidator) + cmd.SetArgs([]string{"gh/test-org/test-repo", "123", "--step", "2"}) + + var outBuf, errBuf bytes.Buffer + cmd.SetOut(&outBuf) + cmd.SetErr(&errBuf) + + err := cmd.Execute() + assert.NoError(t, err) + assert.Equal(t, "docker build -t repo/app:abc123 .\n", outBuf.String()+errBuf.String()) + }) + + t.Run("json output does not include output urls", func(t *testing.T) { + cmd := newLogsCommand(opts, noValidator) + cmd.SetArgs([]string{"gh/test-org/test-repo", "123", "--json"}) + + var outBuf, errBuf bytes.Buffer + cmd.SetOut(&outBuf) + cmd.SetErr(&errBuf) + + err := cmd.Execute() + assert.NoError(t, err) + + gotRaw := outBuf.String() + errBuf.String() + assert.NotContains(t, gotRaw, "SECRET_TOKEN") + + var parsed logsResponse + assert.NoError(t, json.Unmarshal([]byte(gotRaw), &parsed)) + assert.Equal(t, "gh/test-org/test-repo", parsed.ProjectSlug) + assert.Equal(t, 123, parsed.JobNumber) + assert.Len(t, parsed.Steps, 2) + assert.Equal(t, "Checkout code", parsed.Steps[0].Name) + assert.Equal(t, "Build Docker image", parsed.Steps[1].Name) + }) +} diff --git a/cmd/pipeline/definitions.go b/cmd/pipeline/definitions.go new file mode 100644 index 00000000..4a7d60e7 --- /dev/null +++ b/cmd/pipeline/definitions.go @@ -0,0 +1,17 @@ +package pipeline + +import ( + "github.com/CircleCI-Public/circleci-cli/cmd/validator" + "github.com/spf13/cobra" +) + +func newDefinitionsCommand(ops *pipelineOpts, preRunE validator.Validator) *cobra.Command { + cmd := &cobra.Command{ + Use: "definitions", + Short: "Operate on pipeline definitions", + } + + cmd.AddCommand(newDefinitionsListCommand(ops, preRunE)) + + return cmd +} diff --git a/cmd/pipeline/definitions_list.go b/cmd/pipeline/definitions_list.go new file mode 100644 index 00000000..abe27497 --- /dev/null +++ b/cmd/pipeline/definitions_list.go @@ -0,0 +1,48 @@ +package pipeline + +import ( + "github.com/CircleCI-Public/circleci-cli/cmd/validator" + "github.com/CircleCI-Public/circleci-cli/slug" + "github.com/spf13/cobra" +) + +func newDefinitionsListCommand(ops *pipelineOpts, preRunE validator.Validator) *cobra.Command { + cmd := &cobra.Command{ + Use: "list ", + Short: "List pipeline definitions for a project.", + Long: `List pipeline definitions for a project. + +Examples: + circleci pipeline definitions list gh/CircleCI-Public/circleci-cli`, + PreRunE: preRunE, + RunE: func(cmd *cobra.Command, args []string) error { + projectSlug := args[0] + + parsed, err := slug.ParseProject(projectSlug) + if err != nil { + return err + } + + projectInfo, err := ops.projectClient.ProjectInfo(parsed.VCS, parsed.Org, parsed.Repo) + if err != nil { + return err + } + + definitions, err := ops.pipelineClient.ListPipelineDefinitions(projectInfo.Id) + if err != nil { + return err + } + + if len(definitions) == 0 { + cmd.Println("No pipeline definitions found for this project.") + return nil + } + + printPipelineDefinitions(cmd, definitions) + return nil + }, + Args: cobra.ExactArgs(1), + } + + return cmd +} diff --git a/cmd/pipeline/definitions_list_test.go b/cmd/pipeline/definitions_list_test.go new file mode 100644 index 00000000..045a1308 --- /dev/null +++ b/cmd/pipeline/definitions_list_test.go @@ -0,0 +1,91 @@ +package pipeline + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + pipelineapi "github.com/CircleCI-Public/circleci-cli/api/pipeline" + projectapi "github.com/CircleCI-Public/circleci-cli/api/project" + "github.com/CircleCI-Public/circleci-cli/settings" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func TestPipelineDefinitionsListCommand(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/project/gh/test-org/test-repo": + assert.Equal(t, http.MethodGet, r.Method) + _ = json.NewEncoder(w).Encode(map[string]string{"id": "test-project-id"}) + case "/projects/test-project-id/pipeline-definitions": + assert.Equal(t, http.MethodGet, r.Method) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "items": []pipelineapi.PipelineDefinitionInfo{ + { + ID: "123", + Name: "test-pipeline", + Description: "test-description", + ConfigSource: pipelineapi.ConfigSourceResponse{ + Provider: "github_app", + Repo: pipelineapi.RepoResponse{ + FullName: "", + }, + FilePath: "", + }, + CheckoutSource: pipelineapi.CheckoutSourceResponse{ + Provider: "github_app", + Repo: pipelineapi.RepoResponse{ + FullName: "", + }, + }, + }, + }, + }) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + cfg := settings.Config{ + Token: "testtoken", + Host: server.URL, + HTTPClient: http.DefaultClient, + } + + pipelineClient, err := pipelineapi.NewPipelineRestClient(cfg) + assert.NoError(t, err) + + projectClient, err := projectapi.NewProjectRestClient(cfg) + assert.NoError(t, err) + + opts := &pipelineOpts{ + pipelineClient: pipelineClient, + projectClient: projectClient, + } + + noValidator := func(_ *cobra.Command, _ []string) error { return nil } + + cmd := newDefinitionsListCommand(opts, noValidator) + cmd.SetArgs([]string{"gh/test-org/test-repo"}) + + var outBuf, errBuf bytes.Buffer + cmd.SetOut(&outBuf) + cmd.SetErr(&errBuf) + + err = cmd.Execute() + assert.NoError(t, err) + assert.Equal(t, `Pipeline Definitions: + +ID: 123 +Name: test-pipeline +Description: test-description +Config Source: () +Checkout Source: +`, outBuf.String()+errBuf.String()) +} diff --git a/cmd/pipeline/list.go b/cmd/pipeline/list.go index fed1bed8..fdb293ac 100644 --- a/cmd/pipeline/list.go +++ b/cmd/pipeline/list.go @@ -3,22 +3,40 @@ package pipeline import ( "github.com/spf13/cobra" + pipelineapi "github.com/CircleCI-Public/circleci-cli/api/pipeline" "github.com/CircleCI-Public/circleci-cli/cmd/validator" + "github.com/CircleCI-Public/circleci-cli/slug" ) func newListCommand(ops *pipelineOpts, preRunE validator.Validator) *cobra.Command { cmd := &cobra.Command{ - Use: "list ", + Use: "list ", Short: "List pipeline definitions for a project.", Long: `List pipeline definitions for a project. The project ID can be found in your project settings or in the URL of your project page. +Alternatively, you can provide a project slug in the format //. Examples: - circleci pipeline list 12345678-1234-1234-1234-123456789012`, + circleci pipeline list 12345678-1234-1234-1234-123456789012 + circleci pipeline list gh/CircleCI-Public/circleci-cli`, PreRunE: preRunE, RunE: func(cmd *cobra.Command, args []string) error { - projectID := args[0] + projectIDOrSlug := args[0] + + projectID := projectIDOrSlug + if isProjectSlug(projectIDOrSlug) { + parsed, err := slug.ParseProject(projectIDOrSlug) + if err != nil { + return err + } + + info, err := ops.projectClient.ProjectInfo(parsed.VCS, parsed.Org, parsed.Repo) + if err != nil { + return err + } + projectID = info.Id + } definitions, err := ops.pipelineClient.ListPipelineDefinitions(projectID) if err != nil { @@ -30,16 +48,7 @@ Examples: return nil } - cmd.Println("Pipeline Definitions:") - for _, def := range definitions { - cmd.Printf("\nID: %s\n", def.ID) - cmd.Printf("Name: %s\n", def.Name) - if def.Description != "" { - cmd.Printf("Description: %s\n", def.Description) - } - cmd.Printf("Config Source: %s (%s)\n", def.ConfigSource.Repo.FullName, def.ConfigSource.FilePath) - cmd.Printf("Checkout Source: %s\n", def.CheckoutSource.Repo.FullName) - } + printPipelineDefinitions(cmd, definitions) return nil }, @@ -48,3 +57,29 @@ Examples: return cmd } + +func printPipelineDefinitions(cmd *cobra.Command, definitions []*pipelineapi.PipelineDefinitionInfo) { + cmd.Println("Pipeline Definitions:") + for _, def := range definitions { + cmd.Printf("\nID: %s\n", def.ID) + cmd.Printf("Name: %s\n", def.Name) + if def.Description != "" { + cmd.Printf("Description: %s\n", def.Description) + } + cmd.Printf("Config Source: %s (%s)\n", def.ConfigSource.Repo.FullName, def.ConfigSource.FilePath) + cmd.Printf("Checkout Source: %s\n", def.CheckoutSource.Repo.FullName) + } +} + +func isProjectSlug(value string) bool { + return len(value) > 0 && containsRune(value, '/') +} + +func containsRune(s string, needle rune) bool { + for _, r := range s { + if r == needle { + return true + } + } + return false +} diff --git a/cmd/pipeline/list_slug_test.go b/cmd/pipeline/list_slug_test.go new file mode 100644 index 00000000..59a2c800 --- /dev/null +++ b/cmd/pipeline/list_slug_test.go @@ -0,0 +1,91 @@ +package pipeline + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + pipelineapi "github.com/CircleCI-Public/circleci-cli/api/pipeline" + projectapi "github.com/CircleCI-Public/circleci-cli/api/project" + "github.com/CircleCI-Public/circleci-cli/settings" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func TestListPipelineDefinitionsBySlug(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/project/gh/test-org/test-repo": + assert.Equal(t, http.MethodGet, r.Method) + _ = json.NewEncoder(w).Encode(map[string]string{"id": "test-project-id"}) + case "/projects/test-project-id/pipeline-definitions": + assert.Equal(t, http.MethodGet, r.Method) + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "items": []pipelineapi.PipelineDefinitionInfo{ + { + ID: "123", + Name: "test-pipeline", + Description: "test-description", + ConfigSource: pipelineapi.ConfigSourceResponse{ + Provider: "github_app", + Repo: pipelineapi.RepoResponse{ + FullName: "", + }, + FilePath: "", + }, + CheckoutSource: pipelineapi.CheckoutSourceResponse{ + Provider: "github_app", + Repo: pipelineapi.RepoResponse{ + FullName: "", + }, + }, + }, + }, + }) + default: + t.Fatalf("unexpected path: %s", r.URL.Path) + } + })) + defer server.Close() + + cfg := settings.Config{ + Token: "testtoken", + Host: server.URL, + HTTPClient: http.DefaultClient, + } + + pipelineClient, err := pipelineapi.NewPipelineRestClient(cfg) + assert.NoError(t, err) + + projectClient, err := projectapi.NewProjectRestClient(cfg) + assert.NoError(t, err) + + opts := &pipelineOpts{ + pipelineClient: pipelineClient, + projectClient: projectClient, + } + + noValidator := func(_ *cobra.Command, _ []string) error { return nil } + + cmd := newListCommand(opts, noValidator) + cmd.SetArgs([]string{"gh/test-org/test-repo"}) + + var outBuf, errBuf bytes.Buffer + cmd.SetOut(&outBuf) + cmd.SetErr(&errBuf) + + err = cmd.Execute() + assert.NoError(t, err) + assert.Equal(t, `Pipeline Definitions: + +ID: 123 +Name: test-pipeline +Description: test-description +Config Source: () +Checkout Source: +`, outBuf.String()+errBuf.String()) +} diff --git a/cmd/pipeline/list_test.go b/cmd/pipeline/list_test.go index a5b2766e..88a07c9c 100644 --- a/cmd/pipeline/list_test.go +++ b/cmd/pipeline/list_test.go @@ -60,7 +60,7 @@ Checkout Source: name: "Handle API error", response: nil, expectedError: "error", - expectedOutput: "Error: error\nUsage:\n list [flags]\n\nFlags:\n -h, --help help for list\n", + expectedOutput: "Error: error\nUsage:\n list [flags]\n\nFlags:\n -h, --help help for list\n", }, } diff --git a/cmd/pipeline/pipeline.go b/cmd/pipeline/pipeline.go index 4db66f8b..32ffdeb8 100644 --- a/cmd/pipeline/pipeline.go +++ b/cmd/pipeline/pipeline.go @@ -4,6 +4,7 @@ import ( "github.com/spf13/cobra" pipelineapi "github.com/CircleCI-Public/circleci-cli/api/pipeline" + projectapi "github.com/CircleCI-Public/circleci-cli/api/project" "github.com/CircleCI-Public/circleci-cli/cmd/validator" "github.com/CircleCI-Public/circleci-cli/prompt" "github.com/CircleCI-Public/circleci-cli/settings" @@ -17,6 +18,7 @@ type UserInputReader interface { type pipelineOpts struct { pipelineClient pipelineapi.PipelineClient + projectClient projectapi.ProjectClient reader UserInputReader } @@ -52,6 +54,12 @@ func NewPipelineCommand(config *settings.Config, preRunE validator.Validator, op return err } pos.pipelineClient = client + + projectClient, err := projectapi.NewProjectRestClient(*config) + if err != nil { + return err + } + pos.projectClient = projectClient return nil }, } @@ -59,6 +67,9 @@ func NewPipelineCommand(config *settings.Config, preRunE validator.Validator, op command.AddCommand(newCreateCommand(&pos, preRunE)) command.AddCommand(newListCommand(&pos, preRunE)) command.AddCommand(newRunCommand(&pos, preRunE)) + command.AddCommand(newDefinitionsCommand(&pos, preRunE)) + command.AddCommand(newWorkflowsCommand(&pos, preRunE)) + command.AddCommand(newRunsCommand(&pos, preRunE)) return command } diff --git a/cmd/pipeline/runs.go b/cmd/pipeline/runs.go new file mode 100644 index 00000000..8670e7a1 --- /dev/null +++ b/cmd/pipeline/runs.go @@ -0,0 +1,17 @@ +package pipeline + +import ( + "github.com/CircleCI-Public/circleci-cli/cmd/validator" + "github.com/spf13/cobra" +) + +func newRunsCommand(ops *pipelineOpts, preRunE validator.Validator) *cobra.Command { + cmd := &cobra.Command{ + Use: "runs", + Short: "Operate on pipeline runs", + } + + cmd.AddCommand(newRunsLatestCommand(ops, preRunE)) + + return cmd +} diff --git a/cmd/pipeline/runs_latest.go b/cmd/pipeline/runs_latest.go new file mode 100644 index 00000000..3c67a34d --- /dev/null +++ b/cmd/pipeline/runs_latest.go @@ -0,0 +1,60 @@ +package pipeline + +import ( + "encoding/json" + "fmt" + + pipelineapi "github.com/CircleCI-Public/circleci-cli/api/pipeline" + "github.com/CircleCI-Public/circleci-cli/cmd/validator" + "github.com/spf13/cobra" +) + +func newRunsLatestCommand(ops *pipelineOpts, preRunE validator.Validator) *cobra.Command { + var branch string + var jsonFormat bool + + cmd := &cobra.Command{ + Use: "latest ", + Short: "Print the latest pipeline run for a project.", + Long: `Print the latest pipeline run for a project. + +Examples: + circleci pipeline runs latest gh/CircleCI-Public/circleci-cli --branch main`, + PreRunE: preRunE, + RunE: func(cmd *cobra.Command, args []string) error { + projectSlug := args[0] + + resp, err := ops.pipelineClient.ListPipelinesForProject(projectSlug, pipelineapi.ListPipelinesOptions{ + Branch: branch, + }) + if err != nil { + return err + } + + if len(resp.Items) == 0 { + return fmt.Errorf("no pipelines found for project %s (branch=%s)", projectSlug, branch) + } + + latest := resp.Items[0] + + if jsonFormat { + payload, err := json.Marshal(latest) + if err != nil { + return err + } + cmd.Println(string(payload)) + return nil + } + + cmd.Printf("%s\t%d\n", latest.ID, latest.Number) + return nil + }, + Args: cobra.ExactArgs(1), + } + + cmd.Flags().StringVar(&branch, "branch", "", "The name of a vcs branch.") + cmd.Flags().BoolVar(&jsonFormat, "json", false, "Return output back in JSON format") + _ = cmd.MarkFlagRequired("branch") + + return cmd +} diff --git a/cmd/pipeline/runs_latest_test.go b/cmd/pipeline/runs_latest_test.go new file mode 100644 index 00000000..65e11775 --- /dev/null +++ b/cmd/pipeline/runs_latest_test.go @@ -0,0 +1,79 @@ +package pipeline + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + pipelineapi "github.com/CircleCI-Public/circleci-cli/api/pipeline" + "github.com/CircleCI-Public/circleci-cli/settings" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func TestPipelineRunsLatestCommand(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/project/gh/test-org/test-repo/pipeline", r.URL.Path) + assert.Equal(t, "main", r.URL.Query().Get("branch")) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "next_page_token": "", + "items": []pipelineapi.Pipeline{ + { + ID: "pipeline-id", + Number: 456, + }, + }, + }) + })) + defer server.Close() + + cfg := settings.Config{ + Token: "testtoken", + Host: server.URL, + HTTPClient: http.DefaultClient, + } + + client, err := pipelineapi.NewPipelineRestClient(cfg) + assert.NoError(t, err) + + opts := &pipelineOpts{ + pipelineClient: client, + } + + noValidator := func(_ *cobra.Command, _ []string) error { return nil } + + t.Run("prints id and number", func(t *testing.T) { + cmd := newRunsLatestCommand(opts, noValidator) + cmd.SetArgs([]string{"gh/test-org/test-repo", "--branch", "main"}) + + var outBuf, errBuf bytes.Buffer + cmd.SetOut(&outBuf) + cmd.SetErr(&errBuf) + + err := cmd.Execute() + assert.NoError(t, err) + assert.Equal(t, "pipeline-id\t456\n", outBuf.String()+errBuf.String()) + }) + + t.Run("json output", func(t *testing.T) { + cmd := newRunsLatestCommand(opts, noValidator) + cmd.SetArgs([]string{"gh/test-org/test-repo", "--branch", "main", "--json"}) + + var outBuf, errBuf bytes.Buffer + cmd.SetOut(&outBuf) + cmd.SetErr(&errBuf) + + err := cmd.Execute() + assert.NoError(t, err) + + var got pipelineapi.Pipeline + assert.NoError(t, json.Unmarshal([]byte(outBuf.String()+errBuf.String()), &got)) + assert.Equal(t, "pipeline-id", got.ID) + assert.Equal(t, 456, got.Number) + }) +} diff --git a/cmd/pipeline/workflows.go b/cmd/pipeline/workflows.go new file mode 100644 index 00000000..31f574d8 --- /dev/null +++ b/cmd/pipeline/workflows.go @@ -0,0 +1,50 @@ +package pipeline + +import ( + "encoding/json" + + "github.com/CircleCI-Public/circleci-cli/cmd/validator" + "github.com/spf13/cobra" +) + +func newWorkflowsCommand(ops *pipelineOpts, preRunE validator.Validator) *cobra.Command { + var jsonFormat bool + + cmd := &cobra.Command{ + Use: "workflows ", + Short: "List workflows for a pipeline.", + Long: `List workflows for a pipeline. + +Examples: + circleci pipeline workflows 5034460f-c7c4-4c43-9457-de07e2029e7b`, + PreRunE: preRunE, + RunE: func(cmd *cobra.Command, args []string) error { + pipelineID := args[0] + + workflows, err := ops.pipelineClient.ListWorkflowsByPipelineId(pipelineID) + if err != nil { + return err + } + + if jsonFormat { + payload, err := json.Marshal(workflows) + if err != nil { + return err + } + cmd.Println(string(payload)) + return nil + } + + for _, w := range workflows { + cmd.Printf("%s\t%s\t%s\n", w.ID, w.Name, w.Status) + } + + return nil + }, + Args: cobra.ExactArgs(1), + } + + cmd.Flags().BoolVar(&jsonFormat, "json", false, "Return output back in JSON format") + + return cmd +} diff --git a/cmd/pipeline/workflows_test.go b/cmd/pipeline/workflows_test.go new file mode 100644 index 00000000..3c1f7179 --- /dev/null +++ b/cmd/pipeline/workflows_test.go @@ -0,0 +1,79 @@ +package pipeline + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + pipelineapi "github.com/CircleCI-Public/circleci-cli/api/pipeline" + "github.com/CircleCI-Public/circleci-cli/settings" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func TestPipelineWorkflowsCommand(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/pipeline/pipeline-id/workflow", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "next_page_token": "", + "items": []pipelineapi.Workflow{ + { + ID: "workflow-id", + Name: "ci", + Status: "success", + }, + }, + }) + })) + defer server.Close() + + cfg := settings.Config{ + Token: "testtoken", + Host: server.URL, + HTTPClient: http.DefaultClient, + } + + client, err := pipelineapi.NewPipelineRestClient(cfg) + assert.NoError(t, err) + + opts := &pipelineOpts{ + pipelineClient: client, + } + + noValidator := func(_ *cobra.Command, _ []string) error { return nil } + + t.Run("prints tab-separated workflows", func(t *testing.T) { + cmd := newWorkflowsCommand(opts, noValidator) + cmd.SetArgs([]string{"pipeline-id"}) + + var outBuf, errBuf bytes.Buffer + cmd.SetOut(&outBuf) + cmd.SetErr(&errBuf) + + err := cmd.Execute() + assert.NoError(t, err) + assert.Equal(t, "workflow-id\tci\tsuccess\n", outBuf.String()+errBuf.String()) + }) + + t.Run("json output", func(t *testing.T) { + cmd := newWorkflowsCommand(opts, noValidator) + cmd.SetArgs([]string{"pipeline-id", "--json"}) + + var outBuf, errBuf bytes.Buffer + cmd.SetOut(&outBuf) + cmd.SetErr(&errBuf) + + err := cmd.Execute() + assert.NoError(t, err) + + var got []pipelineapi.Workflow + assert.NoError(t, json.Unmarshal([]byte(outBuf.String()+errBuf.String()), &got)) + assert.Len(t, got, 1) + assert.Equal(t, "workflow-id", got[0].ID) + }) +} diff --git a/cmd/project/id.go b/cmd/project/id.go new file mode 100644 index 00000000..002facb3 --- /dev/null +++ b/cmd/project/id.go @@ -0,0 +1,38 @@ +package project + +import ( + "github.com/CircleCI-Public/circleci-cli/cmd/validator" + "github.com/CircleCI-Public/circleci-cli/slug" + "github.com/spf13/cobra" +) + +func newProjectIDCommand(ops *projectOpts, preRunE validator.Validator) *cobra.Command { + cmd := &cobra.Command{ + Use: "id ", + Short: "Print the project ID (UUID) for a given project slug.", + Long: `Print the project ID (UUID) for a given project slug. + +Examples: + circleci project id gh/CircleCI-Public/circleci-cli + circleci project id circleci/9YytKzouJxzu4TjCRFqAoD/44n9wujWcTnVZ2b5S8Fnat`, + PreRunE: preRunE, + RunE: func(cmd *cobra.Command, args []string) error { + projectSlug := args[0] + parsed, err := slug.ParseProject(projectSlug) + if err != nil { + return err + } + + info, err := ops.projectClient.ProjectInfo(parsed.VCS, parsed.Org, parsed.Repo) + if err != nil { + return err + } + + cmd.Println(info.Id) + return nil + }, + Args: cobra.ExactArgs(1), + } + + return cmd +} diff --git a/cmd/project/id_test.go b/cmd/project/id_test.go new file mode 100644 index 00000000..5dcc9466 --- /dev/null +++ b/cmd/project/id_test.go @@ -0,0 +1,51 @@ +package project + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + projectapi "github.com/CircleCI-Public/circleci-cli/api/project" + "github.com/CircleCI-Public/circleci-cli/settings" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func TestProjectIDCommand(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/project/gh/test-org/test-repo", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]string{"id": "123e4567-e89b-12d3-a456-426614174000"}) + })) + defer server.Close() + + cfg := settings.Config{ + Token: "testtoken", + Host: server.URL, + HTTPClient: http.DefaultClient, + } + + client, err := projectapi.NewProjectRestClient(cfg) + assert.NoError(t, err) + + opts := &projectOpts{ + projectClient: client, + } + + noValidator := func(_ *cobra.Command, _ []string) error { return nil } + + cmd := newProjectIDCommand(opts, noValidator) + cmd.SetArgs([]string{"gh/test-org/test-repo"}) + + var outBuf, errBuf bytes.Buffer + cmd.SetOut(&outBuf) + cmd.SetErr(&errBuf) + + err = cmd.Execute() + assert.NoError(t, err) + assert.Equal(t, "123e4567-e89b-12d3-a456-426614174000\n", outBuf.String()+errBuf.String()) +} diff --git a/cmd/project/project.go b/cmd/project/project.go index 01620d63..e4a8b36b 100644 --- a/cmd/project/project.go +++ b/cmd/project/project.go @@ -59,6 +59,7 @@ func NewProjectCommand(config *settings.Config, preRunE validator.Validator, opt command.AddCommand(newProjectEnvironmentVariableCommand(&pos, preRunE)) command.AddCommand(newProjectDLCCommand(config, &pos, preRunE)) command.AddCommand(newProjectCreateCommand(&pos, preRunE)) + command.AddCommand(newProjectIDCommand(&pos, preRunE)) return command } diff --git a/cmd/root.go b/cmd/root.go index e70f5edd..259d93f0 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -9,11 +9,13 @@ import ( "github.com/CircleCI-Public/circleci-cli/api/header" "github.com/CircleCI-Public/circleci-cli/cmd/info" + "github.com/CircleCI-Public/circleci-cli/cmd/job" "github.com/CircleCI-Public/circleci-cli/cmd/pipeline" "github.com/CircleCI-Public/circleci-cli/cmd/policy" "github.com/CircleCI-Public/circleci-cli/cmd/project" "github.com/CircleCI-Public/circleci-cli/cmd/runner" "github.com/CircleCI-Public/circleci-cli/cmd/trigger" + "github.com/CircleCI-Public/circleci-cli/cmd/workflow" "github.com/CircleCI-Public/circleci-cli/data" "github.com/CircleCI-Public/circleci-cli/md_docs" "github.com/CircleCI-Public/circleci-cli/settings" @@ -162,6 +164,8 @@ func MakeCommands() *cobra.Command { rootCmd.AddCommand(project.NewProjectCommand(rootOptions, validator)) rootCmd.AddCommand(trigger.NewTriggerCommand(rootOptions, validator)) rootCmd.AddCommand(pipeline.NewPipelineCommand(rootOptions, validator)) + rootCmd.AddCommand(workflow.NewWorkflowCommand(rootOptions, validator)) + rootCmd.AddCommand(job.NewJobCommand(rootOptions, validator)) rootCmd.AddCommand(newQueryCommand(rootOptions)) rootCmd.AddCommand(newConfigCommand(rootOptions)) rootCmd.AddCommand(newOrbCommand(rootOptions)) diff --git a/cmd/root_test.go b/cmd/root_test.go index bdcc6f53..37065afe 100644 --- a/cmd/root_test.go +++ b/cmd/root_test.go @@ -16,7 +16,7 @@ var _ = Describe("Root", func() { Describe("subcommands", func() { It("can create commands", func() { commands := cmd.MakeCommands() - Expect(len(commands.Commands())).To(Equal(29)) + Expect(len(commands.Commands())).To(Equal(31)) }) }) diff --git a/cmd/workflow/jobs.go b/cmd/workflow/jobs.go new file mode 100644 index 00000000..9aeaeffe --- /dev/null +++ b/cmd/workflow/jobs.go @@ -0,0 +1,50 @@ +package workflow + +import ( + "encoding/json" + + "github.com/CircleCI-Public/circleci-cli/cmd/validator" + "github.com/spf13/cobra" +) + +func newJobsCommand(ops *workflowOpts, preRunE validator.Validator) *cobra.Command { + var jsonFormat bool + + cmd := &cobra.Command{ + Use: "jobs ", + Short: "List jobs for a workflow.", + Long: `List jobs for a workflow. + +Examples: + circleci workflow jobs 93f07932-8924-42cb-88d3-9e0c4ad75905`, + PreRunE: preRunE, + RunE: func(cmd *cobra.Command, args []string) error { + workflowID := args[0] + + jobs, err := ops.workflowClient.ListWorkflowJobs(workflowID) + if err != nil { + return err + } + + if jsonFormat { + payload, err := json.Marshal(jobs) + if err != nil { + return err + } + cmd.Println(string(payload)) + return nil + } + + for _, j := range jobs { + cmd.Printf("%s\t%s\t%d\n", j.Name, j.Status, j.JobNumber) + } + + return nil + }, + Args: cobra.ExactArgs(1), + } + + cmd.Flags().BoolVar(&jsonFormat, "json", false, "Return output back in JSON format") + + return cmd +} diff --git a/cmd/workflow/jobs_test.go b/cmd/workflow/jobs_test.go new file mode 100644 index 00000000..1a24cc4b --- /dev/null +++ b/cmd/workflow/jobs_test.go @@ -0,0 +1,79 @@ +package workflow + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + workflowapi "github.com/CircleCI-Public/circleci-cli/api/workflow" + "github.com/CircleCI-Public/circleci-cli/settings" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" +) + +func TestWorkflowJobsCommand(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/workflow/workflow-id/job", r.URL.Path) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "next_page_token": "", + "items": []workflowapi.Job{ + { + Name: "build", + Status: "success", + JobNumber: 123, + }, + }, + }) + })) + defer server.Close() + + cfg := settings.Config{ + Token: "testtoken", + Host: server.URL, + HTTPClient: http.DefaultClient, + } + + client, err := workflowapi.NewWorkflowRestClient(cfg) + assert.NoError(t, err) + + opts := &workflowOpts{ + workflowClient: client, + } + + noValidator := func(_ *cobra.Command, _ []string) error { return nil } + + t.Run("prints job status and number", func(t *testing.T) { + cmd := newJobsCommand(opts, noValidator) + cmd.SetArgs([]string{"workflow-id"}) + + var outBuf, errBuf bytes.Buffer + cmd.SetOut(&outBuf) + cmd.SetErr(&errBuf) + + err := cmd.Execute() + assert.NoError(t, err) + assert.Equal(t, "build\tsuccess\t123\n", outBuf.String()+errBuf.String()) + }) + + t.Run("json output", func(t *testing.T) { + cmd := newJobsCommand(opts, noValidator) + cmd.SetArgs([]string{"workflow-id", "--json"}) + + var outBuf, errBuf bytes.Buffer + cmd.SetOut(&outBuf) + cmd.SetErr(&errBuf) + + err := cmd.Execute() + assert.NoError(t, err) + + var got []workflowapi.Job + assert.NoError(t, json.Unmarshal([]byte(outBuf.String()+errBuf.String()), &got)) + assert.Len(t, got, 1) + assert.Equal(t, 123, got[0].JobNumber) + }) +} diff --git a/cmd/workflow/workflow.go b/cmd/workflow/workflow.go new file mode 100644 index 00000000..1df9dbe4 --- /dev/null +++ b/cmd/workflow/workflow.go @@ -0,0 +1,34 @@ +package workflow + +import ( + "github.com/spf13/cobra" + + workflowapi "github.com/CircleCI-Public/circleci-cli/api/workflow" + "github.com/CircleCI-Public/circleci-cli/cmd/validator" + "github.com/CircleCI-Public/circleci-cli/settings" +) + +type workflowOpts struct { + workflowClient workflowapi.WorkflowClient +} + +func NewWorkflowCommand(config *settings.Config, preRunE validator.Validator) *cobra.Command { + pos := workflowOpts{} + + command := &cobra.Command{ + Use: "workflow", + Short: "Operate on workflows", + PersistentPreRunE: func(cmd *cobra.Command, args []string) error { + client, err := workflowapi.NewWorkflowRestClient(*config) + if err != nil { + return err + } + pos.workflowClient = client + return nil + }, + } + + command.AddCommand(newJobsCommand(&pos, preRunE)) + + return command +} diff --git a/integration_tests/features/support/env.rb b/integration_tests/features/support/env.rb index bc984054..cb264f7a 100644 --- a/integration_tests/features/support/env.rb +++ b/integration_tests/features/support/env.rb @@ -2,7 +2,9 @@ Before('@k9s') do token = ENV['K9S_CIRCLECI_CLI_TOKEN'] - ENV['CIRCLECI_CLI_TOKEN'] = token if token && !token.empty? + skip_this_scenario('K9S_CIRCLECI_CLI_TOKEN is not set') if token.nil? || token.empty? + + ENV['CIRCLECI_CLI_TOKEN'] = token end After('@k9s') do diff --git a/slug/project.go b/slug/project.go new file mode 100644 index 00000000..0c8b4df0 --- /dev/null +++ b/slug/project.go @@ -0,0 +1,45 @@ +package slug + +import ( + "fmt" + "strings" +) + +type Project struct { + VCS string + Org string + Repo string +} + +func ParseProject(projectSlug string) (Project, error) { + slug := strings.TrimSpace(projectSlug) + slug = strings.Trim(slug, "/") + + parts := strings.Split(slug, "/") + if len(parts) != 3 { + return Project{}, fmt.Errorf("invalid project slug %q (expected //)", projectSlug) + } + + for i := range parts { + if parts[i] == "" { + return Project{}, fmt.Errorf("invalid project slug %q (empty segment)", projectSlug) + } + } + + return Project{ + VCS: parts[0], + Org: parts[1], + Repo: parts[2], + }, nil +} + +func (p Project) V1VCS() (string, error) { + switch strings.ToLower(p.VCS) { + case "gh", "github": + return "github", nil + case "bb", "bitbucket": + return "bitbucket", nil + default: + return "", fmt.Errorf("unsupported vcs %q for v1.1 job logs", p.VCS) + } +} diff --git a/slug/project_test.go b/slug/project_test.go new file mode 100644 index 00000000..193076c9 --- /dev/null +++ b/slug/project_test.go @@ -0,0 +1,65 @@ +package slug + +import "testing" + +func TestParseProject(t *testing.T) { + t.Run("parses valid project slug", func(t *testing.T) { + p, err := ParseProject("gh/GetJobber/lakehouse-event-collector") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.VCS != "gh" || p.Org != "GetJobber" || p.Repo != "lakehouse-event-collector" { + t.Fatalf("unexpected project: %#v", p) + } + }) + + t.Run("trims whitespace and slashes", func(t *testing.T) { + p, err := ParseProject(" /gh/GetJobber/lakehouse-event-collector/ ") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if p.VCS != "gh" || p.Org != "GetJobber" || p.Repo != "lakehouse-event-collector" { + t.Fatalf("unexpected project: %#v", p) + } + }) + + t.Run("rejects invalid slug", func(t *testing.T) { + _, err := ParseProject("gh/GetJobber") + if err == nil { + t.Fatalf("expected error") + } + }) +} + +func TestProject_V1VCS(t *testing.T) { + tests := []struct { + name string + vcs string + want string + wantErr bool + }{ + {name: "gh maps to github", vcs: "gh", want: "github"}, + {name: "github stays github", vcs: "github", want: "github"}, + {name: "bb maps to bitbucket", vcs: "bb", want: "bitbucket"}, + {name: "bitbucket stays bitbucket", vcs: "bitbucket", want: "bitbucket"}, + {name: "unknown errors", vcs: "circleci", wantErr: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, err := Project{VCS: tc.vcs}.V1VCS() + if tc.wantErr { + if err == nil { + t.Fatalf("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tc.want { + t.Fatalf("expected %q, got %q", tc.want, got) + } + }) + } +}