Skip to content
Merged
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
57 changes: 51 additions & 6 deletions internal/commands/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,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 <identifier> 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 <identifier>.",
}
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 <identifier>. 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).
Expand Down Expand Up @@ -260,7 +305,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

Expand All @@ -282,11 +327,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",
Expand All @@ -305,6 +345,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).
Expand Down
96 changes: 96 additions & 0 deletions internal/commands/deploy_test.go
Original file line number Diff line number Diff line change
@@ -1,12 +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"
Expand Down Expand Up @@ -505,6 +508,99 @@ func TestResolveBranchAndRevision_ServerPreferredBranchBeatsGroup(t *testing.T)
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)
}

// TestResolveLatestRevision_EmbedsStatusCodeInMessage guards the telemetry
// regression: SanitizeErrorMessage keeps only the first line of err.Error(),
// so anything in the Hint never reaches the dashboard. The status code must
Expand Down
Loading