From b3babc960fd52d8b5015ddef51360149876051b0 Mon Sep 17 00:00:00 2001 From: Marta Date: Thu, 9 Apr 2026 12:57:10 +0200 Subject: [PATCH 1/5] feat: add ListOptions to all SDK List methods for pagination support Add ListOptions struct with Page/PerPage fields and appendListParams helper to build query strings. All 24 List* methods now accept an optional *ListOptions parameter (nil preserves current behavior). Fix Pagination struct field names to match actual API response (total_records/offset instead of total_count/per_page). Update all callers to pass nil for the new parameter. --- internal/assist/context.go | 4 +- internal/commands/activity.go | 4 +- internal/commands/auth.go | 2 +- internal/commands/auto_deploys.go | 2 +- internal/commands/build_commands.go | 4 +- internal/commands/build_configs.go | 2 +- internal/commands/completions_dynamic.go | 4 +- internal/commands/config_files.go | 2 +- internal/commands/configure.go | 2 +- internal/commands/doctor.go | 2 +- internal/commands/env_vars.go | 4 +- internal/commands/excluded_files.go | 2 +- internal/commands/global_servers.go | 4 +- internal/commands/hello.go | 4 +- internal/commands/init.go | 2 +- internal/commands/language_versions.go | 2 +- internal/commands/network_agents.go | 2 +- internal/commands/projects.go | 4 +- internal/commands/repos.go | 4 +- internal/commands/scheduled_deploys.go | 2 +- internal/commands/ssh_commands.go | 2 +- internal/commands/ssh_keys.go | 2 +- internal/commands/status.go | 2 +- internal/commands/templates.go | 4 +- internal/commands/test_access.go | 2 +- internal/commands/url.go | 6 +- internal/commands/zones.go | 2 +- pkg/sdk/activity.go | 10 +- pkg/sdk/agents.go | 5 +- pkg/sdk/auto_deployments.go | 5 +- pkg/sdk/build_commands.go | 5 +- pkg/sdk/build_configs.go | 5 +- pkg/sdk/client.go | 23 +++ pkg/sdk/client_test.go | 28 +++- pkg/sdk/config_files.go | 5 +- pkg/sdk/deployments.go | 17 +- pkg/sdk/deployments_test.go | 4 +- pkg/sdk/env_vars.go | 10 +- pkg/sdk/excluded_files.go | 5 +- pkg/sdk/global_servers.go | 5 +- pkg/sdk/integrations.go | 5 +- pkg/sdk/language_versions.go | 5 +- pkg/sdk/projects.go | 5 +- pkg/sdk/projects_test.go | 2 +- pkg/sdk/repositories.go | 10 +- pkg/sdk/repositories_test.go | 4 +- pkg/sdk/scheduled_deployments.go | 5 +- pkg/sdk/server_groups.go | 5 +- pkg/sdk/server_groups_test.go | 2 +- pkg/sdk/servers.go | 5 +- pkg/sdk/servers_test.go | 2 +- pkg/sdk/ssh_commands.go | 5 +- pkg/sdk/ssh_keys.go | 5 +- pkg/sdk/templates.go | 8 +- pkg/sdk/templates_test.go | 6 +- pkg/sdk/types.go | 189 ++++++++++++----------- pkg/sdk/zones.go | 5 +- 57 files changed, 284 insertions(+), 189 deletions(-) diff --git a/internal/assist/context.go b/internal/assist/context.go index 59f4a82..c90c2a5 100644 --- a/internal/assist/context.go +++ b/internal/assist/context.go @@ -52,7 +52,7 @@ func GatherContext(ctx context.Context, client *sdk.Client, projectID string) (* ac := &AssistContext{Project: projectID} // Fetch recent deployments - result, err := client.ListDeployments(ctx, projectID) + result, err := client.ListDeployments(ctx, projectID, nil) if err != nil { return ac, nil // non-fatal } @@ -121,7 +121,7 @@ func GatherContext(ctx context.Context, client *sdk.Client, projectID string) (* } // Fetch servers - servers, err := client.ListServers(ctx, projectID) + servers, err := client.ListServers(ctx, projectID, nil) if err == nil { for _, s := range servers { ac.Servers = append(ac.Servers, serverSummary{ diff --git a/internal/commands/activity.go b/internal/commands/activity.go index ceb79ee..86c6a2d 100644 --- a/internal/commands/activity.go +++ b/internal/commands/activity.go @@ -32,7 +32,7 @@ func newActivityListCmd() *cobra.Command { if err != nil { return err } - events, err := client.ListActivity(cliCtx.Background()) + events, err := client.ListActivity(cliCtx.Background(), nil) if err != nil { return err } @@ -71,7 +71,7 @@ func newActivityStatsCmd() *cobra.Command { if err != nil { return err } - result, err := client.ListActivityWithStats(cliCtx.Background()) + result, err := client.ListActivityWithStats(cliCtx.Background(), nil) if err != nil { return err } diff --git a/internal/commands/auth.go b/internal/commands/auth.go index a0931e3..d2fecf5 100644 --- a/internal/commands/auth.go +++ b/internal/commands/auth.go @@ -123,7 +123,7 @@ func runAuthLogin(opts *AuthLoginOptions) error { return &output.UserError{Message: err.Error()} } - _, err = client.ListProjects(cliCtx.Background()) + _, err = client.ListProjects(cliCtx.Background(), nil) if err != nil { if sdk.IsUnauthorized(err) { return &output.AuthError{ diff --git a/internal/commands/auto_deploys.go b/internal/commands/auto_deploys.go index 67fc145..8145bf9 100644 --- a/internal/commands/auto_deploys.go +++ b/internal/commands/auto_deploys.go @@ -26,7 +26,7 @@ func newAutoDeploysCmd() *cobra.Command { if err != nil { return err } - result, err := client.ListAutoDeployments(cliCtx.Background(), projectID) + result, err := client.ListAutoDeployments(cliCtx.Background(), projectID, nil) if err != nil { return err } diff --git a/internal/commands/build_commands.go b/internal/commands/build_commands.go index 680e87d..30bf172 100644 --- a/internal/commands/build_commands.go +++ b/internal/commands/build_commands.go @@ -26,7 +26,7 @@ func newBuildCommandsCmd() *cobra.Command { if err != nil { return err } - cmds, err := client.ListBuildCommands(cliCtx.Background(), projectID) + cmds, err := client.ListBuildCommands(cliCtx.Background(), projectID, nil) if err != nil { return err } @@ -112,7 +112,7 @@ func newBuildCommandsUpdateCmd() *cobra.Command { // resolveBuildCommandID resolves a numeric ID to a UUID identifier if needed. func resolveBuildCommandID(ctx context.Context, client *sdk.Client, projectID, arg string) (string, error) { - cmds, err := client.ListBuildCommands(ctx, projectID) + cmds, err := client.ListBuildCommands(ctx, projectID, nil) if err != nil { return arg, nil // fall through — let the API report the real error } diff --git a/internal/commands/build_configs.go b/internal/commands/build_configs.go index 3cbfe56..84747ba 100644 --- a/internal/commands/build_configs.go +++ b/internal/commands/build_configs.go @@ -26,7 +26,7 @@ func newBuildConfigsCmd() *cobra.Command { if err != nil { return err } - configs, err := client.ListBuildConfigs(cliCtx.Background(), projectID) + configs, err := client.ListBuildConfigs(cliCtx.Background(), projectID, nil) if err != nil { return err } diff --git a/internal/commands/completions_dynamic.go b/internal/commands/completions_dynamic.go index a61c71e..16deeb4 100644 --- a/internal/commands/completions_dynamic.go +++ b/internal/commands/completions_dynamic.go @@ -10,7 +10,7 @@ func completeProjectNames(cmd *cobra.Command, args []string, toComplete string) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } - projects, err := client.ListProjects(cliCtx.Background()) + projects, err := client.ListProjects(cliCtx.Background(), nil) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } @@ -31,7 +31,7 @@ func completeServerNames(cmd *cobra.Command, args []string, toComplete string) ( if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } - servers, err := client.ListServers(cliCtx.Background(), projectID) + servers, err := client.ListServers(cliCtx.Background(), projectID, nil) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } diff --git a/internal/commands/config_files.go b/internal/commands/config_files.go index ee536c4..6cba064 100644 --- a/internal/commands/config_files.go +++ b/internal/commands/config_files.go @@ -35,7 +35,7 @@ func newConfigFilesListCmd() *cobra.Command { if err != nil { return err } - files, err := client.ListConfigFiles(cliCtx.Background(), projectID) + files, err := client.ListConfigFiles(cliCtx.Background(), projectID, nil) if err != nil { return err } diff --git a/internal/commands/configure.go b/internal/commands/configure.go index c8a2e8d..78af68f 100644 --- a/internal/commands/configure.go +++ b/internal/commands/configure.go @@ -35,7 +35,7 @@ func newConfigureCmd() *cobra.Command { } // Step 2: Pick a default project - projects, err := client.ListProjects(cliCtx.Background()) + projects, err := client.ListProjects(cliCtx.Background(), nil) if err != nil || len(projects) == 0 { env.Status("No projects found. Create one with 'dhq projects create --name my-app'") return nil diff --git a/internal/commands/doctor.go b/internal/commands/doctor.go index 94996a8..0a41f01 100644 --- a/internal/commands/doctor.go +++ b/internal/commands/doctor.go @@ -66,7 +66,7 @@ func newDoctorCmd() *cobra.Command { if err != nil { checks = append(checks, doctorCheck{"API connectivity", "fail", err.Error()}) } else { - _, err := client.ListProjects(cliCtx.Background()) + _, err := client.ListProjects(cliCtx.Background(), nil) if err != nil { checks = append(checks, doctorCheck{"API connectivity", "fail", err.Error()}) } else { diff --git a/internal/commands/env_vars.go b/internal/commands/env_vars.go index 05d4835..c7eb79b 100644 --- a/internal/commands/env_vars.go +++ b/internal/commands/env_vars.go @@ -53,7 +53,7 @@ func newEnvVarsListCmd() *cobra.Command { if err != nil { return err } - vars, err := client.ListEnvVars(cliCtx.Background(), projectID) + vars, err := client.ListEnvVars(cliCtx.Background(), projectID, nil) if err != nil { return err } @@ -219,7 +219,7 @@ func newGlobalEnvVarsCmd() *cobra.Command { if err != nil { return err } - vars, err := client.ListGlobalEnvVars(cliCtx.Background()) + vars, err := client.ListGlobalEnvVars(cliCtx.Background(), nil) if err != nil { return err } diff --git a/internal/commands/excluded_files.go b/internal/commands/excluded_files.go index 9a7ec57..b5f8cad 100644 --- a/internal/commands/excluded_files.go +++ b/internal/commands/excluded_files.go @@ -25,7 +25,7 @@ func newExcludedFilesCmd() *cobra.Command { if err != nil { return err } - files, err := client.ListExcludedFiles(cliCtx.Background(), projectID) + files, err := client.ListExcludedFiles(cliCtx.Background(), projectID, nil) if err != nil { return err } diff --git a/internal/commands/global_servers.go b/internal/commands/global_servers.go index 16b2c59..ffca6d0 100644 --- a/internal/commands/global_servers.go +++ b/internal/commands/global_servers.go @@ -21,7 +21,7 @@ func newGlobalServersCmd() *cobra.Command { if err != nil { return err } - servers, err := client.ListGlobalServers(cliCtx.Background()) + servers, err := client.ListGlobalServers(cliCtx.Background(), nil) if err != nil { return err } @@ -171,7 +171,7 @@ func newIntegrationsCmd() *cobra.Command { if err != nil { return err } - integrations, err := client.ListIntegrations(cliCtx.Background(), projectID) + integrations, err := client.ListIntegrations(cliCtx.Background(), projectID, nil) if err != nil { return err } diff --git a/internal/commands/hello.go b/internal/commands/hello.go index 16fee75..7b2d6ae 100644 --- a/internal/commands/hello.go +++ b/internal/commands/hello.go @@ -60,7 +60,7 @@ func newHelloCmd() *cobra.Command { return &output.InternalError{Message: "create client", Cause: err} } - projects, err := client.ListProjects(cliCtx.Background()) + projects, err := client.ListProjects(cliCtx.Background(), nil) if err != nil { env.Warn("Could not fetch projects: %v", err) env.Status("") @@ -196,7 +196,7 @@ func helloLogin(env *output.Envelope, reader *bufio.Reader) (*auth.Credentials, return nil, &output.UserError{Message: err.Error()} } - if _, err := client.ListProjects(cliCtx.Background()); err != nil { + if _, err := client.ListProjects(cliCtx.Background(), nil); err != nil { if sdk.IsUnauthorized(err) { return nil, &output.AuthError{ Message: "Invalid credentials", diff --git a/internal/commands/init.go b/internal/commands/init.go index 218595f..104a503 100644 --- a/internal/commands/init.go +++ b/internal/commands/init.go @@ -791,7 +791,7 @@ func (m *initModel) createRepo() tea.Msg { func (m *initModel) fetchSSHKeys() tea.Msg { ctx := context.Background() - keys, err := m.client.ListSSHKeys(ctx) + keys, err := m.client.ListSSHKeys(ctx, nil) if err != nil { return sshKeysResultMsg{keys: nil, err: err} } diff --git a/internal/commands/language_versions.go b/internal/commands/language_versions.go index acfef4a..6a4747d 100644 --- a/internal/commands/language_versions.go +++ b/internal/commands/language_versions.go @@ -27,7 +27,7 @@ func newLanguageVersionsCmd() *cobra.Command { if err != nil { return err } - versions, err := client.ListLanguageVersions(cliCtx.Background(), projectID) + versions, err := client.ListLanguageVersions(cliCtx.Background(), projectID, nil) if err != nil { return err } diff --git a/internal/commands/network_agents.go b/internal/commands/network_agents.go index 4f8e0de..9c8618e 100644 --- a/internal/commands/network_agents.go +++ b/internal/commands/network_agents.go @@ -21,7 +21,7 @@ func newAgentsCmd() *cobra.Command { if err != nil { return err } - agents, err := client.ListAgents(cliCtx.Background()) + agents, err := client.ListAgents(cliCtx.Background(), nil) if err != nil { return err } diff --git a/internal/commands/projects.go b/internal/commands/projects.go index 271fdbc..60e3c6a 100644 --- a/internal/commands/projects.go +++ b/internal/commands/projects.go @@ -42,7 +42,7 @@ func newProjectsListCmd() *cobra.Command { return err } - projects, err := client.ListProjects(cliCtx.Background()) + projects, err := client.ListProjects(cliCtx.Background(), nil) if err != nil { return err } @@ -129,7 +129,7 @@ func newProjectsShowCmd() *cobra.Command { {"Auto Deploy URL", project.AutoDeployURL}, }) - servers, err := client.ListServers(cliCtx.Background(), project.Permalink) + servers, err := client.ListServers(cliCtx.Background(), project.Permalink, nil) if err != nil { return nil // non-fatal: just skip server listing } diff --git a/internal/commands/repos.go b/internal/commands/repos.go index 8880006..1426db6 100644 --- a/internal/commands/repos.go +++ b/internal/commands/repos.go @@ -189,7 +189,7 @@ func newReposBranchesCmd() *cobra.Command { return err } - branches, err := client.ListBranches(cliCtx.Background(), projectID) + branches, err := client.ListBranches(cliCtx.Background(), projectID, nil) if err != nil { return err } @@ -226,7 +226,7 @@ func newReposCommitsCmd() *cobra.Command { return err } - result, err := client.ListRecentCommits(cliCtx.Background(), projectID) + result, err := client.ListRecentCommits(cliCtx.Background(), projectID, nil) if err != nil { return err } diff --git a/internal/commands/scheduled_deploys.go b/internal/commands/scheduled_deploys.go index 737d5bc..110f98f 100644 --- a/internal/commands/scheduled_deploys.go +++ b/internal/commands/scheduled_deploys.go @@ -25,7 +25,7 @@ func newScheduledDeploysCmd() *cobra.Command { if err != nil { return err } - result, err := client.ListScheduledDeployments(cliCtx.Background(), projectID) + result, err := client.ListScheduledDeployments(cliCtx.Background(), projectID, nil) if err != nil { return err } diff --git a/internal/commands/ssh_commands.go b/internal/commands/ssh_commands.go index bf9a2ea..5d39a55 100644 --- a/internal/commands/ssh_commands.go +++ b/internal/commands/ssh_commands.go @@ -25,7 +25,7 @@ func newSSHCommandsCmd() *cobra.Command { if err != nil { return err } - cmds, err := client.ListSSHCommands(cliCtx.Background(), projectID) + cmds, err := client.ListSSHCommands(cliCtx.Background(), projectID, nil) if err != nil { return err } diff --git a/internal/commands/ssh_keys.go b/internal/commands/ssh_keys.go index bd1bdc6..f4ab1d4 100644 --- a/internal/commands/ssh_keys.go +++ b/internal/commands/ssh_keys.go @@ -21,7 +21,7 @@ func newSSHKeysCmd() *cobra.Command { if err != nil { return err } - keys, err := client.ListSSHKeys(cliCtx.Background()) + keys, err := client.ListSSHKeys(cliCtx.Background(), nil) if err != nil { return err } diff --git a/internal/commands/status.go b/internal/commands/status.go index a295849..98ac2f6 100644 --- a/internal/commands/status.go +++ b/internal/commands/status.go @@ -17,7 +17,7 @@ func newStatusCmd() *cobra.Command { if err != nil { return err } - result, err := client.ListActivityWithStats(cliCtx.Background()) + result, err := client.ListActivityWithStats(cliCtx.Background(), nil) if err != nil { return err } diff --git a/internal/commands/templates.go b/internal/commands/templates.go index 148f73c..9e3a10c 100644 --- a/internal/commands/templates.go +++ b/internal/commands/templates.go @@ -38,7 +38,7 @@ func newTemplatesListCmd() *cobra.Command { return err } - templates, err := client.ListTemplates(cliCtx.Background()) + templates, err := client.ListTemplates(cliCtx.Background(), nil) if err != nil { return err } @@ -79,7 +79,7 @@ func newTemplatesPublicCmd() *cobra.Command { return err } - templates, err := client.ListPublicTemplates(cliCtx.Background(), frameworkType) + templates, err := client.ListPublicTemplates(cliCtx.Background(), frameworkType, nil) if err != nil { return err } diff --git a/internal/commands/test_access.go b/internal/commands/test_access.go index b1ce7b1..c9cd179 100644 --- a/internal/commands/test_access.go +++ b/internal/commands/test_access.go @@ -35,7 +35,7 @@ func newTestAccessCmd() *cobra.Command { // Resolve server name to identifier if provided if server != "" && !isUUID(server) { - servers, err := client.ListServers(cliCtx.Background(), projectID) + servers, err := client.ListServers(cliCtx.Background(), projectID, nil) if err == nil { resolved, _ := resolveServerName(server, servers) if resolved != "" { diff --git a/internal/commands/url.go b/internal/commands/url.go index 2682d6f..c4fbf53 100644 --- a/internal/commands/url.go +++ b/internal/commands/url.go @@ -106,7 +106,7 @@ Examples: } return env.WriteJSON(output.NewResponse(project, fmt.Sprintf("Project: %s", project.Name))) } - projects, err := client.ListProjects(ctx) + projects, err := client.ListProjects(ctx, nil) if err != nil { return err } @@ -143,7 +143,7 @@ func showSubResource(parsed *ParsedURL) error { } return env.WriteJSON(output.NewResponse(dep, fmt.Sprintf("Deployment: %s", dep.Identifier))) } - deps, err := client.ListDeployments(ctx, parsed.Project) + deps, err := client.ListDeployments(ctx, parsed.Project, nil) if err != nil { return err } @@ -157,7 +157,7 @@ func showSubResource(parsed *ParsedURL) error { } return env.WriteJSON(output.NewResponse(srv, fmt.Sprintf("Server: %s", srv.Name))) } - servers, err := client.ListServers(ctx, parsed.Project) + servers, err := client.ListServers(ctx, parsed.Project, nil) if err != nil { return err } diff --git a/internal/commands/zones.go b/internal/commands/zones.go index cdbd1c0..19fe80b 100644 --- a/internal/commands/zones.go +++ b/internal/commands/zones.go @@ -20,7 +20,7 @@ func newZonesCmd() *cobra.Command { if err != nil { return err } - zones, err := client.ListZones(cliCtx.Background()) + zones, err := client.ListZones(cliCtx.Background(), nil) if err != nil { return err } diff --git a/pkg/sdk/activity.go b/pkg/sdk/activity.go index ff162b0..31b9f07 100644 --- a/pkg/sdk/activity.go +++ b/pkg/sdk/activity.go @@ -35,18 +35,20 @@ type ActivityWithStats struct { } // ListActivity returns recent activity events for the account. -func (c *Client) ListActivity(ctx context.Context) ([]ActivityEvent, error) { +func (c *Client) ListActivity(ctx context.Context, opts *ListOptions) ([]ActivityEvent, error) { var result []ActivityEvent - if err := c.get(ctx, "/activity", &result); err != nil { + path := appendListParams("/activity", opts) + if err := c.get(ctx, path, &result); err != nil { return nil, err } return result, nil } // ListActivityWithStats returns recent activity events and deploy stats. -func (c *Client) ListActivityWithStats(ctx context.Context) (*ActivityWithStats, error) { +func (c *Client) ListActivityWithStats(ctx context.Context, opts *ListOptions) (*ActivityWithStats, error) { var result ActivityWithStats - if err := c.get(ctx, "/activity?include=stats", &result); err != nil { + path := appendListParams("/activity?include=stats", opts) + if err := c.get(ctx, path, &result); err != nil { return nil, err } return &result, nil diff --git a/pkg/sdk/agents.go b/pkg/sdk/agents.go index 82c5fe3..1d5a17a 100644 --- a/pkg/sdk/agents.go +++ b/pkg/sdk/agents.go @@ -21,9 +21,10 @@ type AgentCreateRequest struct { ClaimCode string `json:"claim_code"` } -func (c *Client) ListAgents(ctx context.Context) ([]Agent, error) { +func (c *Client) ListAgents(ctx context.Context, opts *ListOptions) ([]Agent, error) { var agents []Agent - if err := c.get(ctx, "/agents", &agents); err != nil { + path := appendListParams("/agents", opts) + if err := c.get(ctx, path, &agents); err != nil { return nil, err } return agents, nil diff --git a/pkg/sdk/auto_deployments.go b/pkg/sdk/auto_deployments.go index 7e10654..d69a27d 100644 --- a/pkg/sdk/auto_deployments.go +++ b/pkg/sdk/auto_deployments.go @@ -31,9 +31,10 @@ type DeployableToggle struct { AutoDeploy bool `json:"auto_deploy"` } -func (c *Client) ListAutoDeployments(ctx context.Context, projectID string) (*AutoDeployment, error) { +func (c *Client) ListAutoDeployments(ctx context.Context, projectID string, opts *ListOptions) (*AutoDeployment, error) { var result AutoDeployment - if err := c.get(ctx, fmt.Sprintf("/projects/%s/auto_deployments", projectID), &result); err != nil { + path := appendListParams(fmt.Sprintf("/projects/%s/auto_deployments", projectID), opts) + if err := c.get(ctx, path, &result); err != nil { return nil, err } return &result, nil diff --git a/pkg/sdk/build_commands.go b/pkg/sdk/build_commands.go index 5e029e1..498a8bd 100644 --- a/pkg/sdk/build_commands.go +++ b/pkg/sdk/build_commands.go @@ -30,9 +30,10 @@ type BuildCommandCreateRequest struct { Enabled *bool `json:"enabled,omitempty"` } -func (c *Client) ListBuildCommands(ctx context.Context, projectID string) ([]BuildCommand, error) { +func (c *Client) ListBuildCommands(ctx context.Context, projectID string, opts *ListOptions) ([]BuildCommand, error) { var cmds []BuildCommand - if err := c.get(ctx, fmt.Sprintf("/projects/%s/build_commands", projectID), &cmds); err != nil { + path := appendListParams(fmt.Sprintf("/projects/%s/build_commands", projectID), opts) + if err := c.get(ctx, path, &cmds); err != nil { return nil, err } return cmds, nil diff --git a/pkg/sdk/build_configs.go b/pkg/sdk/build_configs.go index 32df1a4..07c1d7a 100644 --- a/pkg/sdk/build_configs.go +++ b/pkg/sdk/build_configs.go @@ -18,9 +18,10 @@ type BuildConfigCreateRequest struct { Packages map[string]string `json:"packages,omitempty"` } -func (c *Client) ListBuildConfigs(ctx context.Context, projectID string) ([]BuildConfig, error) { +func (c *Client) ListBuildConfigs(ctx context.Context, projectID string, opts *ListOptions) ([]BuildConfig, error) { var configs []BuildConfig - if err := c.get(ctx, fmt.Sprintf("/projects/%s/build_configurations", projectID), &configs); err != nil { + path := appendListParams(fmt.Sprintf("/projects/%s/build_configurations", projectID), opts) + if err := c.get(ctx, path, &configs); err != nil { return nil, err } return configs, nil diff --git a/pkg/sdk/client.go b/pkg/sdk/client.go index 480078b..87f9b6d 100644 --- a/pkg/sdk/client.go +++ b/pkg/sdk/client.go @@ -8,6 +8,7 @@ import ( "io" "net/http" "net/url" + "strconv" "strings" "time" ) @@ -221,6 +222,28 @@ func parseAPIError(resp *http.Response) error { return apiErr } +// appendListParams appends pagination query parameters to a URL path. +func appendListParams(path string, opts *ListOptions) string { + if opts == nil { + return path + } + params := url.Values{} + if opts.Page > 0 { + params.Set("page", strconv.Itoa(opts.Page)) + } + if opts.PerPage > 0 { + params.Set("per_page", strconv.Itoa(opts.PerPage)) + } + if len(params) == 0 { + return path + } + sep := "?" + if strings.Contains(path, "?") { + sep = "&" + } + return path + sep + params.Encode() +} + // get performs a GET request. func (c *Client) get(ctx context.Context, path string, v interface{}) error { return c.do(ctx, http.MethodGet, path, nil, v) diff --git a/pkg/sdk/client_test.go b/pkg/sdk/client_test.go index 7c1f7c7..4268964 100644 --- a/pkg/sdk/client_test.go +++ b/pkg/sdk/client_test.go @@ -69,7 +69,7 @@ func TestClient_BasicAuth(t *testing.T) { defer server.Close() c := newTestClient(t, server) - _, err := c.ListProjects(context.Background()) + _, err := c.ListProjects(context.Background(), nil) require.NoError(t, err) } @@ -118,7 +118,7 @@ func TestClient_APIError_Unauthorized(t *testing.T) { defer server.Close() c := newTestClient(t, server) - _, err := c.ListProjects(context.Background()) + _, err := c.ListProjects(context.Background(), nil) require.Error(t, err) assert.True(t, IsUnauthorized(err)) } @@ -133,10 +133,32 @@ func TestClient_UserAgent(t *testing.T) { c := newTestClient(t, server) c.userAgent = "my-agent" - _, err := c.ListProjects(context.Background()) + _, err := c.ListProjects(context.Background(), nil) require.NoError(t, err) } +func TestAppendListParams(t *testing.T) { + tests := []struct { + name string + path string + opts *ListOptions + expected string + }{ + {"nil opts", "/projects", nil, "/projects"}, + {"page only", "/projects", &ListOptions{Page: 2}, "/projects?page=2"}, + {"per_page only", "/projects", &ListOptions{PerPage: 10}, "/projects?per_page=10"}, + {"both", "/projects", &ListOptions{Page: 3, PerPage: 25}, "/projects?page=3&per_page=25"}, + {"zero values", "/projects", &ListOptions{}, "/projects"}, + {"existing query string", "/templates/public?framework_type=rails", &ListOptions{Page: 2}, "/templates/public?framework_type=rails&page=2"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := appendListParams(tt.path, tt.opts) + assert.Equal(t, tt.expected, result) + }) + } +} + func TestClient_Do_EscapeHatch(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/custom/endpoint", r.URL.Path) diff --git a/pkg/sdk/config_files.go b/pkg/sdk/config_files.go index 085743b..c3938b0 100644 --- a/pkg/sdk/config_files.go +++ b/pkg/sdk/config_files.go @@ -23,9 +23,10 @@ type ConfigFileCreateRequest struct { Build *bool `json:"build,omitempty"` } -func (c *Client) ListConfigFiles(ctx context.Context, projectID string) ([]ConfigFile, error) { +func (c *Client) ListConfigFiles(ctx context.Context, projectID string, opts *ListOptions) ([]ConfigFile, error) { var files []ConfigFile - if err := c.get(ctx, fmt.Sprintf("/projects/%s/config_files", projectID), &files); err != nil { + path := appendListParams(fmt.Sprintf("/projects/%s/config_files", projectID), opts) + if err := c.get(ctx, path, &files); err != nil { return nil, err } return files, nil diff --git a/pkg/sdk/deployments.go b/pkg/sdk/deployments.go index edb3bad..3dfd4f6 100644 --- a/pkg/sdk/deployments.go +++ b/pkg/sdk/deployments.go @@ -7,9 +7,10 @@ import ( // ListDeployments returns deployments for a project. // The API returns a paginated response with {pagination, records}. -func (c *Client) ListDeployments(ctx context.Context, projectID string) (*PaginatedResponse[Deployment], error) { +func (c *Client) ListDeployments(ctx context.Context, projectID string, opts *ListOptions) (*PaginatedResponse[Deployment], error) { var result PaginatedResponse[Deployment] - if err := c.get(ctx, fmt.Sprintf("/projects/%s/deployments", projectID), &result); err != nil { + path := appendListParams(fmt.Sprintf("/projects/%s/deployments", projectID), opts) + if err := c.get(ctx, path, &result); err != nil { return nil, err } return &result, nil @@ -36,6 +37,18 @@ func (c *Client) CreateDeployment(ctx context.Context, projectID string, req Dep return &deployment, nil } +// PreviewDeployment creates a preview deployment without executing. +// Returns the preview status and identifier. +func (c *Client) PreviewDeployment(ctx context.Context, projectID string, req DeploymentCreateRequest) (*DeploymentPreview, error) { + req.Mode = "preview" + body := struct { + Deployment DeploymentCreateRequest `json:"deployment"` + }{Deployment: req} + var preview DeploymentPreview + err := c.post(ctx, fmt.Sprintf("/projects/%s/deployments", projectID), body, &preview) + return &preview, err +} + // AbortDeployment aborts a running deployment. func (c *Client) AbortDeployment(ctx context.Context, projectID, deploymentID string) error { return c.post(ctx, fmt.Sprintf("/projects/%s/deployments/%s/abort", projectID, deploymentID), nil, nil) diff --git a/pkg/sdk/deployments_test.go b/pkg/sdk/deployments_test.go index 5f512c2..3fcec3a 100644 --- a/pkg/sdk/deployments_test.go +++ b/pkg/sdk/deployments_test.go @@ -15,7 +15,7 @@ func TestListDeployments(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/projects/my-app/deployments", r.URL.Path) _ = json.NewEncoder(w).Encode(PaginatedResponse[Deployment]{ - Pagination: Pagination{CurrentPage: 1, TotalPages: 1, TotalCount: 2, PerPage: 20}, + Pagination: Pagination{CurrentPage: 1, TotalPages: 1, TotalRecords: 2, Offset: 0}, Records: []Deployment{ {Identifier: "dep1", Status: "completed", Branch: "main"}, {Identifier: "dep2", Status: "running", Branch: "develop"}, @@ -25,7 +25,7 @@ func TestListDeployments(t *testing.T) { defer server.Close() c := newTestClient(t, server) - result, err := c.ListDeployments(context.Background(), "my-app") + result, err := c.ListDeployments(context.Background(), "my-app", nil) require.NoError(t, err) assert.Len(t, result.Records, 2) assert.Equal(t, 1, result.Pagination.CurrentPage) diff --git a/pkg/sdk/env_vars.go b/pkg/sdk/env_vars.go index 42a9d46..c2742f1 100644 --- a/pkg/sdk/env_vars.go +++ b/pkg/sdk/env_vars.go @@ -26,9 +26,10 @@ type EnvVarCreateRequest struct { // Project-scoped env vars -func (c *Client) ListEnvVars(ctx context.Context, projectID string) ([]EnvVar, error) { +func (c *Client) ListEnvVars(ctx context.Context, projectID string, opts *ListOptions) ([]EnvVar, error) { var vars []EnvVar - if err := c.get(ctx, fmt.Sprintf("/projects/%s/environment_variables", projectID), &vars); err != nil { + path := appendListParams(fmt.Sprintf("/projects/%s/environment_variables", projectID), opts) + if err := c.get(ctx, path, &vars); err != nil { return nil, err } return vars, nil @@ -70,9 +71,10 @@ func (c *Client) DeleteEnvVar(ctx context.Context, projectID, varID string) erro // Global env vars -func (c *Client) ListGlobalEnvVars(ctx context.Context) ([]EnvVar, error) { +func (c *Client) ListGlobalEnvVars(ctx context.Context, opts *ListOptions) ([]EnvVar, error) { var vars []EnvVar - if err := c.get(ctx, "/global_environment_variables", &vars); err != nil { + path := appendListParams("/global_environment_variables", opts) + if err := c.get(ctx, path, &vars); err != nil { return nil, err } return vars, nil diff --git a/pkg/sdk/excluded_files.go b/pkg/sdk/excluded_files.go index 149a386..103865d 100644 --- a/pkg/sdk/excluded_files.go +++ b/pkg/sdk/excluded_files.go @@ -17,9 +17,10 @@ type ExcludedFileCreateRequest struct { Path string `json:"path"` } -func (c *Client) ListExcludedFiles(ctx context.Context, projectID string) ([]ExcludedFile, error) { +func (c *Client) ListExcludedFiles(ctx context.Context, projectID string, opts *ListOptions) ([]ExcludedFile, error) { var files []ExcludedFile - if err := c.get(ctx, fmt.Sprintf("/projects/%s/excluded_files", projectID), &files); err != nil { + path := appendListParams(fmt.Sprintf("/projects/%s/excluded_files", projectID), opts) + if err := c.get(ctx, path, &files); err != nil { return nil, err } return files, nil diff --git a/pkg/sdk/global_servers.go b/pkg/sdk/global_servers.go index 4cae554..cbe9ca8 100644 --- a/pkg/sdk/global_servers.go +++ b/pkg/sdk/global_servers.go @@ -8,9 +8,10 @@ import ( // GlobalServer is the same as Server but at account level. // Reuses the Server type. -func (c *Client) ListGlobalServers(ctx context.Context) ([]Server, error) { +func (c *Client) ListGlobalServers(ctx context.Context, opts *ListOptions) ([]Server, error) { var servers []Server - if err := c.get(ctx, "/global_servers", &servers); err != nil { + path := appendListParams("/global_servers", opts) + if err := c.get(ctx, path, &servers); err != nil { return nil, err } return servers, nil diff --git a/pkg/sdk/integrations.go b/pkg/sdk/integrations.go index a6bf956..1e346c3 100644 --- a/pkg/sdk/integrations.go +++ b/pkg/sdk/integrations.go @@ -26,9 +26,10 @@ type IntegrationCreateRequest struct { SendOnFailure *bool `json:"send_on_failure,omitempty"` } -func (c *Client) ListIntegrations(ctx context.Context, projectID string) ([]Integration, error) { +func (c *Client) ListIntegrations(ctx context.Context, projectID string, opts *ListOptions) ([]Integration, error) { var integrations []Integration - if err := c.get(ctx, fmt.Sprintf("/projects/%s/integrations", projectID), &integrations); err != nil { + path := appendListParams(fmt.Sprintf("/projects/%s/integrations", projectID), opts) + if err := c.get(ctx, path, &integrations); err != nil { return nil, err } return integrations, nil diff --git a/pkg/sdk/language_versions.go b/pkg/sdk/language_versions.go index 852f7a6..d74e8e9 100644 --- a/pkg/sdk/language_versions.go +++ b/pkg/sdk/language_versions.go @@ -7,9 +7,10 @@ import ( // ListLanguageVersions returns the available language versions for a project's // build server. The result maps language names to their available versions. -func (c *Client) ListLanguageVersions(ctx context.Context, projectID string) (map[string][]string, error) { +func (c *Client) ListLanguageVersions(ctx context.Context, projectID string, opts *ListOptions) (map[string][]string, error) { var result map[string][]string - if err := c.get(ctx, fmt.Sprintf("/projects/%s/language_versions", projectID), &result); err != nil { + path := appendListParams(fmt.Sprintf("/projects/%s/language_versions", projectID), opts) + if err := c.get(ctx, path, &result); err != nil { return nil, err } return result, nil diff --git a/pkg/sdk/projects.go b/pkg/sdk/projects.go index 059abb5..8e539f3 100644 --- a/pkg/sdk/projects.go +++ b/pkg/sdk/projects.go @@ -6,9 +6,10 @@ import ( ) // ListProjects returns all projects for the account. -func (c *Client) ListProjects(ctx context.Context) ([]Project, error) { +func (c *Client) ListProjects(ctx context.Context, opts *ListOptions) ([]Project, error) { var projects []Project - if err := c.get(ctx, "/projects", &projects); err != nil { + path := appendListParams("/projects", opts) + if err := c.get(ctx, path, &projects); err != nil { return nil, err } return projects, nil diff --git a/pkg/sdk/projects_test.go b/pkg/sdk/projects_test.go index 87cb2a7..6048383 100644 --- a/pkg/sdk/projects_test.go +++ b/pkg/sdk/projects_test.go @@ -23,7 +23,7 @@ func TestListProjects(t *testing.T) { defer server.Close() c := newTestClient(t, server) - projects, err := c.ListProjects(context.Background()) + projects, err := c.ListProjects(context.Background(), nil) require.NoError(t, err) assert.Len(t, projects, 2) assert.Equal(t, "My App", projects[0].Name) diff --git a/pkg/sdk/repositories.go b/pkg/sdk/repositories.go index 79b3cd1..deda386 100644 --- a/pkg/sdk/repositories.go +++ b/pkg/sdk/repositories.go @@ -40,9 +40,10 @@ func (c *Client) UpdateRepository(ctx context.Context, projectID string, req Rep // ListBranches returns the branches for a project's repository. // The API returns a map of branch_name -> latest_commit_sha. -func (c *Client) ListBranches(ctx context.Context, projectID string) (map[string]string, error) { +func (c *Client) ListBranches(ctx context.Context, projectID string, opts *ListOptions) (map[string]string, error) { var branches map[string]string - if err := c.get(ctx, fmt.Sprintf("/projects/%s/repository/branches", projectID), &branches); err != nil { + path := appendListParams(fmt.Sprintf("/projects/%s/repository/branches", projectID), opts) + if err := c.get(ctx, path, &branches); err != nil { return nil, err } return branches, nil @@ -60,9 +61,10 @@ func (c *Client) GetLatestRevision(ctx context.Context, projectID string) (strin } // ListRecentCommits returns recent commits for a project's repository. -func (c *Client) ListRecentCommits(ctx context.Context, projectID string) (*CommitsTagsReleases, error) { +func (c *Client) ListRecentCommits(ctx context.Context, projectID string, opts *ListOptions) (*CommitsTagsReleases, error) { var result CommitsTagsReleases - if err := c.get(ctx, fmt.Sprintf("/projects/%s/repository/recent_commits", projectID), &result); err != nil { + path := appendListParams(fmt.Sprintf("/projects/%s/repository/recent_commits", projectID), opts) + if err := c.get(ctx, path, &result); err != nil { return nil, err } return &result, nil diff --git a/pkg/sdk/repositories_test.go b/pkg/sdk/repositories_test.go index 472ced7..e289c2d 100644 --- a/pkg/sdk/repositories_test.go +++ b/pkg/sdk/repositories_test.go @@ -68,7 +68,7 @@ func TestListBranches(t *testing.T) { defer server.Close() c := newTestClient(t, server) - branches, err := c.ListBranches(context.Background(), "my-app") + branches, err := c.ListBranches(context.Background(), "my-app", nil) require.NoError(t, err) assert.Len(t, branches, 3) assert.Contains(t, branches, "main") @@ -102,7 +102,7 @@ func TestListRecentCommits(t *testing.T) { defer server.Close() c := newTestClient(t, server) - result, err := c.ListRecentCommits(context.Background(), "my-app") + result, err := c.ListRecentCommits(context.Background(), "my-app", nil) require.NoError(t, err) assert.Len(t, result.Commits, 1) assert.Equal(t, "abc123", result.Commits[0].Ref) diff --git a/pkg/sdk/scheduled_deployments.go b/pkg/sdk/scheduled_deployments.go index 29d6ac0..2c40a25 100644 --- a/pkg/sdk/scheduled_deployments.go +++ b/pkg/sdk/scheduled_deployments.go @@ -19,9 +19,10 @@ type ScheduledDeployment struct { UseBuildCache bool `json:"use_build_cache"` } -func (c *Client) ListScheduledDeployments(ctx context.Context, projectID string) ([]ScheduledDeployment, error) { +func (c *Client) ListScheduledDeployments(ctx context.Context, projectID string, opts *ListOptions) ([]ScheduledDeployment, error) { var result []ScheduledDeployment - if err := c.get(ctx, fmt.Sprintf("/projects/%s/scheduled_deployments", projectID), &result); err != nil { + path := appendListParams(fmt.Sprintf("/projects/%s/scheduled_deployments", projectID), opts) + if err := c.get(ctx, path, &result); err != nil { return nil, err } return result, nil diff --git a/pkg/sdk/server_groups.go b/pkg/sdk/server_groups.go index e3e5790..01b747a 100644 --- a/pkg/sdk/server_groups.go +++ b/pkg/sdk/server_groups.go @@ -6,9 +6,10 @@ import ( ) // ListServerGroups returns all server groups for a project. -func (c *Client) ListServerGroups(ctx context.Context, projectID string) ([]ServerGroup, error) { +func (c *Client) ListServerGroups(ctx context.Context, projectID string, opts *ListOptions) ([]ServerGroup, error) { var groups []ServerGroup - if err := c.get(ctx, fmt.Sprintf("/projects/%s/server_groups", projectID), &groups); err != nil { + path := appendListParams(fmt.Sprintf("/projects/%s/server_groups", projectID), opts) + if err := c.get(ctx, path, &groups); err != nil { return nil, err } return groups, nil diff --git a/pkg/sdk/server_groups_test.go b/pkg/sdk/server_groups_test.go index 694d387..b10d5ef 100644 --- a/pkg/sdk/server_groups_test.go +++ b/pkg/sdk/server_groups_test.go @@ -21,7 +21,7 @@ func TestListServerGroups(t *testing.T) { defer server.Close() c := newTestClient(t, server) - groups, err := c.ListServerGroups(context.Background(), "my-app") + groups, err := c.ListServerGroups(context.Background(), "my-app", nil) require.NoError(t, err) assert.Len(t, groups, 1) assert.Equal(t, "Production", groups[0].Name) diff --git a/pkg/sdk/servers.go b/pkg/sdk/servers.go index 7ee5fa2..fc37ce8 100644 --- a/pkg/sdk/servers.go +++ b/pkg/sdk/servers.go @@ -6,9 +6,10 @@ import ( ) // ListServers returns all servers for a project. -func (c *Client) ListServers(ctx context.Context, projectID string) ([]Server, error) { +func (c *Client) ListServers(ctx context.Context, projectID string, opts *ListOptions) ([]Server, error) { var servers []Server - if err := c.get(ctx, fmt.Sprintf("/projects/%s/servers", projectID), &servers); err != nil { + path := appendListParams(fmt.Sprintf("/projects/%s/servers", projectID), opts) + if err := c.get(ctx, path, &servers); err != nil { return nil, err } return servers, nil diff --git a/pkg/sdk/servers_test.go b/pkg/sdk/servers_test.go index f0571f3..4e2bc78 100644 --- a/pkg/sdk/servers_test.go +++ b/pkg/sdk/servers_test.go @@ -23,7 +23,7 @@ func TestListServers(t *testing.T) { defer server.Close() c := newTestClient(t, server) - servers, err := c.ListServers(context.Background(), "my-app") + servers, err := c.ListServers(context.Background(), "my-app", nil) require.NoError(t, err) assert.Len(t, servers, 2) assert.Equal(t, "Production", servers[0].Name) diff --git a/pkg/sdk/ssh_commands.go b/pkg/sdk/ssh_commands.go index c726346..a7cda47 100644 --- a/pkg/sdk/ssh_commands.go +++ b/pkg/sdk/ssh_commands.go @@ -29,9 +29,10 @@ type SSHCommandCreateRequest struct { Enabled *bool `json:"enabled,omitempty"` } -func (c *Client) ListSSHCommands(ctx context.Context, projectID string) ([]SSHCommand, error) { +func (c *Client) ListSSHCommands(ctx context.Context, projectID string, opts *ListOptions) ([]SSHCommand, error) { var cmds []SSHCommand - if err := c.get(ctx, fmt.Sprintf("/projects/%s/commands", projectID), &cmds); err != nil { + path := appendListParams(fmt.Sprintf("/projects/%s/commands", projectID), opts) + if err := c.get(ctx, path, &cmds); err != nil { return nil, err } return cmds, nil diff --git a/pkg/sdk/ssh_keys.go b/pkg/sdk/ssh_keys.go index fcba66b..9182be1 100644 --- a/pkg/sdk/ssh_keys.go +++ b/pkg/sdk/ssh_keys.go @@ -21,9 +21,10 @@ type SSHKeyCreateRequest struct { KeyType string `json:"key_type,omitempty"` } -func (c *Client) ListSSHKeys(ctx context.Context) ([]SSHKey, error) { +func (c *Client) ListSSHKeys(ctx context.Context, opts *ListOptions) ([]SSHKey, error) { var keys []SSHKey - if err := c.get(ctx, "/ssh_keys", &keys); err != nil { + path := appendListParams("/ssh_keys", opts) + if err := c.get(ctx, path, &keys); err != nil { return nil, err } return keys, nil diff --git a/pkg/sdk/templates.go b/pkg/sdk/templates.go index 7b1cec7..e59a24d 100644 --- a/pkg/sdk/templates.go +++ b/pkg/sdk/templates.go @@ -28,20 +28,22 @@ type TemplateUpdateRequest struct { } // ListTemplates returns all private templates for the account. -func (c *Client) ListTemplates(ctx context.Context) ([]Template, error) { +func (c *Client) ListTemplates(ctx context.Context, opts *ListOptions) ([]Template, error) { var templates []Template - if err := c.get(ctx, "/templates", &templates); err != nil { + path := appendListParams("/templates", opts) + if err := c.get(ctx, path, &templates); err != nil { return nil, err } return templates, nil } // ListPublicTemplates returns publicly available templates, optionally filtered by framework type. -func (c *Client) ListPublicTemplates(ctx context.Context, frameworkType string) ([]Template, error) { +func (c *Client) ListPublicTemplates(ctx context.Context, frameworkType string, opts *ListOptions) ([]Template, error) { path := "/templates/public_templates" if frameworkType != "" { path += "?framework_type=" + frameworkType } + path = appendListParams(path, opts) var templates []Template if err := c.get(ctx, path, &templates); err != nil { return nil, err diff --git a/pkg/sdk/templates_test.go b/pkg/sdk/templates_test.go index bf3609e..3d4afee 100644 --- a/pkg/sdk/templates_test.go +++ b/pkg/sdk/templates_test.go @@ -22,7 +22,7 @@ func TestListTemplates(t *testing.T) { defer server.Close() c := newTestClient(t, server) - templates, err := c.ListTemplates(context.Background()) + templates, err := c.ListTemplates(context.Background(), nil) require.NoError(t, err) assert.Len(t, templates, 2) assert.Equal(t, "Rails", templates[0].Name) @@ -55,7 +55,7 @@ func TestListPublicTemplates(t *testing.T) { defer server.Close() c := newTestClient(t, server) - templates, err := c.ListPublicTemplates(context.Background(), "") + templates, err := c.ListPublicTemplates(context.Background(), "", nil) require.NoError(t, err) assert.Len(t, templates, 1) assert.Equal(t, "WordPress", templates[0].Name) @@ -88,7 +88,7 @@ func TestListPublicTemplates_WithFilter(t *testing.T) { defer server.Close() c := newTestClient(t, server) - templates, err := c.ListPublicTemplates(context.Background(), "cms") + templates, err := c.ListPublicTemplates(context.Background(), "cms", nil) require.NoError(t, err) assert.Len(t, templates, 1) } diff --git a/pkg/sdk/types.go b/pkg/sdk/types.go index 7dbc6f4..e8175cb 100644 --- a/pkg/sdk/types.go +++ b/pkg/sdk/types.go @@ -69,38 +69,38 @@ type ProjectUpdateRequest struct { // Server represents a deployment server. type Server struct { - ID int `json:"id"` - Identifier string `json:"identifier"` - Name string `json:"name"` - ProtocolType string `json:"protocol_type"` - ServerPath string `json:"server_path"` - LastRevision string `json:"last_revision"` - PreferredBranch string `json:"preferred_branch"` - Branch string `json:"branch"` - NotifyEmail string `json:"notify_email"` - ServerGroupIdentifier *string `json:"server_group_identifier,omitempty"` - AutoDeploy bool `json:"auto_deploy"` - Environment string `json:"environment"` - Enabled bool `json:"enabled"` - Agent *ServerAgent `json:"agent,omitempty"` - Atomic *bool `json:"atomic,omitempty"` - AtomicStrategy string `json:"atomic_strategy"` - AtomicRetention int `json:"atomic_retention"` - UseCompression bool `json:"use_compression"` - UseAcceleratedTransfer bool `json:"use_accelerated_transfer"` - UseParallelUpload bool `json:"use_parallel_upload"` - RootPath string `json:"root_path"` - Position int `json:"position"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` - ConnectionCheckedAt *string `json:"connection_checked_at,omitempty"` - ConnectionErrorMessage *string `json:"connection_error_message,omitempty"` - Hostname string `json:"hostname,omitempty"` - Username string `json:"username,omitempty"` - Port FlexString `json:"port,omitempty"` - UseSSHKeys bool `json:"use_ssh_keys,omitempty"` - HostKey string `json:"host_key,omitempty"` - UnlinkBeforeUpload bool `json:"unlink_before_upload,omitempty"` + ID int `json:"id"` + Identifier string `json:"identifier"` + Name string `json:"name"` + ProtocolType string `json:"protocol_type"` + ServerPath string `json:"server_path"` + LastRevision string `json:"last_revision"` + PreferredBranch string `json:"preferred_branch"` + Branch string `json:"branch"` + NotifyEmail string `json:"notify_email"` + ServerGroupIdentifier *string `json:"server_group_identifier,omitempty"` + AutoDeploy bool `json:"auto_deploy"` + Environment string `json:"environment"` + Enabled bool `json:"enabled"` + Agent *ServerAgent `json:"agent,omitempty"` + Atomic *bool `json:"atomic,omitempty"` + AtomicStrategy string `json:"atomic_strategy"` + AtomicRetention int `json:"atomic_retention"` + UseCompression bool `json:"use_compression"` + UseAcceleratedTransfer bool `json:"use_accelerated_transfer"` + UseParallelUpload bool `json:"use_parallel_upload"` + RootPath string `json:"root_path"` + Position int `json:"position"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + ConnectionCheckedAt *string `json:"connection_checked_at,omitempty"` + ConnectionErrorMessage *string `json:"connection_error_message,omitempty"` + Hostname string `json:"hostname,omitempty"` + Username string `json:"username,omitempty"` + Port FlexString `json:"port,omitempty"` + UseSSHKeys bool `json:"use_ssh_keys,omitempty"` + HostKey string `json:"host_key,omitempty"` + UnlinkBeforeUpload bool `json:"unlink_before_upload,omitempty"` } // ServerCreateRequest is the payload for creating a server. @@ -127,7 +127,7 @@ type ServerCreateRequest struct { // S3 BucketName string `json:"bucket_name,omitempty"` AccessKeyID string `json:"access_key_id,omitempty"` - SecretAccessKey string `json:"secret_access_key,omitempty"` + SecretAccessKey string `json:"secret_access_key,omitempty"` // S3-Compatible CustomEndpoint string `json:"custom_endpoint,omitempty"` @@ -186,49 +186,49 @@ type ServerGroupUpdateRequest struct { // ServerAgent is the embedded agent object within a server response. // The API returns the full agent object, not just an identifier. type ServerAgent struct { - ID int `json:"id"` - CreatedAt string `json:"created_at"` - Identifier string `json:"identifier"` - Name string `json:"name"` - Online bool `json:"online"` - RevokedAt *string `json:"revoked_at,omitempty"` - UpdatedAt string `json:"updated_at"` + ID int `json:"id"` + CreatedAt string `json:"created_at"` + Identifier string `json:"identifier"` + Name string `json:"name"` + Online bool `json:"online"` + RevokedAt *string `json:"revoked_at,omitempty"` + UpdatedAt string `json:"updated_at"` } // Deployment represents a deployment. type Deployment struct { - Identifier string `json:"identifier"` - Servers []Server `json:"servers"` - Project *DeploymentProject `json:"project,omitempty"` - Deployer *string `json:"deployer,omitempty"` - DeployerAvatar *string `json:"deployer_avatar,omitempty"` - Branch string `json:"branch"` - StartRevision *Revision `json:"start_revision,omitempty"` - EndRevision *Revision `json:"end_revision,omitempty"` - Status string `json:"status"` - Timestamps *Timestamps `json:"timestamps,omitempty"` - Configuration *DeployConfig `json:"configuration,omitempty"` - Legacy bool `json:"legacy"` - Deferred bool `json:"deferred"` - ConfigFilesDeployment bool `json:"config_files_deployment"` - Overview *string `json:"overview,omitempty"` - Archived bool `json:"archived"` - ArchivedAt *string `json:"archived_at,omitempty"` - LogSummary *string `json:"log_summary,omitempty"` - Steps []DeploymentStep `json:"steps,omitempty"` + Identifier string `json:"identifier"` + Servers []Server `json:"servers"` + Project *DeploymentProject `json:"project,omitempty"` + Deployer *string `json:"deployer,omitempty"` + DeployerAvatar *string `json:"deployer_avatar,omitempty"` + Branch string `json:"branch"` + StartRevision *Revision `json:"start_revision,omitempty"` + EndRevision *Revision `json:"end_revision,omitempty"` + Status string `json:"status"` + Timestamps *Timestamps `json:"timestamps,omitempty"` + Configuration *DeployConfig `json:"configuration,omitempty"` + Legacy bool `json:"legacy"` + Deferred bool `json:"deferred"` + ConfigFilesDeployment bool `json:"config_files_deployment"` + Overview *string `json:"overview,omitempty"` + Archived bool `json:"archived"` + ArchivedAt *string `json:"archived_at,omitempty"` + LogSummary *string `json:"log_summary,omitempty"` + Steps []DeploymentStep `json:"steps,omitempty"` } // DeploymentProject is the embedded project info within a deployment response. type DeploymentProject struct { - Name string `json:"name"` - Permalink string `json:"permalink"` - Identifier string `json:"identifier"` - PublicKey string `json:"public_key"` - Repository *Repository `json:"repository,omitempty"` - RepositoryURL string `json:"repository_url"` - Zone string `json:"zone"` - LastDeployedAt string `json:"last_deployed_at"` - AutoDeployURL string `json:"auto_deploy_url"` + Name string `json:"name"` + Permalink string `json:"permalink"` + Identifier string `json:"identifier"` + PublicKey string `json:"public_key"` + Repository *Repository `json:"repository,omitempty"` + RepositoryURL string `json:"repository_url"` + Zone string `json:"zone"` + LastDeployedAt string `json:"last_deployed_at"` + AutoDeployURL string `json:"auto_deploy_url"` } // Revision holds a git revision reference. @@ -269,18 +269,25 @@ type DeploymentStep struct { // DeploymentCreateRequest is the payload for creating a deployment. type DeploymentCreateRequest struct { - StartRevision string `json:"start_revision,omitempty"` - EndRevision string `json:"end_revision,omitempty"` - CopyConfigFiles *bool `json:"copy_config_files,omitempty"` + StartRevision string `json:"start_revision,omitempty"` + EndRevision string `json:"end_revision,omitempty"` + CopyConfigFiles *bool `json:"copy_config_files,omitempty"` NotificationAddresses string `json:"notification_addresses,omitempty"` - Branch string `json:"branch,omitempty"` - ParentIdentifier string `json:"parent_identifier,omitempty"` - ServerIdentifier string `json:"server_identifier,omitempty"` - RunBuildCommands *bool `json:"run_build_commands,omitempty"` - UseBuildCache *bool `json:"use_build_cache,omitempty"` - ConfigFilesDeployment *bool `json:"config_files_deployment,omitempty"` - Mode string `json:"mode,omitempty"` - UseLatest *bool `json:"use_latest,omitempty"` + Branch string `json:"branch,omitempty"` + ParentIdentifier string `json:"parent_identifier,omitempty"` + ServerIdentifier string `json:"server_identifier,omitempty"` + RunBuildCommands *bool `json:"run_build_commands,omitempty"` + UseBuildCache *bool `json:"use_build_cache,omitempty"` + ConfigFilesDeployment *bool `json:"config_files_deployment,omitempty"` + Mode string `json:"mode,omitempty"` + UseLatest *bool `json:"use_latest,omitempty"` +} + +// DeploymentPreview is the minimal response from a preview deployment. +// The API returns only status and identifier for preview mode. +type DeploymentPreview struct { + Status string `json:"status"` + Identifier string `json:"identifier"` } // Repository represents a project's repository configuration. @@ -333,19 +340,19 @@ type CommitsTagsReleases struct { // DeploymentStepLog represents logs for a deployment step. type DeploymentStepLog struct { - ID FlexString `json:"id"` - Step string `json:"step"` - Type string `json:"type,omitempty"` - Detail *string `json:"detail,omitempty"` - Message string `json:"message"` + ID FlexString `json:"id"` + Step string `json:"step"` + Type string `json:"type,omitempty"` + Detail *string `json:"detail,omitempty"` + Message string `json:"message"` } // Pagination holds pagination metadata from list responses. type Pagination struct { - CurrentPage int `json:"current_page"` - TotalPages int `json:"total_pages"` - TotalCount int `json:"total_count"` - PerPage int `json:"per_page"` + CurrentPage int `json:"current_page"` + TotalPages int `json:"total_pages"` + TotalRecords int `json:"total_records"` + Offset int `json:"offset"` } // PaginatedResponse wraps a list response with pagination info. @@ -353,3 +360,9 @@ type PaginatedResponse[T any] struct { Pagination Pagination `json:"pagination"` Records []T `json:"records"` } + +// ListOptions provides optional pagination parameters for list endpoints. +type ListOptions struct { + Page int + PerPage int +} diff --git a/pkg/sdk/zones.go b/pkg/sdk/zones.go index 499d4cb..2fe336c 100644 --- a/pkg/sdk/zones.go +++ b/pkg/sdk/zones.go @@ -8,9 +8,10 @@ type Zone struct { Description string `json:"description"` } -func (c *Client) ListZones(ctx context.Context) ([]Zone, error) { +func (c *Client) ListZones(ctx context.Context, opts *ListOptions) ([]Zone, error) { var zones []Zone - if err := c.get(ctx, "/zones", &zones); err != nil { + path := appendListParams("/zones", opts) + if err := c.get(ctx, path, &zones); err != nil { return nil, err } return zones, nil From df306f725902d5ef884ee9823800e86dbfbbe93c Mon Sep 17 00:00:00 2001 From: Marta Date: Thu, 9 Apr 2026 12:57:23 +0200 Subject: [PATCH 2/5] feat: add --dry-run flag to deploy and --page/--per-page to list commands Dry-run: dhq deploy --dry-run creates a preview deployment via the API (mode: "preview") without executing. Returns preview_pending status. Mutually exclusive with --wait. Pagination: --page and --per-page flags on deployments list, servers list, and server-groups list (the three endpoints that support server-side pagination). JSON output includes pagination metadata via new NewPaginatedResponse envelope. TTY output shows page footer when multiple pages exist. --- internal/commands/deploy.go | 41 ++++++++++++++++++++++++-- internal/commands/deploy_test.go | 22 ++++++++++++++ internal/commands/deployments.go | 24 +++++++++++++-- internal/commands/pagination.go | 18 ++++++++++++ internal/commands/pagination_test.go | 44 ++++++++++++++++++++++++++++ internal/commands/server_groups.go | 9 ++++-- internal/commands/servers.go | 11 +++++-- internal/output/breadcrumbs.go | 21 +++++++++++++ internal/output/breadcrumbs_test.go | 37 +++++++++++++++++++++++ 9 files changed, 216 insertions(+), 11 deletions(-) create mode 100644 internal/commands/deploy_test.go create mode 100644 internal/commands/pagination.go create mode 100644 internal/commands/pagination_test.go diff --git a/internal/commands/deploy.go b/internal/commands/deploy.go index 5f4bf33..d7d438b 100644 --- a/internal/commands/deploy.go +++ b/internal/commands/deploy.go @@ -72,7 +72,7 @@ func normalize(s string) string { func newDeployCmd() *cobra.Command { var branch, server, revision string - var wait bool + var dryRun, wait bool var timeout int cmd := &cobra.Command{ @@ -92,9 +92,13 @@ func newDeployCmd() *cobra.Command { env := cliCtx.Envelope + if dryRun && wait { + return &output.UserError{Message: "--dry-run and --wait are mutually exclusive"} + } + // Auto-select server if not specified if server == "" { - servers, err := client.ListServers(cliCtx.Background(), projectID) + servers, err := client.ListServers(cliCtx.Background(), projectID, nil) if err == nil && len(servers) == 1 { server = servers[0].Identifier env.Status("Auto-selected server: %s", servers[0].Name) @@ -126,7 +130,7 @@ func newDeployCmd() *cobra.Command { // Resolve server name to identifier if needed if server != "" && !isUUID(server) { - servers, err := client.ListServers(cliCtx.Background(), projectID) + servers, err := client.ListServers(cliCtx.Background(), projectID, nil) if err == nil { resolved, candidates := resolveServerName(server, servers) if resolved != "" { @@ -173,6 +177,28 @@ func newDeployCmd() *cobra.Command { ParentIdentifier: server, } + if dryRun { + preview, err := client.PreviewDeployment(cliCtx.Background(), projectID, req) + if err != nil { + return err + } + + if env.JSONMode || !env.IsTTY { + return env.WriteJSON(output.NewResponse(preview, + fmt.Sprintf("Preview %s created (status: %s)", preview.Identifier, preview.Status), + output.Breadcrumb{Action: "execute", Cmd: deployExecuteCmd(projectID, server, branch)}, + )) + } + + env.Status("DRY RUN — preview created, deployment will NOT execute\n") + env.Status(" Identifier: %s", preview.Identifier) + env.Status(" Status: %s", output.ColorStatus(preview.Status)) + env.Status("\nThe preview will be processed asynchronously.") + env.Status("Preview deployments are excluded from 'dhq deployments list'.") + env.Status("\nUse 'dhq deploy' (without --dry-run) to execute.") + return nil + } + dep, err := client.CreateDeployment(cliCtx.Background(), projectID, req) if err != nil { return err @@ -219,11 +245,20 @@ func newDeployCmd() *cobra.Command { cmd.Flags().StringVarP(&branch, "branch", "b", "", "Branch to deploy") cmd.Flags().StringVarP(&server, "server", "s", "", "Server or group identifier") cmd.Flags().StringVarP(&revision, "revision", "r", "", "End revision (default: latest)") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Preview what would be deployed without executing") cmd.Flags().BoolVarP(&wait, "wait", "w", false, "Wait for deployment to complete") cmd.Flags().IntVar(&timeout, "timeout", 0, "Timeout in seconds when using --wait (0 = no timeout)") return cmd } +func deployExecuteCmd(projectID, server, branch string) string { + cmd := fmt.Sprintf("dhq deploy -p %s -s %s", projectID, server) + if branch != "" { + cmd += " -b " + branch + } + return cmd +} + func newRetryCmd() *cobra.Command { return &cobra.Command{ Use: "retry ", diff --git a/internal/commands/deploy_test.go b/internal/commands/deploy_test.go new file mode 100644 index 0000000..6a76d5e --- /dev/null +++ b/internal/commands/deploy_test.go @@ -0,0 +1,22 @@ +package commands + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestDeployDryRunFlag(t *testing.T) { + cmd := NewRootCmd("test") + deployCmd, _, _ := cmd.Find([]string{"deploy"}) + assert.NotNil(t, deployCmd) + assert.NotNil(t, deployCmd.Flags().Lookup("dry-run")) +} + +func TestDeployDryRunMutuallyExclusiveWithWait(t *testing.T) { + cmd := NewRootCmd("test") + cmd.SetArgs([]string{"deploy", "--dry-run", "--wait", "-p", "test-project"}) + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), "mutually exclusive") +} diff --git a/internal/commands/deployments.go b/internal/commands/deployments.go index fff7b7f..aa1d0e8 100644 --- a/internal/commands/deployments.go +++ b/internal/commands/deployments.go @@ -30,7 +30,9 @@ func newDeploymentsCmd() *cobra.Command { } func newDeploymentsListCmd() *cobra.Command { - return &cobra.Command{ + var page, perPage int + + cmd := &cobra.Command{ Use: "list", Short: "List deployments", RunE: func(cmd *cobra.Command, args []string) error { @@ -44,14 +46,20 @@ func newDeploymentsListCmd() *cobra.Command { return err } - result, err := client.ListDeployments(cliCtx.Background(), projectID) + result, err := client.ListDeployments(cliCtx.Background(), projectID, listOptsFromFlags(page, perPage)) if err != nil { return err } env := cliCtx.Envelope if env.JSONMode || !env.IsTTY { - return env.WriteJSON(output.NewResponse(result, + return env.WriteJSON(output.NewPaginatedResponse(result.Records, + output.Pagination{ + CurrentPage: result.Pagination.CurrentPage, + TotalPages: result.Pagination.TotalPages, + TotalRecords: result.Pagination.TotalRecords, + Offset: result.Pagination.Offset, + }, fmt.Sprintf("%d deployments (page %d/%d)", len(result.Records), result.Pagination.CurrentPage, result.Pagination.TotalPages), output.Breadcrumb{Action: "show", Cmd: fmt.Sprintf("dhq deployments show -p %s", projectID)}, output.Breadcrumb{Action: "deploy", Cmd: fmt.Sprintf("dhq deploy -p %s", projectID)}, @@ -73,12 +81,22 @@ func newDeploymentsListCmd() *cobra.Command { } env.WriteTable(columns, rows) + if result.Pagination.TotalPages > 1 { + env.Status("\nPage %d/%d (%d total) — use --page to navigate", + result.Pagination.CurrentPage, + result.Pagination.TotalPages, + result.Pagination.TotalRecords) + } + if len(result.Records) > 0 { env.Status("\nTip: dhq deployments show %s -p %s", result.Records[0].Identifier, projectID) } return nil }, } + + addPaginationFlags(cmd, &page, &perPage) + return cmd } func newDeploymentsShowCmd() *cobra.Command { diff --git a/internal/commands/pagination.go b/internal/commands/pagination.go new file mode 100644 index 0000000..cf64901 --- /dev/null +++ b/internal/commands/pagination.go @@ -0,0 +1,18 @@ +package commands + +import ( + "github.com/deployhq/deployhq-cli/pkg/sdk" + "github.com/spf13/cobra" +) + +func addPaginationFlags(cmd *cobra.Command, page *int, perPage *int) { + cmd.Flags().IntVar(page, "page", 0, "Page number (0 = API default)") + cmd.Flags().IntVar(perPage, "per-page", 0, "Results per page (0 = API default)") +} + +func listOptsFromFlags(page, perPage int) *sdk.ListOptions { + if page == 0 && perPage == 0 { + return nil + } + return &sdk.ListOptions{Page: page, PerPage: perPage} +} diff --git a/internal/commands/pagination_test.go b/internal/commands/pagination_test.go new file mode 100644 index 0000000..7ec5fbf --- /dev/null +++ b/internal/commands/pagination_test.go @@ -0,0 +1,44 @@ +package commands + +import ( + "testing" + + "github.com/deployhq/deployhq-cli/pkg/sdk" + "github.com/stretchr/testify/assert" +) + +func TestListOptsFromFlags(t *testing.T) { + assert.Nil(t, listOptsFromFlags(0, 0)) + assert.Equal(t, &sdk.ListOptions{Page: 2}, listOptsFromFlags(2, 0)) + assert.Equal(t, &sdk.ListOptions{PerPage: 10}, listOptsFromFlags(0, 10)) + assert.Equal(t, &sdk.ListOptions{Page: 2, PerPage: 10}, listOptsFromFlags(2, 10)) +} + +func TestDeploymentsListPaginationFlags(t *testing.T) { + cmd := NewRootCmd("test") + cmd.SetArgs([]string{"deployments", "list", "--help"}) + err := cmd.Execute() + assert.NoError(t, err) + + // Find the list subcommand and verify flags exist + deploymentsCmd, _, _ := cmd.Find([]string{"deployments", "list"}) + assert.NotNil(t, deploymentsCmd) + assert.NotNil(t, deploymentsCmd.Flags().Lookup("page")) + assert.NotNil(t, deploymentsCmd.Flags().Lookup("per-page")) +} + +func TestServersListPaginationFlags(t *testing.T) { + cmd := NewRootCmd("test") + serversCmd, _, _ := cmd.Find([]string{"servers", "list"}) + assert.NotNil(t, serversCmd) + assert.NotNil(t, serversCmd.Flags().Lookup("page")) + assert.NotNil(t, serversCmd.Flags().Lookup("per-page")) +} + +func TestServerGroupsListPaginationFlags(t *testing.T) { + cmd := NewRootCmd("test") + sgCmd, _, _ := cmd.Find([]string{"server-groups", "list"}) + assert.NotNil(t, sgCmd) + assert.NotNil(t, sgCmd.Flags().Lookup("page")) + assert.NotNil(t, sgCmd.Flags().Lookup("per-page")) +} diff --git a/internal/commands/server_groups.go b/internal/commands/server_groups.go index 34343ad..55c9fb3 100644 --- a/internal/commands/server_groups.go +++ b/internal/commands/server_groups.go @@ -27,7 +27,9 @@ func newServerGroupsCmd() *cobra.Command { } func newServerGroupsListCmd() *cobra.Command { - return &cobra.Command{ + var page, perPage int + + cmd := &cobra.Command{ Use: "list", Short: "List server groups", RunE: func(cmd *cobra.Command, args []string) error { @@ -41,7 +43,7 @@ func newServerGroupsListCmd() *cobra.Command { return err } - groups, err := client.ListServerGroups(cliCtx.Background(), projectID) + groups, err := client.ListServerGroups(cliCtx.Background(), projectID, listOptsFromFlags(page, perPage)) if err != nil { return err } @@ -64,6 +66,9 @@ func newServerGroupsListCmd() *cobra.Command { return nil }, } + + addPaginationFlags(cmd, &page, &perPage) + return cmd } func newServerGroupsShowCmd() *cobra.Command { diff --git a/internal/commands/servers.go b/internal/commands/servers.go index fb65ddd..87a152e 100644 --- a/internal/commands/servers.go +++ b/internal/commands/servers.go @@ -31,7 +31,9 @@ func newServersCmd() *cobra.Command { } func newServersListCmd() *cobra.Command { - return &cobra.Command{ + var page, perPage int + + cmd := &cobra.Command{ Use: "list", Short: "List servers in a project", RunE: func(cmd *cobra.Command, args []string) error { @@ -45,7 +47,7 @@ func newServersListCmd() *cobra.Command { return err } - servers, err := client.ListServers(cliCtx.Background(), projectID) + servers, err := client.ListServers(cliCtx.Background(), projectID, listOptsFromFlags(page, perPage)) if err != nil { return err } @@ -76,6 +78,9 @@ func newServersListCmd() *cobra.Command { return nil }, } + + addPaginationFlags(cmd, &page, &perPage) + return cmd } func newServersShowCmd() *cobra.Command { @@ -237,7 +242,7 @@ func newServersCreateCmd() *cobra.Command { // Resolve the public key for display / installation var publicKey string if globalKeyPairID != "" { - keys, kerr := client.ListSSHKeys(cliCtx.Background()) + keys, kerr := client.ListSSHKeys(cliCtx.Background(), nil) if kerr == nil { for _, k := range keys { if k.Identifier == globalKeyPairID { diff --git a/internal/output/breadcrumbs.go b/internal/output/breadcrumbs.go index e03ab1f..0b3e15d 100644 --- a/internal/output/breadcrumbs.go +++ b/internal/output/breadcrumbs.go @@ -8,10 +8,19 @@ type Breadcrumb struct { Cmd string `json:"cmd"` } +// Pagination metadata for JSON output. +type Pagination struct { + CurrentPage int `json:"current_page"` + TotalPages int `json:"total_pages"` + TotalRecords int `json:"total_records"` + Offset int `json:"offset"` +} + // Response is the JSON envelope with breadcrumbs (Basecamp pattern). type Response struct { OK bool `json:"ok"` Data interface{} `json:"data"` + Pagination *Pagination `json:"pagination,omitempty"` Summary string `json:"summary,omitempty"` Breadcrumbs []Breadcrumb `json:"breadcrumbs,omitempty"` } @@ -26,6 +35,18 @@ func NewResponse(data interface{}, summary string, breadcrumbs ...Breadcrumb) *R } } +// NewPaginatedResponse creates a success response with pagination metadata and optional breadcrumbs. +func NewPaginatedResponse(data interface{}, pagination Pagination, summary string, breadcrumbs ...Breadcrumb) *Response { + p := pagination + return &Response{ + OK: true, + Data: data, + Pagination: &p, + Summary: summary, + Breadcrumbs: breadcrumbs, + } +} + // ErrorResponse creates an error response envelope. func ErrorResponse(code, message, suggestion, docURL string) *Response { return &Response{ diff --git a/internal/output/breadcrumbs_test.go b/internal/output/breadcrumbs_test.go index 0d82970..7df8790 100644 --- a/internal/output/breadcrumbs_test.go +++ b/internal/output/breadcrumbs_test.go @@ -36,6 +36,43 @@ func TestResponse_JSONShape(t *testing.T) { assert.NotNil(t, raw["data"]) } +func TestNewPaginatedResponse(t *testing.T) { + resp := NewPaginatedResponse( + []string{"a", "b"}, + Pagination{CurrentPage: 2, TotalPages: 5, TotalRecords: 50, Offset: 10}, + "test summary", + ) + assert.True(t, resp.OK) + assert.NotNil(t, resp.Pagination) + assert.Equal(t, 2, resp.Pagination.CurrentPage) + assert.Equal(t, 5, resp.Pagination.TotalPages) + assert.Equal(t, 50, resp.Pagination.TotalRecords) + assert.Equal(t, 10, resp.Pagination.Offset) + assert.Equal(t, "test summary", resp.Summary) +} + +func TestNewResponse_NoPagination(t *testing.T) { + resp := NewResponse([]string{"a"}, "test") + assert.Nil(t, resp.Pagination) + + b, err := json.Marshal(resp) + assert.NoError(t, err) + assert.NotContains(t, string(b), "pagination") +} + +func TestNewPaginatedResponse_IncludesPaginationInJSON(t *testing.T) { + resp := NewPaginatedResponse( + []string{"a"}, + Pagination{CurrentPage: 1, TotalPages: 3, TotalRecords: 30, Offset: 0}, + "test", + ) + b, err := json.Marshal(resp) + assert.NoError(t, err) + assert.Contains(t, string(b), `"pagination"`) + assert.Contains(t, string(b), `"current_page":1`) + assert.Contains(t, string(b), `"total_pages":3`) +} + func TestErrorResponse(t *testing.T) { resp := ErrorResponse("timeout", "Deploy timed out", "Increase --wait-timeout", "https://docs.example.com") From a65b60542cc03c790988a1e106cc02be0da08933 Mon Sep 17 00:00:00 2001 From: Marta Date: Thu, 9 Apr 2026 12:57:33 +0200 Subject: [PATCH 3/5] docs: add pagination, dry-run, and API spec URL to CLAUDE.md --- CLAUDE.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index a613126..fe07a5f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,9 @@ internal/version/ Update checker (GitHub releases API) - **`dhq api`**: Escape hatch covering all 144+ endpoints not in the command tree. - **Breadcrumbs**: JSON responses include suggested next commands. - **No login in CI**: `DEPLOYHQ_ACCOUNT` + `DEPLOYHQ_EMAIL` + `DEPLOYHQ_API_KEY` env vars. +- **Pagination**: `--page` and `--per-page` on paginated list commands (deployments, servers, server-groups). JSON output includes `pagination` metadata when available. Non-paginated endpoints don't have these flags. +- **Dry-run**: `dhq deploy --dry-run` creates a preview deployment without executing. Returns `preview_pending` status. Mutually exclusive with `--wait`. +- **API spec**: `https://api.deployhq.com/docs` (OpenAPI 3.1.0, machine-readable JSON at `/docs.json`) ## Testing From 140a34f13058dfe145942de1d6c422a11a6487bc Mon Sep 17 00:00:00 2001 From: Marta Date: Thu, 9 Apr 2026 13:24:06 +0200 Subject: [PATCH 4/5] fix: move dry-run/wait check before auth so it works without credentials The mutual exclusivity check was after cliCtx.Client() which requires auth. In CI (no keyring), the test hit "Not logged in" before reaching the flag validation. Move the check earlier. --- internal/commands/deploy.go | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/internal/commands/deploy.go b/internal/commands/deploy.go index d7d438b..550b3d5 100644 --- a/internal/commands/deploy.go +++ b/internal/commands/deploy.go @@ -85,6 +85,10 @@ func newDeployCmd() *cobra.Command { return err } + if dryRun && wait { + return &output.UserError{Message: "--dry-run and --wait are mutually exclusive"} + } + client, err := cliCtx.Client() if err != nil { return err @@ -92,10 +96,6 @@ func newDeployCmd() *cobra.Command { env := cliCtx.Envelope - if dryRun && wait { - return &output.UserError{Message: "--dry-run and --wait are mutually exclusive"} - } - // Auto-select server if not specified if server == "" { servers, err := client.ListServers(cliCtx.Background(), projectID, nil) From 3b1edb1fc7561a9f89a64d1c919d6a8652f8e963 Mon Sep 17 00:00:00 2001 From: Marta Date: Thu, 9 Apr 2026 13:27:34 +0200 Subject: [PATCH 5/5] fix: address PR review comments - Return nil preview on API error in PreviewDeployment (avoid zero-value use) - Use require.NoError on cmd.Find in pagination flag tests (stricter assertions) --- internal/commands/pagination_test.go | 13 +++++++------ pkg/sdk/deployments.go | 6 ++++-- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/internal/commands/pagination_test.go b/internal/commands/pagination_test.go index 7ec5fbf..770ee71 100644 --- a/internal/commands/pagination_test.go +++ b/internal/commands/pagination_test.go @@ -5,6 +5,7 @@ import ( "github.com/deployhq/deployhq-cli/pkg/sdk" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) func TestListOptsFromFlags(t *testing.T) { @@ -21,24 +22,24 @@ func TestDeploymentsListPaginationFlags(t *testing.T) { assert.NoError(t, err) // Find the list subcommand and verify flags exist - deploymentsCmd, _, _ := cmd.Find([]string{"deployments", "list"}) - assert.NotNil(t, deploymentsCmd) + deploymentsCmd, _, err2 := cmd.Find([]string{"deployments", "list"}) + require.NoError(t, err2) assert.NotNil(t, deploymentsCmd.Flags().Lookup("page")) assert.NotNil(t, deploymentsCmd.Flags().Lookup("per-page")) } func TestServersListPaginationFlags(t *testing.T) { cmd := NewRootCmd("test") - serversCmd, _, _ := cmd.Find([]string{"servers", "list"}) - assert.NotNil(t, serversCmd) + serversCmd, _, err := cmd.Find([]string{"servers", "list"}) + require.NoError(t, err) assert.NotNil(t, serversCmd.Flags().Lookup("page")) assert.NotNil(t, serversCmd.Flags().Lookup("per-page")) } func TestServerGroupsListPaginationFlags(t *testing.T) { cmd := NewRootCmd("test") - sgCmd, _, _ := cmd.Find([]string{"server-groups", "list"}) - assert.NotNil(t, sgCmd) + sgCmd, _, err := cmd.Find([]string{"server-groups", "list"}) + require.NoError(t, err) assert.NotNil(t, sgCmd.Flags().Lookup("page")) assert.NotNil(t, sgCmd.Flags().Lookup("per-page")) } diff --git a/pkg/sdk/deployments.go b/pkg/sdk/deployments.go index 3dfd4f6..9cab444 100644 --- a/pkg/sdk/deployments.go +++ b/pkg/sdk/deployments.go @@ -45,8 +45,10 @@ func (c *Client) PreviewDeployment(ctx context.Context, projectID string, req De Deployment DeploymentCreateRequest `json:"deployment"` }{Deployment: req} var preview DeploymentPreview - err := c.post(ctx, fmt.Sprintf("/projects/%s/deployments", projectID), body, &preview) - return &preview, err + if err := c.post(ctx, fmt.Sprintf("/projects/%s/deployments", projectID), body, &preview); err != nil { + return nil, err + } + return &preview, nil } // AbortDeployment aborts a running deployment.