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 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/deploy.go b/internal/commands/deploy.go index 5f4bf33..550b3d5 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{ @@ -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 @@ -94,7 +98,7 @@ func newDeployCmd() *cobra.Command { // 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/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/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..770ee71 --- /dev/null +++ b/internal/commands/pagination_test.go @@ -0,0 +1,45 @@ +package commands + +import ( + "testing" + + "github.com/deployhq/deployhq-cli/pkg/sdk" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +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, _, 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, _, 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, _, 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/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/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/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/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") 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..9cab444 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,20 @@ 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 + 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. 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