diff --git a/acceptance/task_test.go b/acceptance/task_test.go index f2ad65ce..1781a22e 100644 --- a/acceptance/task_test.go +++ b/acceptance/task_test.go @@ -7,6 +7,7 @@ import ( "path/filepath" "strings" "testing" + "time" "gotest.tools/v3/assert" @@ -775,3 +776,146 @@ func TestTaskRunCircleCIOrgType(t *testing.T) { assert.Equal(t, body["definition_id"], "e2016e4e-0172-47b3-a4ea-a3ee1a592dba") assert.Equal(t, body["checkout_branch"], "develop") } + +func TestTaskStatus(t *testing.T) { + cci := fakes.NewFakeCircleCI() + srv := httptest.NewServer(cci) + defer srv.Close() + + env := testenv.NewTestEnv(t) + env.CircleCIURL = srv.URL + + result := binary.RunCLI(t, []string{ + "task", "status", "pipeline-def-456", + }, env, t.TempDir()) + + assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr) + assert.Assert(t, strings.Contains(result.Stdout, "chunk-task"), + "expected workflow name in output, got: %s", result.Stdout) + assert.Assert(t, strings.Contains(result.Stdout, "running"), + "expected workflow status in output, got: %s", result.Stdout) + assert.Assert(t, strings.Contains(result.Stdout, "app.circleci.com"), + "expected web URL in output, got: %s", result.Stdout) +} + +func TestTaskWatchSuccess(t *testing.T) { + cci := fakes.NewFakeCircleCI() + cci.PipelineWorkflows = []fakes.Workflow{ + {ID: "workflow-abc-789", PipelineID: "pipeline-def-456", Name: "chunk-task", Status: "running"}, + } + srv := httptest.NewServer(cci) + defer srv.Close() + + go func() { + time.Sleep(100 * time.Millisecond) + cci.SetPipelineWorkflows([]fakes.Workflow{ + {ID: "workflow-abc-789", PipelineID: "pipeline-def-456", Name: "chunk-task", Status: "success"}, + }) + cci.SetWorkflowJobs("workflow-abc-789", []fakes.WorkflowJob{{Name: "run-agent", Status: "success"}}) + }() + + env := testenv.NewTestEnv(t) + env.CircleCIURL = srv.URL + + result := binary.RunCLI(t, []string{ + "task", "watch", "pipeline-def-456", + "--interval", "50ms", + "--timeout", "5s", + }, env, t.TempDir()) + + assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr) + assert.Assert(t, strings.Contains(result.Stdout, "success"), + "expected success status in output, got: %s", result.Stdout) +} + +func TestTaskStatusMissingToken(t *testing.T) { + env := testenv.NewTestEnv(t) + env.CircleToken = "" + + result := binary.RunCLI(t, []string{ + "task", "status", "pipeline-def-456", + }, env, t.TempDir()) + + assert.Assert(t, result.ExitCode != 0, "expected non-zero exit code") + combined := result.Stdout + result.Stderr + assert.Assert(t, strings.Contains(combined, "CIRCLE_TOKEN") || strings.Contains(combined, "token"), + "expected token error message, got: %s", combined) +} + +func TestTaskStatusJSON(t *testing.T) { + cci := fakes.NewFakeCircleCI() + srv := httptest.NewServer(cci) + defer srv.Close() + + env := testenv.NewTestEnv(t) + env.CircleCIURL = srv.URL + + result := binary.RunCLI(t, []string{ + "task", "status", "pipeline-def-456", "--json", + }, env, t.TempDir()) + + assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr) + + var body struct { + PipelineID string `json:"pipelineId"` + WebURL string `json:"webUrl"` + Workflows []struct { + Name string `json:"name"` + Status string `json:"status"` + } `json:"workflows"` + } + err := json.Unmarshal([]byte(result.Stdout), &body) + assert.NilError(t, err) + assert.Equal(t, body.PipelineID, "pipeline-def-456") + assert.Assert(t, len(body.Workflows) > 0) + assert.Equal(t, body.Workflows[0].Name, "chunk-task") + assert.Assert(t, strings.Contains(body.WebURL, "app.circleci.com")) +} + +func TestTaskWatchFailure(t *testing.T) { + cci := fakes.NewFakeCircleCI() + cci.PipelineWorkflows = []fakes.Workflow{ + {ID: "workflow-abc-789", PipelineID: "pipeline-def-456", Name: "chunk-task", Status: "failed"}, + } + cci.WorkflowJobs = map[string][]fakes.WorkflowJob{ + "workflow-abc-789": {{Name: "run-agent", Status: "failed"}}, + } + srv := httptest.NewServer(cci) + defer srv.Close() + + env := testenv.NewTestEnv(t) + env.CircleCIURL = srv.URL + + result := binary.RunCLI(t, []string{ + "task", "watch", "pipeline-def-456", + "--interval", "50ms", + "--timeout", "5s", + }, env, t.TempDir()) + + assert.Assert(t, result.ExitCode != 0, "expected non-zero exit code, stderr: %s", result.Stderr) + combined := result.Stdout + result.Stderr + assert.Assert(t, strings.Contains(combined, "failed") || strings.Contains(combined, "did not complete"), + "expected failure message, got: %s", combined) +} + +func TestTaskRunIncludesURL(t *testing.T) { + cci := fakes.NewFakeCircleCI() + srv := httptest.NewServer(cci) + defer srv.Close() + + workDir := gitrepo.SetupGitRepo(t, "test-org", "test-repo") + writeRunConfig(t, workDir) + + env := testenv.NewTestEnv(t) + env.CircleCIURL = srv.URL + + result := binary.RunCLI(t, []string{ + "task", "run", + "--definition", "dev", + "--prompt", "Fix the flaky test", + }, env, workDir) + + assert.Equal(t, result.ExitCode, 0, "stderr: %s", result.Stderr) + assert.Assert(t, strings.Contains(result.Stdout, "app.circleci.com"), + "expected web URL in output, got: %s", result.Stdout) +} diff --git a/docs/CLI.md b/docs/CLI.md index de24f629..a964344f 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -45,6 +45,12 @@ chunk │ │ --new-branch # Create a new branch │ │ --no-pipeline-as-tool # Disable pipeline-as-tool mode │ │ --json # Output as JSON +│ ├── status # Show pipeline/workflow status +│ │ --json # Output as JSON +│ ├── watch # Poll until workflows complete +│ │ --interval # Polling interval (default: 5s) +│ │ --timeout # Max wait time (default: 30m) +│ │ --json # Output final status as JSON │ └── config # Set up .chunk/run.json for this repository │ --force # Overwrite existing configuration without confirmation │ @@ -146,6 +152,11 @@ chunk - `build-prompt` does not write intermediate files by default. Pass `--debug` to write the raw details JSON, analysis markdown, and PR rankings CSV alongside the prompt — useful when diagnosing unexpected prompt output. - `task run` defaults to pipeline-as-tool mode; use `--no-pipeline-as-tool` to disable. +- `task status` and `task watch` take a CircleCI **pipeline ID** (printed by + `task run` as `Pipeline:`). They do not require a git repository or + `.chunk/run.json`. +- `task watch` exits non-zero when a workflow fails, is canceled, or the + `--timeout` elapses. - `config set` user keys: `model`. Project keys (`.chunk/config.json`): `orgID`, `validation.sidecarImage`. Credentials use `chunk auth set`, not `config set`. - **Org ID resolution** for `sidecar create`, `sidecar list`, and other sidecar diff --git a/internal/circleci/client.go b/internal/circleci/client.go index 15a20206..c0ab9caa 100644 --- a/internal/circleci/client.go +++ b/internal/circleci/client.go @@ -328,6 +328,42 @@ func (c *Client) TriggerRun(ctx context.Context, orgID, projectID string, body T return &resp, nil } +func (c *Client) GetPipeline(ctx context.Context, pipelineID string) (*Pipeline, error) { + var resp Pipeline + _, err := c.cl.Call(ctx, hc.NewRequest(http.MethodGet, "/api/v2/pipeline/%s", + hc.RouteParams(pipelineID), + hc.JSONDecoder(&resp), + )) + if err != nil { + return nil, mapErr("get pipeline", err) + } + return &resp, nil +} + +func (c *Client) ListPipelineWorkflows(ctx context.Context, pipelineID string) ([]Workflow, error) { + var resp workflowList + _, err := c.cl.Call(ctx, hc.NewRequest(http.MethodGet, "/api/v2/pipeline/%s/workflow", + hc.RouteParams(pipelineID), + hc.JSONDecoder(&resp), + )) + if err != nil { + return nil, mapErr("list pipeline workflows", err) + } + return resp.Items, nil +} + +func (c *Client) ListWorkflowJobs(ctx context.Context, workflowID string) ([]WorkflowJob, error) { + var resp workflowJobList + _, err := c.cl.Call(ctx, hc.NewRequest(http.MethodGet, "/api/v2/workflow/%s/job", + hc.RouteParams(workflowID), + hc.JSONDecoder(&resp), + )) + if err != nil { + return nil, mapErr("list workflow jobs", err) + } + return resp.Items, nil +} + func mapErr(op string, err error) error { var he *hc.HTTPError if !errors.As(err, &he) { diff --git a/internal/circleci/pipeline_test.go b/internal/circleci/pipeline_test.go new file mode 100644 index 00000000..af2f98c7 --- /dev/null +++ b/internal/circleci/pipeline_test.go @@ -0,0 +1,96 @@ +package circleci + +import ( + "context" + "net/http/httptest" + "testing" + + "gotest.tools/v3/assert" + + "github.com/CircleCI-Public/chunk-cli/internal/testing/fakes" +) + +func TestGetPipeline(t *testing.T) { + t.Run("success", func(t *testing.T) { + fake := fakes.NewFakeCircleCI() + fake.Pipeline = &fakes.Pipeline{ + ID: "pipe-1", + ProjectSlug: "gh/org/repo", + Number: 42, + State: "created", + } + srv := httptest.NewServer(fake) + defer srv.Close() + + client := newTestClient(t, srv.URL) + pipe, err := client.GetPipeline(context.Background(), "pipe-1") + assert.NilError(t, err) + assert.Equal(t, pipe.ID, "pipe-1") + assert.Equal(t, pipe.ProjectSlug, "gh/org/repo") + assert.Equal(t, pipe.Number, 42) + assert.Equal(t, pipe.State, "created") + + reqs := fake.Recorder.AllRequests() + last := reqs[len(reqs)-1] + assert.Equal(t, last.URL.Path, "/api/v2/pipeline/pipe-1") + }) + + t.Run("not found", func(t *testing.T) { + fake := fakes.NewFakeCircleCI() + fake.PipelineStatusCode = 404 + srv := httptest.NewServer(fake) + defer srv.Close() + + client := newTestClient(t, srv.URL) + _, err := client.GetPipeline(context.Background(), "missing") + assert.Assert(t, err != nil) + }) +} + +func TestListPipelineWorkflows(t *testing.T) { + t.Run("success", func(t *testing.T) { + fake := fakes.NewFakeCircleCI() + fake.PipelineWorkflows = []fakes.Workflow{ + {ID: "wf-1", PipelineID: "pipe-1", Name: "chunk-task", Status: "running"}, + {ID: "wf-2", PipelineID: "pipe-1", Name: "other", Status: "success"}, + } + srv := httptest.NewServer(fake) + defer srv.Close() + + client := newTestClient(t, srv.URL) + wfs, err := client.ListPipelineWorkflows(context.Background(), "pipe-1") + assert.NilError(t, err) + assert.Equal(t, len(wfs), 2) + assert.Equal(t, wfs[0].Name, "chunk-task") + assert.Equal(t, wfs[0].Status, "running") + + reqs := fake.Recorder.AllRequests() + last := reqs[len(reqs)-1] + assert.Equal(t, last.URL.Path, "/api/v2/pipeline/pipe-1/workflow") + }) +} + +func TestListWorkflowJobs(t *testing.T) { + t.Run("success", func(t *testing.T) { + fake := fakes.NewFakeCircleCI() + fake.WorkflowJobs = map[string][]fakes.WorkflowJob{ + "wf-1": { + {Name: "build", Status: "success"}, + {Name: "test", Status: "failed"}, + }, + } + srv := httptest.NewServer(fake) + defer srv.Close() + + client := newTestClient(t, srv.URL) + jobs, err := client.ListWorkflowJobs(context.Background(), "wf-1") + assert.NilError(t, err) + assert.Equal(t, len(jobs), 2) + assert.Equal(t, jobs[1].Name, "test") + assert.Equal(t, jobs[1].Status, "failed") + + reqs := fake.Recorder.AllRequests() + last := reqs[len(reqs)-1] + assert.Equal(t, last.URL.Path, "/api/v2/workflow/wf-1/job") + }) +} diff --git a/internal/circleci/types.go b/internal/circleci/types.go index a38ba3cb..c68d8c29 100644 --- a/internal/circleci/types.go +++ b/internal/circleci/types.go @@ -49,6 +49,35 @@ type RunResponse struct { PipelineID string `json:"pipelineId,omitempty"` } +type Pipeline struct { + ID string `json:"id"` + ProjectSlug string `json:"project_slug"` + Number int `json:"number"` + State string `json:"state"` +} + +type Workflow struct { + ID string `json:"id"` + PipelineID string `json:"pipeline_id"` + Name string `json:"name"` + ProjectSlug string `json:"project_slug,omitempty"` + Status string `json:"status"` +} + +type WorkflowJob struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Status string `json:"status"` +} + +type workflowList struct { + Items []Workflow `json:"items"` +} + +type workflowJobList struct { + Items []WorkflowJob `json:"items"` +} + type Snapshot struct { ID string `json:"id"` OrgID string `json:"org_id"` diff --git a/internal/cmd/task.go b/internal/cmd/task.go index 08cd51f2..c9dc9219 100644 --- a/internal/cmd/task.go +++ b/internal/cmd/task.go @@ -5,7 +5,9 @@ import ( "errors" "fmt" "os" + "strings" "sync" + "time" "github.com/spf13/cobra" @@ -28,6 +30,8 @@ func newTaskCmd() *cobra.Command { cmd.AddCommand(newTaskRunCmd()) cmd.AddCommand(newTaskConfigCmd()) + cmd.AddCommand(newTaskStatusCmd()) + cmd.AddCommand(newTaskWatchCmd()) return cmd } @@ -85,6 +89,9 @@ func newTaskRunCmd() *cobra.Command { w := 12 io.Printf("%s %s\n", ui.Label("Run triggered:", w), ui.Green(resp.RunID)) io.Printf("%s %s\n", ui.Label("Pipeline:", w), resp.PipelineID) + if webURL, err := task.RunWebURL(cmd.Context(), client, resp.PipelineID); err == nil { + io.Printf("%s %s\n", ui.Label("URL:", w), webURL) + } return nil }, } @@ -194,6 +201,135 @@ func newTaskConfigCmd() *cobra.Command { return cmd } +func newTaskStatusCmd() *cobra.Command { + var jsonOut bool + cmd := &cobra.Command{ + Use: "status ", + Short: "Show the status of a task run pipeline", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + io := iostream.FromCmd(cmd) + insecureStorage := insecureStorageFlag(cmd) + rc, _ := config.Resolve("", "", insecureStorage) + client, err := ensureCircleCIClient(cmd.Context(), cmd, rc, io, tui.PromptHidden) + if err != nil { + return err + } + + status, err := task.FetchStatus(cmd.Context(), client, args[0]) + if err != nil { + return err + } + if jsonOut { + return iostream.PrintJSON(io.Out, status) + } + printRunStatus(io, status) + return nil + }, + } + cmd.Flags().BoolVar(&jsonOut, "json", false, "Output as JSON") + return cmd +} + +func newTaskWatchCmd() *cobra.Command { + var ( + jsonOut bool + interval time.Duration + timeout time.Duration + ) + cmd := &cobra.Command{ + Use: "watch ", + Short: "Watch a task run pipeline until completion", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + io := iostream.FromCmd(cmd) + insecureStorage := insecureStorageFlag(cmd) + rc, _ := config.Resolve("", "", insecureStorage) + client, err := ensureCircleCIClient(cmd.Context(), cmd, rc, io, tui.PromptHidden) + if err != nil { + return err + } + + var lastKey string + status, err := task.WatchStatus(cmd.Context(), client, args[0], task.WatchOptions{ + Interval: interval, + Timeout: timeout, + OnUpdate: func(s task.RunStatus) { + key := statusKey(s) + if key == lastKey { + return + } + lastKey = key + printRunStatus(io, s) + }, + }) + if err != nil { + return &userError{ + msg: "Task run did not complete successfully.", + suggestion: "Check the workflow in the CircleCI web UI or run `chunk task status`.", + err: err, + } + } + if jsonOut { + return iostream.PrintJSON(io.Out, status) + } + return nil + }, + } + cmd.Flags().BoolVar(&jsonOut, "json", false, "Output final status as JSON") + cmd.Flags().DurationVar(&interval, "interval", 5*time.Second, "Polling interval") + cmd.Flags().DurationVar(&timeout, "timeout", 30*time.Minute, "Maximum time to wait") + return cmd +} + +func printRunStatus(io iostream.Streams, status task.RunStatus) { + w := 12 + io.Printf("%s %s\n", ui.Label("Pipeline:", w), status.PipelineID) + io.Printf("%s %d\n", ui.Label("Number:", w), status.PipelineNumber) + io.Printf("%s %s\n", ui.Label("State:", w), status.PipelineState) + io.Printf("%s %s\n", ui.Label("URL:", w), status.WebURL) + for _, wf := range status.Workflows { + line := fmt.Sprintf("%s (%s)", wf.Name, formatWorkflowStatus(wf.Status)) + io.Printf("%s %s\n", ui.Label("Workflow:", w), line) + for _, job := range wf.Jobs { + if job.Status == "failed" || job.Status == "error" { + io.Printf("%s %s (%s)\n", ui.Label("Failed job:", w), job.Name, job.Status) + } + } + } +} + +func formatWorkflowStatus(status string) string { + switch status { + case "success": + return ui.Green(status) + case "failed", "error", "canceled", "unauthorized": + return ui.Red(status) + case "running": + return ui.Yellow(status) + default: + return status + } +} + +func statusKey(s task.RunStatus) string { + var b strings.Builder + b.WriteString(s.PipelineState) + for _, wf := range s.Workflows { + b.WriteByte('|') + b.WriteString(wf.ID) + b.WriteByte(':') + b.WriteString(wf.Status) + for _, job := range wf.Jobs { + b.WriteByte(':') + b.WriteString(job.Name) + b.WriteByte('=') + b.WriteString(job.Status) + } + } + return b.String() +} + func fetchProjectsAndCollabs(ctx context.Context, client *circleci.Client) ([]circleci.FollowedProject, []circleci.Collaboration, error) { var projects []circleci.FollowedProject var collabs []circleci.Collaboration diff --git a/internal/task/status.go b/internal/task/status.go new file mode 100644 index 00000000..f318cd49 --- /dev/null +++ b/internal/task/status.go @@ -0,0 +1,190 @@ +package task + +import ( + "context" + "fmt" + "sort" + "strings" + "time" + + "github.com/CircleCI-Public/chunk-cli/internal/circleci" +) + +const taskWorkflowName = "chunk-task" + +type JobStatus struct { + Name string `json:"name"` + Status string `json:"status"` +} + +type WorkflowStatus struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + Jobs []JobStatus `json:"jobs,omitempty"` +} + +type RunStatus struct { + PipelineID string `json:"pipelineId"` + PipelineNumber int `json:"pipelineNumber"` + PipelineState string `json:"pipelineState"` + ProjectSlug string `json:"projectSlug"` + WebURL string `json:"webUrl"` + Workflows []WorkflowStatus `json:"workflows"` +} + +type WatchOptions struct { + Interval time.Duration + Timeout time.Duration + OnUpdate func(RunStatus) +} + +func PipelineWebURL(projectSlug string, pipelineNumber int, workflowID string) string { + base := fmt.Sprintf("https://app.circleci.com/pipelines/%s/%d", projectSlug, pipelineNumber) + if workflowID == "" { + return base + } + return base + "/workflows/" + workflowID +} + +func WorkflowTerminal(status string) bool { + switch status { + case "success", "failed", "error", "canceled", "unauthorized": + return true + default: + return false + } +} + +func (s RunStatus) AllTerminal() bool { + if len(s.Workflows) == 0 { + return false + } + for _, wf := range s.Workflows { + if !WorkflowTerminal(wf.Status) { + return false + } + } + return true +} + +func (s RunStatus) AnyFailed() bool { + for _, wf := range s.Workflows { + switch wf.Status { + case "failed", "error", "canceled", "unauthorized": + return true + } + } + return false +} + +func FetchStatus(ctx context.Context, client *circleci.Client, pipelineID string) (RunStatus, error) { + pipe, err := client.GetPipeline(ctx, pipelineID) + if err != nil { + return RunStatus{}, fmt.Errorf("fetch pipeline: %w", err) + } + + workflows, err := client.ListPipelineWorkflows(ctx, pipelineID) + if err != nil { + return RunStatus{}, fmt.Errorf("fetch workflows: %w", err) + } + sortWorkflows(workflows) + + status := RunStatus{ + PipelineID: pipe.ID, + PipelineNumber: pipe.Number, + PipelineState: pipe.State, + ProjectSlug: pipe.ProjectSlug, + Workflows: make([]WorkflowStatus, 0, len(workflows)), + } + + primaryWorkflowID := "" + for _, wf := range workflows { + jobs, err := client.ListWorkflowJobs(ctx, wf.ID) + if err != nil { + return RunStatus{}, fmt.Errorf("fetch jobs for workflow %s: %w", wf.ID, err) + } + wfStatus := WorkflowStatus{ + ID: wf.ID, + Name: wf.Name, + Status: wf.Status, + } + for _, job := range jobs { + wfStatus.Jobs = append(wfStatus.Jobs, JobStatus{ + Name: job.Name, + Status: job.Status, + }) + } + status.Workflows = append(status.Workflows, wfStatus) + if primaryWorkflowID == "" { + primaryWorkflowID = wf.ID + } + } + + status.WebURL = PipelineWebURL(pipe.ProjectSlug, pipe.Number, primaryWorkflowID) + return status, nil +} + +func RunWebURL(ctx context.Context, client *circleci.Client, pipelineID string) (string, error) { + pipe, err := client.GetPipeline(ctx, pipelineID) + if err != nil { + return "", fmt.Errorf("fetch pipeline: %w", err) + } + workflows, err := client.ListPipelineWorkflows(ctx, pipelineID) + if err != nil { + return "", fmt.Errorf("fetch workflows: %w", err) + } + sortWorkflows(workflows) + workflowID := "" + if len(workflows) > 0 { + workflowID = workflows[0].ID + } + return PipelineWebURL(pipe.ProjectSlug, pipe.Number, workflowID), nil +} + +func WatchStatus(ctx context.Context, client *circleci.Client, pipelineID string, opts WatchOptions) (RunStatus, error) { + if opts.Interval <= 0 { + opts.Interval = 5 * time.Second + } + if opts.Timeout <= 0 { + opts.Timeout = 30 * time.Minute + } + + deadline := time.Now().Add(opts.Timeout) + + for { + status, err := FetchStatus(ctx, client, pipelineID) + if err != nil { + return RunStatus{}, err + } + if opts.OnUpdate != nil { + opts.OnUpdate(status) + } + if status.AllTerminal() { + if status.AnyFailed() { + return status, fmt.Errorf("workflow failed") + } + return status, nil + } + if time.Now().After(deadline) { + return status, fmt.Errorf("timed out waiting for workflow completion") + } + + select { + case <-ctx.Done(): + return status, ctx.Err() + case <-time.After(opts.Interval): + } + } +} + +func sortWorkflows(workflows []circleci.Workflow) { + sort.SliceStable(workflows, func(i, j int) bool { + iTask := strings.EqualFold(workflows[i].Name, taskWorkflowName) + jTask := strings.EqualFold(workflows[j].Name, taskWorkflowName) + if iTask != jTask { + return iTask + } + return workflows[i].Name < workflows[j].Name + }) +} diff --git a/internal/task/status_test.go b/internal/task/status_test.go new file mode 100644 index 00000000..739ed2eb --- /dev/null +++ b/internal/task/status_test.go @@ -0,0 +1,172 @@ +package task + +import ( + "context" + "net/http/httptest" + "testing" + "time" + + "gotest.tools/v3/assert" + + "github.com/CircleCI-Public/chunk-cli/internal/circleci" + "github.com/CircleCI-Public/chunk-cli/internal/testing/fakes" +) + +func newStatusTestClient(t *testing.T, fake *fakes.FakeCircleCI) *circleci.Client { + t.Helper() + srv := httptest.NewServer(fake) + t.Cleanup(srv.Close) + cl, err := circleci.NewClient(circleci.Config{Token: "test-token", BaseURL: srv.URL}) + assert.NilError(t, err) + return cl +} + +func TestPipelineWebURL(t *testing.T) { + t.Run("with workflow", func(t *testing.T) { + url := PipelineWebURL("gh/org/repo", 42, "wf-abc") + assert.Equal(t, url, "https://app.circleci.com/pipelines/gh/org/repo/42/workflows/wf-abc") + }) + + t.Run("without workflow", func(t *testing.T) { + url := PipelineWebURL("gh/org/repo", 42, "") + assert.Equal(t, url, "https://app.circleci.com/pipelines/gh/org/repo/42") + }) +} + +func TestWorkflowTerminal(t *testing.T) { + assert.Assert(t, WorkflowTerminal("success")) + assert.Assert(t, WorkflowTerminal("failed")) + assert.Assert(t, WorkflowTerminal("canceled")) + assert.Assert(t, !WorkflowTerminal("running")) + assert.Assert(t, !WorkflowTerminal("on_hold")) +} + +func TestFetchStatus(t *testing.T) { + fake := fakes.NewFakeCircleCI() + fake.Pipeline = &fakes.Pipeline{ + ID: "pipe-1", + ProjectSlug: "gh/org/repo", + Number: 7, + State: "created", + } + fake.PipelineWorkflows = []fakes.Workflow{ + {ID: "wf-other", PipelineID: "pipe-1", Name: "other", Status: "success"}, + {ID: "wf-task", PipelineID: "pipe-1", Name: "chunk-task", Status: "running"}, + } + fake.WorkflowJobs = map[string][]fakes.WorkflowJob{ + "wf-task": {{Name: "run-agent", Status: "running"}}, + } + + client := newStatusTestClient(t, fake) + status, err := FetchStatus(context.Background(), client, "pipe-1") + assert.NilError(t, err) + assert.Equal(t, status.PipelineID, "pipe-1") + assert.Equal(t, status.PipelineNumber, 7) + assert.Equal(t, len(status.Workflows), 2) + assert.Equal(t, status.Workflows[0].Name, "chunk-task") + assert.Equal(t, status.Workflows[0].Jobs[0].Name, "run-agent") + assert.Equal(t, status.WebURL, "https://app.circleci.com/pipelines/gh/org/repo/7/workflows/wf-task") + assert.Assert(t, !status.AllTerminal()) +} + +func TestWatchStatusSuccess(t *testing.T) { + fake := fakes.NewFakeCircleCI() + fake.Pipeline = &fakes.Pipeline{ + ID: "pipe-1", + ProjectSlug: "gh/org/repo", + Number: 1, + State: "created", + } + fake.PipelineWorkflows = []fakes.Workflow{ + {ID: "wf-1", PipelineID: "pipe-1", Name: "chunk-task", Status: "running"}, + } + fake.WorkflowJobs = map[string][]fakes.WorkflowJob{ + "wf-1": {{Name: "run-agent", Status: "running"}}, + } + + client := newStatusTestClient(t, fake) + + done := make(chan struct{}) + go func() { + time.Sleep(50 * time.Millisecond) + fake.SetPipelineWorkflows([]fakes.Workflow{ + {ID: "wf-1", PipelineID: "pipe-1", Name: "chunk-task", Status: "success"}, + }) + fake.SetWorkflowJobs("wf-1", []fakes.WorkflowJob{{Name: "run-agent", Status: "success"}}) + close(done) + }() + + status, err := WatchStatus(context.Background(), client, "pipe-1", WatchOptions{ + Interval: 20 * time.Millisecond, + Timeout: 2 * time.Second, + }) + assert.NilError(t, err) + assert.Equal(t, status.Workflows[0].Status, "success") + <-done +} + +func TestFetchStatusNoWorkflows(t *testing.T) { + fake := fakes.NewFakeCircleCI() + fake.Pipeline = &fakes.Pipeline{ + ID: "pipe-1", + ProjectSlug: "gh/org/repo", + Number: 3, + State: "created", + } + fake.PipelineWorkflows = []fakes.Workflow{} + + client := newStatusTestClient(t, fake) + status, err := FetchStatus(context.Background(), client, "pipe-1") + assert.NilError(t, err) + assert.Equal(t, status.PipelineID, "pipe-1") + assert.Equal(t, len(status.Workflows), 0) + assert.Equal(t, status.WebURL, "https://app.circleci.com/pipelines/gh/org/repo/3") + assert.Assert(t, !status.AllTerminal()) +} + +func TestWatchStatusTimeout(t *testing.T) { + fake := fakes.NewFakeCircleCI() + fake.Pipeline = &fakes.Pipeline{ + ID: "pipe-1", + ProjectSlug: "gh/org/repo", + Number: 1, + State: "created", + } + fake.PipelineWorkflows = []fakes.Workflow{ + {ID: "wf-1", PipelineID: "pipe-1", Name: "chunk-task", Status: "running"}, + } + fake.WorkflowJobs = map[string][]fakes.WorkflowJob{ + "wf-1": {{Name: "run-agent", Status: "running"}}, + } + + client := newStatusTestClient(t, fake) + _, err := WatchStatus(context.Background(), client, "pipe-1", WatchOptions{ + Interval: 20 * time.Millisecond, + Timeout: 50 * time.Millisecond, + }) + assert.Assert(t, err != nil) + assert.ErrorContains(t, err, "timed out waiting for workflow completion") +} + +func TestWatchStatusFailed(t *testing.T) { + fake := fakes.NewFakeCircleCI() + fake.Pipeline = &fakes.Pipeline{ + ID: "pipe-1", + ProjectSlug: "gh/org/repo", + Number: 1, + State: "created", + } + fake.PipelineWorkflows = []fakes.Workflow{ + {ID: "wf-1", PipelineID: "pipe-1", Name: "chunk-task", Status: "failed"}, + } + fake.WorkflowJobs = map[string][]fakes.WorkflowJob{ + "wf-1": {{Name: "run-agent", Status: "failed"}}, + } + + client := newStatusTestClient(t, fake) + _, err := WatchStatus(context.Background(), client, "pipe-1", WatchOptions{ + Interval: 20 * time.Millisecond, + Timeout: 1 * time.Second, + }) + assert.Assert(t, err != nil) +} diff --git a/internal/testing/fakes/circleci.go b/internal/testing/fakes/circleci.go index 4e2ac60e..d770b470 100644 --- a/internal/testing/fakes/circleci.go +++ b/internal/testing/fakes/circleci.go @@ -44,6 +44,27 @@ type RunResponse struct { PipelineID string `json:"pipelineId,omitempty"` } +type Pipeline struct { + ID string `json:"id"` + ProjectSlug string `json:"project_slug"` + Number int `json:"number"` + State string `json:"state"` +} + +type Workflow struct { + ID string `json:"id"` + PipelineID string `json:"pipeline_id"` + Name string `json:"name"` + ProjectSlug string `json:"project_slug,omitempty"` + Status string `json:"status"` +} + +type WorkflowJob struct { + ID string `json:"id,omitempty"` + Name string `json:"name"` + Status string `json:"status"` +} + type ExecResponse struct { CommandID string `json:"command_id"` PID int `json:"pid"` @@ -67,17 +88,21 @@ type FakeCircleCI struct { http.Handler Recorder *recorder.RequestRecorder - mu sync.RWMutex - snapshotCounter int - Collaborations []Collaboration - Projects []Project - Sidecars []Sidecar - Snapshots []Snapshot - RunResponse *RunResponse - AddKeyURL string - ExecResponse *ExecResponse - CommandResponse *CommandResponse - RunStatusCode int // override status code for trigger run endpoint + mu sync.RWMutex + snapshotCounter int + Collaborations []Collaboration + Projects []Project + Sidecars []Sidecar + Snapshots []Snapshot + RunResponse *RunResponse + AddKeyURL string + ExecResponse *ExecResponse + CommandResponse *CommandResponse + RunStatusCode int // override status code for trigger run endpoint + Pipeline *Pipeline + PipelineWorkflows []Workflow + WorkflowJobs map[string][]WorkflowJob + PipelineStatusCode int // Per-endpoint status code overrides for testing error responses. CollaborationsStatusCode int // override for GET /me/collaborations @@ -123,6 +148,11 @@ func NewFakeCircleCI() *FakeCircleCI { // Task run endpoint r.POST("/api/v2/agents/org/:org_id/project/:project_id/runs", f.handleTriggerRun) + // Pipeline / workflow endpoints + r.GET("/api/v2/pipeline/:pipeline_id", f.handleGetPipeline) + r.GET("/api/v2/pipeline/:pipeline_id/workflow", f.handleListPipelineWorkflows) + r.GET("/api/v2/workflow/:workflow_id/job", f.handleListWorkflowJobs) + return f } @@ -522,3 +552,79 @@ func (f *FakeCircleCI) handleTriggerRun(c *gin.Context) { PipelineID: "pipeline-def-456", }) } + +func (f *FakeCircleCI) handleGetPipeline(c *gin.Context) { + if !f.requireToken(c) { + return + } + f.mu.RLock() + pipe := f.Pipeline + statusCode := f.PipelineStatusCode + f.mu.RUnlock() + + if statusCode != 0 { + c.JSON(statusCode, gin.H{"message": "not found"}) + return + } + if pipe == nil { + pipe = &Pipeline{ + ID: c.Param("pipeline_id"), + ProjectSlug: "gh/test-org/test-repo", + Number: 99, + State: "created", + } + } + c.JSON(http.StatusOK, pipe) +} + +func (f *FakeCircleCI) handleListPipelineWorkflows(c *gin.Context) { + if !f.requireToken(c) { + return + } + f.mu.RLock() + wfs := f.PipelineWorkflows + f.mu.RUnlock() + + if wfs == nil { + pipelineID := c.Param("pipeline_id") + wfs = []Workflow{ + { + ID: "workflow-abc-789", + PipelineID: pipelineID, + Name: "chunk-task", + Status: "running", + }, + } + } + c.JSON(http.StatusOK, gin.H{"items": wfs}) +} + +func (f *FakeCircleCI) handleListWorkflowJobs(c *gin.Context) { + if !f.requireToken(c) { + return + } + workflowID := c.Param("workflow_id") + f.mu.RLock() + jobs := f.WorkflowJobs[workflowID] + f.mu.RUnlock() + + if jobs == nil { + jobs = []WorkflowJob{{Name: "run-agent", Status: "running"}} + } + c.JSON(http.StatusOK, gin.H{"items": jobs}) +} + +func (f *FakeCircleCI) SetPipelineWorkflows(wfs []Workflow) { + f.mu.Lock() + defer f.mu.Unlock() + f.PipelineWorkflows = wfs +} + +func (f *FakeCircleCI) SetWorkflowJobs(workflowID string, jobs []WorkflowJob) { + f.mu.Lock() + defer f.mu.Unlock() + if f.WorkflowJobs == nil { + f.WorkflowJobs = make(map[string][]WorkflowJob) + } + f.WorkflowJobs[workflowID] = jobs +}