Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions acceptance/task_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import (
"path/filepath"
"strings"
"testing"
"time"

"gotest.tools/v3/assert"

Expand Down Expand Up @@ -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)
}
11 changes: 11 additions & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <pipeline-id> # Show pipeline/workflow status
│ │ --json # Output as JSON
│ ├── watch <pipeline-id> # Poll until workflows complete
│ │ --interval <duration> # Polling interval (default: 5s)
│ │ --timeout <duration> # 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
Expand Down Expand Up @@ -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
Expand Down
36 changes: 36 additions & 0 deletions internal/circleci/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
96 changes: 96 additions & 0 deletions internal/circleci/pipeline_test.go
Original file line number Diff line number Diff line change
@@ -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")
})
}
29 changes: 29 additions & 0 deletions internal/circleci/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Loading