From 37559b3beb59fdd3b3a2b7ca1ee8e2d23be8a7b1 Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Mon, 8 Jun 2026 09:31:35 +0200 Subject: [PATCH] fix(deploy): auto-pick the only project when --project is missing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Telemetry shows "No project specified" on `dhq deploy` is a steady small bucket (19 hits / 30d, mix of humans and a claude-code agent), and 4/19 came from current v0.18.2. The deploy command already auto- selects the only server on the account; extend the same symmetry to projects, and when there are multiple, list them in the error so agents can self-correct on retry without a separate `dhq projects list` call. - New resolveDeployProject helper: returns the configured project if set, otherwise calls ListProjects. 1 project → auto-pick with a Status line. 0 → tell the user to create one. >1 → UserError whose Hint enumerates Identifier (Name) pairs. - "No project specified" stays as the headline string in every error path so the existing Mixpanel failure bucket stays continuous after the fix lands. - Five unit tests cover configured short-circuit, lone-project pick, multi-project listing, zero-project hint, and the headline-preserved API-error path. - Update the deploy command's Long description so the auto-pick is discoverable from `dhq deploy --help`. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/commands/deploy.go | 57 +++++++++++++++++-- internal/commands/deploy_test.go | 97 ++++++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+), 6 deletions(-) diff --git a/internal/commands/deploy.go b/internal/commands/deploy.go index 1c4bc48..8d3b268 100644 --- a/internal/commands/deploy.go +++ b/internal/commands/deploy.go @@ -111,6 +111,51 @@ func resolveStartRevision(server *sdk.Server, group *sdk.ServerGroup, flagStart return "" } +// resolveDeployProject returns the project identifier for a deploy. +// +// When the user did not pass --project (or set DEPLOYHQ_PROJECT / a +// .deployhq.toml), we mirror the server auto-pick behaviour: if the account +// has exactly one project, use it. Otherwise return a UserError that lists +// the available projects so callers (including agents) can retry with a +// concrete identifier. +func resolveDeployProject(ctx context.Context, client *sdk.Client, configured string, env *output.Envelope) (string, error) { + if configured != "" { + return configured, nil + } + + projects, err := client.ListProjects(ctx, nil) + if err != nil { + // Don't swallow the API error — but keep the original "No project + // specified" message as the headline so the failure bucket stays + // recognisable in telemetry. + return "", &output.UserError{ + Message: "No project specified", + Hint: fmt.Sprintf("Could not auto-detect a project: %v\nPass --project or set DEPLOYHQ_PROJECT.", err), + } + } + + switch len(projects) { + case 0: + return "", &output.UserError{ + Message: "No project specified", + Hint: "This account has no projects yet. Create one in the DeployHQ dashboard, then re-run with --project .", + } + case 1: + env.Status("Auto-selected project: %s", projects[0].Name) + return projects[0].Identifier, nil + default: + items := make([]string, 0, len(projects)) + for _, p := range projects { + items = append(items, fmt.Sprintf("%s (%s)", p.Identifier, p.Name)) + } + return "", &output.UserError{ + Message: "No project specified", + Hint: fmt.Sprintf("Account has %d projects — pass --project . Available: %s", + len(projects), strings.Join(items, ", ")), + } + } +} + // resolveLatestRevision tries to find the latest revision for a project, // falling back to the most recent deployment's end revision if the // repository endpoint fails (e.g. empty repo, missing default branch). @@ -240,7 +285,7 @@ func newDeployCmd() *cobra.Command { cmd := &cobra.Command{ Use: "deploy", Short: "Deploy to a server (shortcut for deployments create)", - Long: "Create a deployment. Shortcut for 'dhq deployments create'.\n\nBy default deploys are incremental: the start revision defaults to the server's last successfully deployed commit. Use --full to deploy the entire branch from the first commit.\n\nUse --wait (-w) to watch the deployment until it completes or fails.", + Long: "Create a deployment. Shortcut for 'dhq deployments create'.\n\nBy default deploys are incremental: the start revision defaults to the server's last successfully deployed commit. Use --full to deploy the entire branch from the first commit.\n\nUse --wait (-w) to watch the deployment until it completes or fails.\n\nWhen --project is not provided (and DEPLOYHQ_PROJECT / .deployhq.toml don't set it), `dhq deploy` auto-selects the only project on the account; if there are multiple, the error lists them so you can pick.", Example: ` # Deploy the latest revision (auto-selects the only server, uses the server's preferred branch) dhq deploy @@ -262,11 +307,6 @@ func newDeployCmd() *cobra.Command { # Deploy the entire branch from the first commit (overrides incremental default) dhq deploy --full`, RunE: func(cmd *cobra.Command, args []string) error { - projectID, err := cliCtx.RequireProject() - if err != nil { - return err - } - if dryRun && wait { return &output.UserError{ Message: "--dry-run and --wait are mutually exclusive", @@ -285,6 +325,11 @@ func newDeployCmd() *cobra.Command { env := cliCtx.Envelope + projectID, err := resolveDeployProject(cliCtx.Background(), client, cliCtx.Config.Project, env) + if err != nil { + return err + } + // Track the Server or ServerGroup we resolved to, so branch/revision lookup // can reuse them without extra round-trips. Exactly one of these will be // non-nil once the target is locked in (or both nil for a project-wide deploy). diff --git a/internal/commands/deploy_test.go b/internal/commands/deploy_test.go index 4a0872b..c0e9278 100644 --- a/internal/commands/deploy_test.go +++ b/internal/commands/deploy_test.go @@ -1,11 +1,15 @@ package commands import ( + "bytes" "encoding/json" + "io" "net/http" "net/http/httptest" + "strings" "testing" + "github.com/deployhq/deployhq-cli/internal/output" "github.com/deployhq/deployhq-cli/pkg/sdk" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -503,3 +507,96 @@ func TestResolveBranchAndRevision_ServerPreferredBranchBeatsGroup(t *testing.T) assert.Equal(t, "server-branch", branch) assert.Equal(t, "sha-of-server", revision) } + +// testEnvelope returns an Envelope wired to a bytes.Buffer so Status messages +// are captured for assertion. +func testEnvelope() (*output.Envelope, *bytes.Buffer) { + var buf bytes.Buffer + return &output.Envelope{Stdout: io.Discard, Stderr: &buf}, &buf +} + +func TestResolveDeployProject_ConfiguredShortCircuits(t *testing.T) { + // No API server: a configured project must NOT trigger ListProjects. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Errorf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer srv.Close() + client := newTestSDKClient(t, srv) + env, _ := testEnvelope() + + id, err := resolveDeployProject(t.Context(), client, "my-app", env) + require.NoError(t, err) + assert.Equal(t, "my-app", id) +} + +func TestResolveDeployProject_AutoPicksLoneProject(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "/projects", r.URL.Path) + _ = json.NewEncoder(w).Encode([]sdk.Project{{Identifier: "only-id", Name: "Only Project"}}) + })) + defer srv.Close() + client := newTestSDKClient(t, srv) + env, stderr := testEnvelope() + + id, err := resolveDeployProject(t.Context(), client, "", env) + require.NoError(t, err) + assert.Equal(t, "only-id", id) + assert.Contains(t, stderr.String(), "Auto-selected project: Only Project") +} + +func TestResolveDeployProject_MultipleProjectsListsThem(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]sdk.Project{ + {Identifier: "a-id", Name: "Alpha"}, + {Identifier: "b-id", Name: "Beta"}, + {Identifier: "c-id", Name: "Gamma"}, + }) + })) + defer srv.Close() + client := newTestSDKClient(t, srv) + env, _ := testEnvelope() + + id, err := resolveDeployProject(t.Context(), client, "", env) + require.Error(t, err) + assert.Empty(t, id) + msg := err.Error() + // Headline preserved for telemetry continuity. + firstLine := strings.SplitN(msg, "\n", 2)[0] + assert.Equal(t, "No project specified", firstLine) + // Hint lists each project so agents can self-correct on retry. + assert.Contains(t, msg, "3 projects") + assert.Contains(t, msg, "a-id (Alpha)") + assert.Contains(t, msg, "b-id (Beta)") + assert.Contains(t, msg, "c-id (Gamma)") +} + +func TestResolveDeployProject_ZeroProjects(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode([]sdk.Project{}) + })) + defer srv.Close() + client := newTestSDKClient(t, srv) + env, _ := testEnvelope() + + id, err := resolveDeployProject(t.Context(), client, "", env) + require.Error(t, err) + assert.Empty(t, id) + assert.Contains(t, err.Error(), "No project specified") + assert.Contains(t, err.Error(), "no projects yet") +} + +func TestResolveDeployProject_APIErrorKeepsHeadline(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + client := newTestSDKClient(t, srv) + env, _ := testEnvelope() + + _, err := resolveDeployProject(t.Context(), client, "", env) + require.Error(t, err) + // The "No project specified" headline must survive telemetry sanitization + // (which keeps only the first line) so the failure bucket stays recognisable. + firstLine := strings.SplitN(err.Error(), "\n", 2)[0] + assert.Equal(t, "No project specified", firstLine) +}