From d06c27cf28502726a18a34a2cf76c75548f66395 Mon Sep 17 00:00:00 2001 From: Hidetaka Okamoto CCI Date: Fri, 10 Jul 2026 10:16:10 +0900 Subject: [PATCH] Add --project flag to task config for large CircleCI accounts. Skip fetching and scrolling through followed projects when the slug is already known, and fix GetProjectBySlug org ID parsing for organization_id. Co-authored-by: Cursor --- docs/CLI.md | 1 + internal/circleci/circleci_test.go | 24 ++++++++++++ internal/circleci/projects.go | 2 +- internal/cmd/task.go | 23 +++++++---- internal/task/collect.go | 32 ++++++++++++++- internal/task/collect_test.go | 63 +++++++++++++++++++++++++++++- internal/testing/fakes/circleci.go | 30 ++++++++++++++ 7 files changed, 164 insertions(+), 11 deletions(-) diff --git a/docs/CLI.md b/docs/CLI.md index de24f629..c1bc3179 100644 --- a/docs/CLI.md +++ b/docs/CLI.md @@ -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 # Skip project picker (e.g. gh/org/repo) │ ├── skill │ ├── install # Install all skills diff --git a/internal/circleci/circleci_test.go b/internal/circleci/circleci_test.go index fe7092ef..c7f00a24 100644 --- a/internal/circleci/circleci_test.go +++ b/internal/circleci/circleci_test.go @@ -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() diff --git a/internal/circleci/projects.go b/internal/circleci/projects.go index dccde59b..ea8056f5 100644 --- a/internal/circleci/projects.go +++ b/internal/circleci/projects.go @@ -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. diff --git a/internal/cmd/task.go b/internal/cmd/task.go index 08cd51f2..8f69ff5a 100644 --- a/internal/cmd/task.go +++ b/internal/cmd/task.go @@ -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", @@ -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, @@ -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 } @@ -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 } diff --git a/internal/task/collect.go b/internal/task/collect.go index 6c77303a..04b08dc0 100644 --- a/internal/task/collect.go +++ b/internal/task/collect.go @@ -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. @@ -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 } @@ -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) @@ -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 // (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, diff --git a/internal/task/collect_test.go b/internal/task/collect_test.go index bf332132..d7eec151 100644 --- a/internal/task/collect_test.go +++ b/internal/task/collect_test.go @@ -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) @@ -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) @@ -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) @@ -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) @@ -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) @@ -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") @@ -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) @@ -417,7 +424,7 @@ 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) } @@ -425,3 +432,57 @@ func TestCollectRunConfig_BitbucketPrefix(t *testing.T) { 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") + } +} diff --git a/internal/testing/fakes/circleci.go b/internal/testing/fakes/circleci.go index 4e2ac60e..60bc4cc2 100644 --- a/internal/testing/fakes/circleci.go +++ b/internal/testing/fakes/circleci.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/http" + "strings" "sync" "github.com/gin-gonic/gin" @@ -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"` @@ -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 @@ -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 { @@ -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) @@ -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