From bef2706e2b0ad19bf9a9f1e57628e82933bae80c Mon Sep 17 00:00:00 2001 From: Facundo Farias Date: Fri, 22 May 2026 10:25:37 +0200 Subject: [PATCH] fix(deploy): resolve server-group names passed to -s/--server (DHQ-586) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dhq deploy -s "" was documented as supported but only worked when the group's UUID was passed; a display name fell through to the server-only resolver and triggered the picker or "Multiple servers found" error in non-interactive mode. Add a resolveGroupName helper mirroring resolveServerName's exact / normalized / contains tiers, and try it as a fallback in runDeploy before showing the server picker. On a unique group match, the group's identifier is used as parent_identifier and resolvedServer stays nil — downstream code already tolerates this (GetServer 404s for groups). Refs DHQ-586. Co-Authored-By: Claude Opus 4.7 (1M context) --- internal/commands/deploy.go | 99 +++++++++++++++++++++++++------- internal/commands/deploy_test.go | 54 +++++++++++++++++ 2 files changed, 131 insertions(+), 22 deletions(-) diff --git a/internal/commands/deploy.go b/internal/commands/deploy.go index ec8430a..37abb72 100644 --- a/internal/commands/deploy.go +++ b/internal/commands/deploy.go @@ -173,6 +173,43 @@ func resolveServerName(input string, servers []sdk.Server) (string, []sdk.Server return "", servers } +// resolveGroupName matches a user-provided name to a server-group identifier. +// Mirrors resolveServerName's exact / normalized / contains tiers. Returns the +// identifier on a unique match, or "" when ambiguous or no match. The group's +// display name is also returned for user-facing status messages. +func resolveGroupName(input string, groups []sdk.ServerGroup) (identifier, name string) { + normalized := normalize(input) + + for _, g := range groups { + if strings.EqualFold(g.Name, input) { + return g.Identifier, g.Name + } + } + + var normalizedMatches []sdk.ServerGroup + for _, g := range groups { + if normalize(g.Name) == normalized { + normalizedMatches = append(normalizedMatches, g) + } + } + if len(normalizedMatches) == 1 { + return normalizedMatches[0].Identifier, normalizedMatches[0].Name + } + + lower := strings.ToLower(input) + var containsMatches []sdk.ServerGroup + for _, g := range groups { + if strings.Contains(strings.ToLower(g.Name), lower) { + containsMatches = append(containsMatches, g) + } + } + if len(containsMatches) == 1 { + return containsMatches[0].Identifier, containsMatches[0].Name + } + + return "", "" +} + // normalize lowercases and collapses all non-alphanumeric chars. func normalize(s string) string { var b strings.Builder @@ -284,6 +321,7 @@ func newDeployCmd() *cobra.Command { servers, err := client.ListServers(cliCtx.Background(), projectID, nil) if err == nil { resolved, candidates := resolveServerName(server, servers) + matched := false if resolved != "" { server = resolved for i := range servers { @@ -292,29 +330,46 @@ func newDeployCmd() *cobra.Command { break } } - } else if len(candidates) > 0 && !env.NonInteractive { - items := make([]string, len(candidates)) - for i, s := range candidates { - items[i] = fmt.Sprintf("%s (%s)", s.Name, s.ProtocolType) - } - prompt := promptui.Select{ - Label: fmt.Sprintf("Multiple servers match %q", server), - Items: items, - } - idx, _, err := prompt.Run() - if err != nil { - return &output.UserError{Message: "Server selection cancelled"} - } - server = candidates[idx].Identifier - resolvedServer = &candidates[idx] - } else if len(candidates) > 0 { - names := make([]string, len(candidates)) - for i, s := range candidates { - names[i] = fmt.Sprintf("%s (%s)", s.Identifier, s.Name) + matched = true + } + // Server groups are deployable targets too. Fall back to group-name + // resolution before the per-server picker so `-s "My Group"` works + // (documented at deployhq.com/support/cli/cli-deploying). + if !matched { + if groups, gerr := client.ListServerGroups(cliCtx.Background(), projectID, nil); gerr == nil { + if groupID, groupName := resolveGroupName(server, groups); groupID != "" { + server = groupID + resolvedServer = nil // groups don't map to a single Server + env.Status("Resolved to server group: %s", groupName) + matched = true + } } - return &output.UserError{ - Message: fmt.Sprintf("Multiple servers match %q — specify which one", server), - Hint: fmt.Sprintf("Use the full identifier. Matches: %s", strings.Join(names, ", ")), + } + if !matched { + if len(candidates) > 0 && !env.NonInteractive { + items := make([]string, len(candidates)) + for i, s := range candidates { + items[i] = fmt.Sprintf("%s (%s)", s.Name, s.ProtocolType) + } + prompt := promptui.Select{ + Label: fmt.Sprintf("Multiple servers match %q", server), + Items: items, + } + idx, _, err := prompt.Run() + if err != nil { + return &output.UserError{Message: "Server selection cancelled"} + } + server = candidates[idx].Identifier + resolvedServer = &candidates[idx] + } else if len(candidates) > 0 { + names := make([]string, len(candidates)) + for i, s := range candidates { + names[i] = fmt.Sprintf("%s (%s)", s.Identifier, s.Name) + } + return &output.UserError{ + Message: fmt.Sprintf("Multiple servers match %q — specify which one", server), + Hint: fmt.Sprintf("Use the full identifier. Matches: %s", strings.Join(names, ", ")), + } } } } diff --git a/internal/commands/deploy_test.go b/internal/commands/deploy_test.go index 59ee80d..b8258a1 100644 --- a/internal/commands/deploy_test.go +++ b/internal/commands/deploy_test.go @@ -300,6 +300,60 @@ func TestDeployStartRevisionFlagsRegistered(t *testing.T) { assert.NotNil(t, deployCmd.Flags().Lookup("full")) } +func TestResolveGroupName_ExactMatch(t *testing.T) { + // DHQ-586: passing the group's display name to -s must resolve to its identifier. + groups := []sdk.ServerGroup{ + {Identifier: "grp-prod", Name: "Production"}, + {Identifier: "grp-stag", Name: "Staging"}, + } + id, name := resolveGroupName("Production", groups) + assert.Equal(t, "grp-prod", id) + assert.Equal(t, "Production", name) +} + +func TestResolveGroupName_CaseInsensitive(t *testing.T) { + groups := []sdk.ServerGroup{{Identifier: "grp-prod", Name: "Production"}} + id, _ := resolveGroupName("production", groups) + assert.Equal(t, "grp-prod", id) +} + +func TestResolveGroupName_NormalizedMatch(t *testing.T) { + // "us-prod" should match "US Prod" via the normalize tier. + groups := []sdk.ServerGroup{ + {Identifier: "grp-us", Name: "US Prod"}, + {Identifier: "grp-eu", Name: "EU Prod"}, + } + id, name := resolveGroupName("us-prod", groups) + assert.Equal(t, "grp-us", id) + assert.Equal(t, "US Prod", name) +} + +func TestResolveGroupName_ContainsMatch(t *testing.T) { + groups := []sdk.ServerGroup{ + {Identifier: "grp-prod", Name: "Production Cluster"}, + {Identifier: "grp-stag", Name: "Staging Cluster"}, + } + id, _ := resolveGroupName("Production", groups) + assert.Equal(t, "grp-prod", id) +} + +func TestResolveGroupName_AmbiguousReturnsEmpty(t *testing.T) { + // Multiple contains matches → don't auto-pick. Caller falls back to the + // existing server picker / "specify which one" error. + groups := []sdk.ServerGroup{ + {Identifier: "grp-1", Name: "Production US"}, + {Identifier: "grp-2", Name: "Production EU"}, + } + id, _ := resolveGroupName("Production", groups) + assert.Equal(t, "", id) +} + +func TestResolveGroupName_NoMatch(t *testing.T) { + groups := []sdk.ServerGroup{{Identifier: "grp-prod", Name: "Production"}} + id, _ := resolveGroupName("Nonexistent", groups) + assert.Equal(t, "", id) +} + func TestResolveBranchAndRevision_PreferredBranchEmptyFallsToBranchField(t *testing.T) { // Some servers populate Branch but not PreferredBranch. Treat both as the // same source of truth.