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
1 change: 1 addition & 0 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ chunk
│ │ --json # Output as JSON
│ └── config # Set up .chunk/run.json for this repository
│ --force # Overwrite existing configuration without confirmation
│ --project <slug> # Skip project picker (e.g. gh/org/repo)
├── skill
│ ├── install # Install all skills
Expand Down
24 changes: 24 additions & 0 deletions internal/circleci/circleci_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -361,6 +361,30 @@ func TestTriggerRun(t *testing.T) {
})
}

func TestGetProjectBySlug(t *testing.T) {
fake := fakes.NewFakeCircleCI()
fake.ProjectDetails = map[string]fakes.ProjectDetail{
"gh/acme/api": {ID: "proj-1", Slug: "gh/acme/api", Name: "api", OrgID: "org-1"},
}
srv := httptest.NewServer(fake)
defer srv.Close()

client := newTestClient(t, srv.URL)
ctx := context.Background()

t.Run("success", func(t *testing.T) {
detail, err := client.GetProjectBySlug(ctx, "gh/acme/api")
assert.NilError(t, err)
assert.Equal(t, detail.ID, "proj-1")
assert.Equal(t, detail.OrgID, "org-1")
})

t.Run("not found", func(t *testing.T) {
_, err := client.GetProjectBySlug(ctx, "gh/missing/repo")
assert.Assert(t, err != nil)
})
}

func TestAuthRequired(t *testing.T) {
// Verify that the fake returns 401 when no Circle-Token header is present.
fake := fakes.NewFakeCircleCI()
Expand Down
2 changes: 1 addition & 1 deletion internal/circleci/projects.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ type ProjectDetail struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
OrgID string `json:"org_id"`
OrgID string `json:"organization_id"`
}

// ListFollowedProjects returns projects the user follows.
Expand Down
23 changes: 15 additions & 8 deletions internal/cmd/task.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,7 @@ func newTaskRunCmd() *cobra.Command {

func newTaskConfigCmd() *cobra.Command {
var force bool
var projectSlug string
cmd := &cobra.Command{
Use: "config",
Short: "Set up .chunk/run.json for this repository",
Expand Down Expand Up @@ -152,13 +153,6 @@ func newTaskConfigCmd() *cobra.Command {
return err
}

io.ErrPrintln(ui.Dim("Fetching your CircleCI projects..."))

projects, collabs, err := fetchProjectsAndCollabs(ctx, client)
if err != nil {
return err
}

prompts := task.Prompts{
Confirm: tui.Confirm,
SelectFrom: tui.SelectFromList,
Expand All @@ -171,7 +165,19 @@ func newTaskConfigCmd() *cobra.Command {
return client.GetProjectBySlug(ctx, slug)
}

runCfg, err := task.CollectRunConfig(ctx, prompts, projects, collabs, fetchDetail, os.Getenv(config.EnvCircleCIOrgID))
opts := task.CollectOptions{ProjectSlug: projectSlug}

var projects []circleci.FollowedProject
var collabs []circleci.Collaboration
if projectSlug == "" {
io.ErrPrintln(ui.Dim("Fetching your CircleCI projects..."))
projects, collabs, err = fetchProjectsAndCollabs(ctx, client)
if err != nil {
return err
}
}

runCfg, err := task.CollectRunConfig(ctx, prompts, projects, collabs, fetchDetail, os.Getenv(config.EnvCircleCIOrgID), opts)
if errors.Is(err, tui.ErrCancelled) {
return nil
}
Expand All @@ -191,6 +197,7 @@ func newTaskConfigCmd() *cobra.Command {
},
}
cmd.Flags().BoolVarP(&force, "force", "f", false, "Overwrite existing configuration without confirmation")
cmd.Flags().StringVar(&projectSlug, "project", "", "Project slug to skip interactive selection (e.g. gh/org/repo)")
return cmd
}

Expand Down
32 changes: 31 additions & 1 deletion internal/task/collect.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ type Prompts struct {
// ProjectDetailFunc fetches project detail by slug (e.g. "gh/org/repo").
type ProjectDetailFunc func(ctx context.Context, slug string) (*circleci.ProjectDetail, error)

// CollectOptions configures CollectRunConfig.
type CollectOptions struct {
// ProjectSlug, when set (e.g. "gh/org/repo"), skips interactive project selection.
ProjectSlug string
}

// CollectRunConfig drives the interactive form to build a RunConfig.
// It takes already-fetched projects and collaborations plus injected UI
// and data-fetch dependencies so the logic is testable without a TTY.
Expand All @@ -31,8 +37,9 @@ func CollectRunConfig(
collabs []circleci.Collaboration,
fetchDetail ProjectDetailFunc,
envOrgID string,
opts CollectOptions,
) (*RunConfig, error) {
orgID, projectID, orgType, err := collectProject(ctx, prompts, projects, collabs, fetchDetail)
orgID, projectID, orgType, err := collectProject(ctx, prompts, projects, collabs, fetchDetail, opts.ProjectSlug)
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -63,7 +70,12 @@ func collectProject(
projects []circleci.FollowedProject,
collabs []circleci.Collaboration,
fetchDetail ProjectDetailFunc,
projectSlug string,
) (orgID, projectID, orgType string, err error) {
if projectSlug != "" {
return resolveProjectFromSlug(ctx, projectSlug, fetchDetail)
}

// Sort projects alphabetically
sort.Slice(projects, func(i, j int) bool {
a := fmt.Sprintf("%s/%s", projects[i].Username, projects[i].Reponame)
Expand All @@ -89,6 +101,24 @@ func collectProject(
return collectManualProject(prompts, collabs)
}

func resolveProjectFromSlug(
ctx context.Context,
slug string,
fetchDetail ProjectDetailFunc,
) (orgID, projectID, orgType string, err error) {
slug = strings.TrimSpace(slug)
parts := strings.Split(slug, "/")
if len(parts) != 3 || parts[0] == "" || parts[1] == "" || parts[2] == "" {
return "", "", "", fmt.Errorf("invalid project slug %q: expected format <vcs>/<org>/<repo> (e.g. gh/circleci/chunk-cli)", slug)
}

detail, err := fetchDetail(ctx, slug)
if err != nil {
return "", "", "", fmt.Errorf("fetch project details for %s: %w", slug, err)
}
return detail.OrgID, detail.ID, MapVcsTypeToOrgType(parts[0]), nil
}

func resolveProjectFromList(
ctx context.Context,
p circleci.FollowedProject,
Expand Down
63 changes: 62 additions & 1 deletion internal/task/collect_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ func TestCollectRunConfig_SelectFromList(t *testing.T) {
nil,
fakeFetchDetail("org-1", "proj-1"),
"",
CollectOptions{},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
Expand Down Expand Up @@ -148,6 +149,7 @@ func TestCollectRunConfig_ManualEntry(t *testing.T) {
collabs,
nil, // fetchDetail not needed for manual
"",
CollectOptions{},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
Expand Down Expand Up @@ -208,6 +210,7 @@ func TestCollectRunConfig_MultipleDefinitions(t *testing.T) {
nil,
fakeFetchDetail("org-1", "proj-1"),
"",
CollectOptions{},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
Expand Down Expand Up @@ -250,6 +253,7 @@ func TestCollectRunConfig_InvalidUUIDRetries(t *testing.T) {
nil,
fakeFetchDetail("org-1", "proj-1"),
"",
CollectOptions{},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
Expand Down Expand Up @@ -323,6 +327,7 @@ func TestCollectRunConfig_RequiredDefIDRetries(t *testing.T) {
nil,
fakeFetchDetail("org-1", "proj-1"),
"",
CollectOptions{},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
Expand Down Expand Up @@ -356,6 +361,7 @@ func TestCollectRunConfig_NoCollabsError(t *testing.T) {
nil, // no collabs
nil,
"",
CollectOptions{},
)
if err == nil {
t.Fatal("expected error for no organizations")
Expand Down Expand Up @@ -385,6 +391,7 @@ func TestCollectRunConfig_OrgMismatchWarning(t *testing.T) {
nil,
fakeFetchDetail("org-1", "proj-1"),
"different-org",
CollectOptions{},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
Expand Down Expand Up @@ -417,11 +424,65 @@ func TestCollectRunConfig_BitbucketPrefix(t *testing.T) {
nil,
)

_, err := CollectRunConfig(context.Background(), prompts, projects, nil, fetch, "")
_, err := CollectRunConfig(context.Background(), prompts, projects, nil, fetch, "", CollectOptions{})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if capturedSlug != "bb/acme/api" {
t.Fatalf("slug = %q, want %q", capturedSlug, "bb/acme/api")
}
}

func TestCollectRunConfig_ProjectSlug(t *testing.T) {
var capturedSlug string
fetch := func(_ context.Context, slug string) (*circleci.ProjectDetail, error) {
capturedSlug = slug
return &circleci.ProjectDetail{ID: "proj-slug", OrgID: "org-slug", Slug: slug}, nil
}

// No SelectFrom for project — only definition prompts.
prompts := fakePrompts(
nil,
[]string{"dev", "550e8400-e29b-41d4-a716-446655440000", "", "", ""},
[]bool{false},
nil,
)

cfg, err := CollectRunConfig(
context.Background(),
prompts,
nil, // projects unused when slug set
nil,
fetch,
"",
CollectOptions{ProjectSlug: "gh/acme/api"},
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if capturedSlug != "gh/acme/api" {
t.Fatalf("slug = %q, want %q", capturedSlug, "gh/acme/api")
}
if cfg.OrgID != "org-slug" || cfg.ProjectID != "proj-slug" {
t.Fatalf("org/project = %s/%s", cfg.OrgID, cfg.ProjectID)
}
if cfg.OrgType != "github" {
t.Fatalf("OrgType = %q, want github", cfg.OrgType)
}
}

func TestCollectRunConfig_InvalidProjectSlug(t *testing.T) {
prompts := fakePrompts(nil, nil, nil, nil)
_, err := CollectRunConfig(
context.Background(),
prompts,
nil,
nil,
nil,
"",
CollectOptions{ProjectSlug: "not-a-slug"},
)
if err == nil {
t.Fatal("expected error for invalid slug")
}
}
30 changes: 30 additions & 0 deletions internal/testing/fakes/circleci.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"

"github.com/gin-gonic/gin"
Expand All @@ -24,6 +25,13 @@ type Project struct {
Reponame string `json:"reponame"`
}

type ProjectDetail struct {
ID string `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
OrgID string `json:"organization_id"`
}

type Sidecar struct {
ID string `json:"id"`
Name string `json:"name"`
Expand Down Expand Up @@ -71,6 +79,7 @@ type FakeCircleCI struct {
snapshotCounter int
Collaborations []Collaboration
Projects []Project
ProjectDetails map[string]ProjectDetail // slug -> detail for GET /project/:slug
Sidecars []Sidecar
Snapshots []Snapshot
RunResponse *RunResponse
Expand All @@ -90,6 +99,7 @@ type FakeCircleCI struct {
GetSnapshotStatusCode int // override for GET /sidecar/snapshots/:id
ListSnapshotsStatusCode int // override for GET /sidecar/snapshots
GetCommandStatusCode int // override for GET /sidecar/commands/:id
GetProjectStatusCode int // override for GET /project/:slug
}

func NewFakeCircleCI() *FakeCircleCI {
Expand All @@ -104,6 +114,7 @@ func NewFakeCircleCI() *FakeCircleCI {
r.GET("/api/v2/me", f.handleGetCurrentUser)
r.GET("/api/v2/me/collaborations", f.handleCollaborations)
r.GET("/api/v1.1/projects", f.handleProjects)
r.GET("/api/v2/project/*slug", f.handleGetProjectBySlug)

// Sidecar V3 endpoints
r.GET("/api/v3/sidecar/instances", f.handleListSidecars)
Expand Down Expand Up @@ -164,6 +175,25 @@ func (f *FakeCircleCI) handleProjects(c *gin.Context) {
c.JSON(http.StatusOK, f.Projects)
}

func (f *FakeCircleCI) handleGetProjectBySlug(c *gin.Context) {
if !f.requireToken(c) {
return
}
f.mu.RLock()
defer f.mu.RUnlock()
if f.GetProjectStatusCode != 0 {
c.JSON(f.GetProjectStatusCode, gin.H{"message": "API error"})
return
}
// gin wildcard includes leading slash: "/gh/org/repo"
slug := strings.TrimPrefix(c.Param("slug"), "/")
if detail, ok := f.ProjectDetails[slug]; ok {
c.JSON(http.StatusOK, detail)
return
}
c.JSON(http.StatusNotFound, gin.H{"message": "Project not found"})
}

func (f *FakeCircleCI) handleListSidecars(c *gin.Context) {
if !f.requireToken(c) {
return
Expand Down