From b2c871237c77c84367eba98be5a81971eda0d1ae Mon Sep 17 00:00:00 2001 From: omattsson Date: Fri, 15 May 2026 21:51:07 +0200 Subject: [PATCH 1/4] feat(template): add versions list/get/diff subcommands Implements issue #62. - types: TemplateVersion, TemplateVersionDetail, TemplateSnapshot, TemplateSnapshotData, TemplateChartSnapshotData, TemplateVersionDiff, TemplateVersionSide, ChartDiffEntry - client: ListTemplateVersions, GetTemplateVersion, DiffTemplateVersions - cmd: `template versions list|get|diff` subcommand group - table mode: human-readable summary / chart-diff table - json/yaml mode: structured output - quiet mode: IDs for list; chart names with differences for diff - unit tests: all output modes + 404 for all three commands - integration tests: list/get/diff round-trip via mock server - e2e test: happy-path diff scenario --- cli/cmd/template.go | 190 ++++++++++++++++ cli/cmd/template_test.go | 213 ++++++++++++++++++ cli/pkg/client/client.go | 34 +++ cli/pkg/client/client_test.go | 178 +++++++++++++++ cli/pkg/types/types.go | 65 ++++++ cli/test/e2e/cli_e2e_test.go | 44 ++++ .../template_definition_integration_test.go | 98 +++++++- 7 files changed, 821 insertions(+), 1 deletion(-) diff --git a/cli/cmd/template.go b/cli/cmd/template.go index 3f0081d..f880742 100644 --- a/cli/cmd/template.go +++ b/cli/cmd/template.go @@ -443,6 +443,192 @@ Examples: }, } +var templateVersionsCmd = &cobra.Command{ + Use: "versions", + Short: "Manage template version history", + Long: "List, inspect, and compare versioned snapshots of a stack template.", +} + +var templateVersionsListCmd = &cobra.Command{ + Use: "list ", + Short: "List version history for a template", + Long: `List all published versions of a stack template, newest first. + +Examples: + stackctl template versions list 1 + stackctl template versions list 1 -o json`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + id, err := parseID(args[0]) + if err != nil { + return err + } + + c, err := newClient() + if err != nil { + return err + } + + versions, err := c.ListTemplateVersions(id) + if err != nil { + return err + } + + if printer.Quiet { + ids := make([]string, len(versions)) + for i, v := range versions { + ids[i] = v.ID + } + printer.PrintIDs(ids) + return nil + } + + switch printer.Format { + case output.FormatJSON: + return printer.PrintJSON(versions) + case output.FormatYAML: + return printer.PrintYAML(versions) + default: + headers := []string{"ID", "VERSION", "CHANGE SUMMARY", "CREATED BY", "CREATED AT"} + rows := make([][]string, len(versions)) + for i, v := range versions { + rows[i] = []string{ + v.ID, + v.Version, + v.ChangeSummary, + v.CreatedBy, + v.CreatedAt.Format("2006-01-02 15:04"), + } + } + return printer.PrintTable(headers, rows) + } + }, +} + +var templateVersionsGetCmd = &cobra.Command{ + Use: "get ", + Short: "Show a specific template version", + Long: `Show details of a specific template version snapshot. + +Examples: + stackctl template versions get 1 v1 + stackctl template versions get 1 v1 -o json`, + Args: cobra.ExactArgs(2), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + templateID, err := parseID(args[0]) + if err != nil { + return err + } + versionID := args[1] + + c, err := newClient() + if err != nil { + return err + } + + v, err := c.GetTemplateVersion(templateID, versionID) + if err != nil { + return err + } + + if printer.Quiet { + fmt.Fprintln(printer.Writer, v.ID) + return nil + } + + switch printer.Format { + case output.FormatJSON: + return printer.PrintJSON(v) + case output.FormatYAML: + return printer.PrintYAML(v) + default: + headers := []string{"FIELD", "VALUE"} + rows := [][]string{ + {"ID", v.ID}, + {"Template ID", v.TemplateID}, + {"Version", v.Version}, + {"Change Summary", v.ChangeSummary}, + {"Created By", v.CreatedBy}, + {"Created At", v.CreatedAt.Format("2006-01-02 15:04")}, + {"Charts", strconv.Itoa(len(v.Snapshot.Charts))}, + } + return printer.PrintTable(headers, rows) + } + }, +} + +var templateVersionsDiffCmd = &cobra.Command{ + Use: "diff ", + Short: "Compare two template versions", + Long: `Compare two template version snapshots side by side. + +In table mode, shows a chart-level diff summary. +In JSON or YAML mode, returns the full structured diff. + +Examples: + stackctl template versions diff 1 v1 v2 + stackctl template versions diff 1 v1 v2 -o json`, + Args: cobra.ExactArgs(3), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + templateID, err := parseID(args[0]) + if err != nil { + return err + } + leftID := args[1] + rightID := args[2] + + c, err := newClient() + if err != nil { + return err + } + + diff, err := c.DiffTemplateVersions(templateID, leftID, rightID) + if err != nil { + return err + } + + if printer.Quiet { + for _, ch := range diff.ChartDiffs { + if ch.HasDifferences { + fmt.Fprintln(printer.Writer, ch.ChartName) + } + } + return nil + } + + switch printer.Format { + case output.FormatJSON: + return printer.PrintJSON(diff) + case output.FormatYAML: + return printer.PrintYAML(diff) + default: + fmt.Fprintf(printer.Writer, "Comparing %s → %s\n\n", diff.Left.Version, diff.Right.Version) + headers := []string{"CHART", "CHANGE", "REPO URL CHANGED", "VALUES CHANGED"} + rows := make([][]string, len(diff.ChartDiffs)) + for i, ch := range diff.ChartDiffs { + repoChanged := "no" + if ch.LeftRepoURL != ch.RightRepoURL { + repoChanged = "yes" + } + valuesChanged := "no" + if ch.LeftValues != ch.RightValues { + valuesChanged = "yes" + } + rows[i] = []string{ + ch.ChartName, + printer.StatusColor(ch.ChangeType), + repoChanged, + valuesChanged, + } + } + return printer.PrintTable(headers, rows) + } + }, +} + // printTemplate outputs a StackTemplate in the active format. func printTemplate(tmpl *types.StackTemplate) error { if printer.Quiet { @@ -524,5 +710,9 @@ func init() { templateCmd.AddCommand(templateCloneCmd) templateCmd.AddCommand(templatePublishCmd) templateCmd.AddCommand(templateUnpublishCmd) + templateVersionsCmd.AddCommand(templateVersionsListCmd) + templateVersionsCmd.AddCommand(templateVersionsGetCmd) + templateVersionsCmd.AddCommand(templateVersionsDiffCmd) + templateCmd.AddCommand(templateVersionsCmd) rootCmd.AddCommand(templateCmd) } diff --git a/cli/cmd/template_test.go b/cli/cmd/template_test.go index bdf78ad..de33452 100644 --- a/cli/cmd/template_test.go +++ b/cli/cmd/template_test.go @@ -1361,3 +1361,216 @@ func TestTemplateUnpublishCmd_Forbidden(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "Permission denied") } + +// ---------- template versions list ---------- + +func sampleTemplateVersion() types.TemplateVersion { + return types.TemplateVersion{ + ID: "1", + TemplateID: "10", + Version: "v1", + ChangeSummary: "Initial publish", + CreatedBy: "admin", + CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC), + } +} + +func sampleTemplateVersionDetail() types.TemplateVersionDetail { + return types.TemplateVersionDetail{ + TemplateVersion: sampleTemplateVersion(), + Snapshot: types.TemplateSnapshot{ + Template: types.TemplateSnapshotData{Name: "web-app", DefaultBranch: "master", IsPublished: true, Version: "v1"}, + Charts: []types.TemplateChartSnapshotData{{ChartName: "frontend", RepoURL: "https://charts.example.com"}}, + }, + } +} + +func TestTemplateVersionsListCmd_Success(t *testing.T) { + v := sampleTemplateVersion() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/templates/10/versions", r.URL.Path) + require.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode([]types.TemplateVersion{v}) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + err := templateVersionsListCmd.RunE(templateVersionsListCmd, []string{"10"}) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "1") + assert.Contains(t, out, "v1") +} + +func TestTemplateVersionsListCmd_JSONOutput(t *testing.T) { + v := sampleTemplateVersion() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode([]types.TemplateVersion{v}) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Format = output.FormatJSON + err := templateVersionsListCmd.RunE(templateVersionsListCmd, []string{"10"}) + require.NoError(t, err) + + var result []types.TemplateVersion + require.NoError(t, json.Unmarshal(buf.Bytes(), &result)) + require.Len(t, result, 1) + assert.Equal(t, "1", result[0].ID) +} + +func TestTemplateVersionsListCmd_QuietOutput(t *testing.T) { + v := sampleTemplateVersion() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode([]types.TemplateVersion{v}) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Quiet = true + err := templateVersionsListCmd.RunE(templateVersionsListCmd, []string{"10"}) + require.NoError(t, err) + assert.Equal(t, "1\n", buf.String()) +} + +func TestTemplateVersionsListCmd_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(types.ErrorResponse{Error: "not found"}) + })) + defer server.Close() + + _ = setupStackTestCmd(t, server.URL) + err := templateVersionsListCmd.RunE(templateVersionsListCmd, []string{"10"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +// ---------- template versions get ---------- + +func TestTemplateVersionsGetCmd_Success(t *testing.T) { + v := sampleTemplateVersionDetail() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/templates/10/versions/v1", r.URL.Path) + require.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(v) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + err := templateVersionsGetCmd.RunE(templateVersionsGetCmd, []string{"10", "v1"}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "v1") +} + +func TestTemplateVersionsGetCmd_JSONOutput(t *testing.T) { + v := sampleTemplateVersionDetail() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(v) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Format = output.FormatJSON + err := templateVersionsGetCmd.RunE(templateVersionsGetCmd, []string{"10", "v1"}) + require.NoError(t, err) + + var result types.TemplateVersionDetail + require.NoError(t, json.Unmarshal(buf.Bytes(), &result)) + assert.Equal(t, "v1", result.Version) +} + +func TestTemplateVersionsGetCmd_QuietOutput(t *testing.T) { + v := sampleTemplateVersionDetail() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(v) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Quiet = true + err := templateVersionsGetCmd.RunE(templateVersionsGetCmd, []string{"10", "v1"}) + require.NoError(t, err) + assert.Equal(t, "1\n", buf.String()) +} + +// ---------- template versions diff ---------- + +func sampleTemplateVersionDiff() types.TemplateVersionDiff { + return types.TemplateVersionDiff{ + Left: types.TemplateVersionSide{Version: "v1"}, + Right: types.TemplateVersionSide{Version: "v2"}, + ChartDiffs: []types.ChartDiffEntry{ + {ChartName: "frontend", ChangeType: "modified", HasDifferences: true, LeftRepoURL: "https://charts.example.com", RightRepoURL: "https://charts2.example.com"}, + }, + } +} + +func TestTemplateVersionsDiffCmd_Success(t *testing.T) { + diff := sampleTemplateVersionDiff() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, "/api/v1/templates/10/versions/diff", r.URL.Path) + require.Equal(t, http.MethodGet, r.Method) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(diff) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + err := templateVersionsDiffCmd.RunE(templateVersionsDiffCmd, []string{"10", "v1", "v2"}) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "frontend") + assert.Contains(t, strings.ToLower(out), "modified") +} + +func TestTemplateVersionsDiffCmd_JSONOutput(t *testing.T) { + diff := sampleTemplateVersionDiff() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(diff) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Format = output.FormatJSON + err := templateVersionsDiffCmd.RunE(templateVersionsDiffCmd, []string{"10", "v1", "v2"}) + require.NoError(t, err) + + var result types.TemplateVersionDiff + require.NoError(t, json.Unmarshal(buf.Bytes(), &result)) + require.Len(t, result.ChartDiffs, 1) + assert.Equal(t, "frontend", result.ChartDiffs[0].ChartName) +} + +func TestTemplateVersionsDiffCmd_QuietOutput(t *testing.T) { + diff := sampleTemplateVersionDiff() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(diff) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Quiet = true + err := templateVersionsDiffCmd.RunE(templateVersionsDiffCmd, []string{"10", "v1", "v2"}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "frontend") +} diff --git a/cli/pkg/client/client.go b/cli/pkg/client/client.go index a88c32e..9c02ec8 100644 --- a/cli/pkg/client/client.go +++ b/cli/pkg/client/client.go @@ -658,6 +658,40 @@ func (c *Client) UnpublishTemplate(id string) (*types.StackTemplate, error) { return &tmpl, nil } +// ListTemplateVersions returns all version snapshots for a template. +func (c *Client) ListTemplateVersions(templateID string) ([]types.TemplateVersion, error) { + var versions []types.TemplateVersion + err := c.Get(fmt.Sprintf("/api/v1/templates/%s/versions", templateID), &versions) + if err != nil { + return nil, err + } + return versions, nil +} + +// GetTemplateVersion returns a specific version snapshot for a template. +func (c *Client) GetTemplateVersion(templateID, versionID string) (*types.TemplateVersionDetail, error) { + var v types.TemplateVersionDetail + err := c.Get(fmt.Sprintf("/api/v1/templates/%s/versions/%s", templateID, versionID), &v) + if err != nil { + return nil, err + } + return &v, nil +} + +// DiffTemplateVersions compares two template version snapshots. +func (c *Client) DiffTemplateVersions(templateID, leftID, rightID string) (*types.TemplateVersionDiff, error) { + var diff types.TemplateVersionDiff + err := c.GetWithQuery( + fmt.Sprintf("/api/v1/templates/%s/versions/diff", templateID), + map[string]string{"left": leftID, "right": rightID}, + &diff, + ) + if err != nil { + return nil, err + } + return &diff, nil +} + // ListOrphanedNamespaces returns namespaces that have the stack-manager label but no matching DB record. func (c *Client) ListOrphanedNamespaces() ([]types.OrphanedNamespace, error) { var ns []types.OrphanedNamespace diff --git a/cli/pkg/client/client_test.go b/cli/pkg/client/client_test.go index 9d4d50e..73e3b16 100644 --- a/cli/pkg/client/client_test.go +++ b/cli/pkg/client/client_test.go @@ -2922,3 +2922,181 @@ func TestUnpublishTemplate(t *testing.T) { }) } } + +func TestListTemplateVersions(t *testing.T) { + t.Parallel() + tests := []struct { + name string + statusCode int + body interface{} + wantErr bool + wantStatus int + }{ + { + name: "Success", + statusCode: http.StatusOK, + body: []types.TemplateVersion{{ID: "1", TemplateID: "1", Version: "v1", ChangeSummary: "Initial", CreatedBy: "admin"}}, + wantErr: false, + }, + { + name: "NotFound", + statusCode: http.StatusNotFound, + body: types.ErrorResponse{Error: "not found"}, + wantErr: true, + wantStatus: http.StatusNotFound, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v1/templates/1/versions", r.URL.Path) + w.WriteHeader(tt.statusCode) + json.NewEncoder(w).Encode(tt.body) + })) + defer server.Close() + + c := New(server.URL) + versions, err := c.ListTemplateVersions("1") + if tt.wantErr { + require.Error(t, err) + assert.Nil(t, versions) + apiErr, ok := err.(*APIError) + require.True(t, ok) + assert.Equal(t, tt.wantStatus, apiErr.StatusCode) + } else { + require.NoError(t, err) + require.Len(t, versions, 1) + assert.Equal(t, "1", versions[0].ID) + assert.Equal(t, "v1", versions[0].Version) + } + }) + } +} + +func TestGetTemplateVersion(t *testing.T) { + t.Parallel() + tests := []struct { + name string + statusCode int + body interface{} + wantErr bool + wantStatus int + }{ + { + name: "Success", + statusCode: http.StatusOK, + body: types.TemplateVersionDetail{ + TemplateVersion: types.TemplateVersion{ID: "1", Version: "v1"}, + Snapshot: types.TemplateSnapshot{ + Template: types.TemplateSnapshotData{Name: "web-app"}, + Charts: []types.TemplateChartSnapshotData{{ChartName: "frontend"}}, + }, + }, + wantErr: false, + }, + { + name: "NotFound", + statusCode: http.StatusNotFound, + body: types.ErrorResponse{Error: "not found"}, + wantErr: true, + wantStatus: http.StatusNotFound, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v1/templates/1/versions/v1", r.URL.Path) + w.WriteHeader(tt.statusCode) + json.NewEncoder(w).Encode(tt.body) + })) + defer server.Close() + + c := New(server.URL) + v, err := c.GetTemplateVersion("1", "v1") + if tt.wantErr { + require.Error(t, err) + assert.Nil(t, v) + apiErr, ok := err.(*APIError) + require.True(t, ok) + assert.Equal(t, tt.wantStatus, apiErr.StatusCode) + } else { + require.NoError(t, err) + require.NotNil(t, v) + assert.Equal(t, "1", v.ID) + assert.Equal(t, "v1", v.Version) + assert.Equal(t, "web-app", v.Snapshot.Template.Name) + } + }) + } +} + +func TestDiffTemplateVersions(t *testing.T) { + t.Parallel() + tests := []struct { + name string + statusCode int + body interface{} + wantErr bool + wantStatus int + }{ + { + name: "Success", + statusCode: http.StatusOK, + body: types.TemplateVersionDiff{ + Left: types.TemplateVersionSide{Version: "v1"}, + Right: types.TemplateVersionSide{Version: "v2"}, + ChartDiffs: []types.ChartDiffEntry{ + {ChartName: "frontend", ChangeType: "modified", HasDifferences: true}, + }, + }, + wantErr: false, + }, + { + name: "NotFound", + statusCode: http.StatusNotFound, + body: types.ErrorResponse{Error: "not found"}, + wantErr: true, + wantStatus: http.StatusNotFound, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/api/v1/templates/1/versions/diff", r.URL.Path) + assert.Equal(t, "v1", r.URL.Query().Get("left")) + assert.Equal(t, "v2", r.URL.Query().Get("right")) + w.WriteHeader(tt.statusCode) + json.NewEncoder(w).Encode(tt.body) + })) + defer server.Close() + + c := New(server.URL) + diff, err := c.DiffTemplateVersions("1", "v1", "v2") + if tt.wantErr { + require.Error(t, err) + assert.Nil(t, diff) + apiErr, ok := err.(*APIError) + require.True(t, ok) + assert.Equal(t, tt.wantStatus, apiErr.StatusCode) + } else { + require.NoError(t, err) + require.NotNil(t, diff) + assert.Equal(t, "v1", diff.Left.Version) + assert.Equal(t, "v2", diff.Right.Version) + require.Len(t, diff.ChartDiffs, 1) + assert.Equal(t, "frontend", diff.ChartDiffs[0].ChartName) + assert.Equal(t, "modified", diff.ChartDiffs[0].ChangeType) + assert.True(t, diff.ChartDiffs[0].HasDifferences) + } + }) + } +} diff --git a/cli/pkg/types/types.go b/cli/pkg/types/types.go index 31f4d30..974bf37 100644 --- a/cli/pkg/types/types.go +++ b/cli/pkg/types/types.go @@ -416,3 +416,68 @@ type UpdateTemplateRequest struct { type CloneTemplateRequest struct { Name string `json:"name" yaml:"name"` } + +// TemplateVersion represents a version snapshot entry in the template history. +type TemplateVersion struct { + ID string `json:"id" yaml:"id"` + TemplateID string `json:"template_id" yaml:"template_id"` + Version string `json:"version" yaml:"version"` + ChangeSummary string `json:"change_summary" yaml:"change_summary"` + CreatedBy string `json:"created_by" yaml:"created_by"` + CreatedAt time.Time `json:"created_at" yaml:"created_at"` +} + +// TemplateVersionDetail is the full version response including the parsed snapshot. +type TemplateVersionDetail struct { + TemplateVersion + Snapshot TemplateSnapshot `json:"snapshot" yaml:"snapshot"` +} + +// TemplateSnapshot is the state of a template captured at publish time. +type TemplateSnapshot struct { + Template TemplateSnapshotData `json:"template" yaml:"template"` + Charts []TemplateChartSnapshotData `json:"charts" yaml:"charts"` +} + +// TemplateSnapshotData holds the template fields in a snapshot. +type TemplateSnapshotData struct { + Name string `json:"name" yaml:"name"` + Description string `json:"description,omitempty" yaml:"description,omitempty"` + DefaultBranch string `json:"default_branch" yaml:"default_branch"` + IsPublished bool `json:"is_published" yaml:"is_published"` + Version string `json:"version" yaml:"version"` +} + +// TemplateChartSnapshotData holds chart config fields in a snapshot. +type TemplateChartSnapshotData struct { + ChartName string `json:"chart_name" yaml:"chart_name"` + RepoURL string `json:"repo_url" yaml:"repo_url"` + DefaultValues string `json:"default_values,omitempty" yaml:"default_values,omitempty"` + LockedValues string `json:"locked_values,omitempty" yaml:"locked_values,omitempty"` + IsRequired bool `json:"is_required" yaml:"is_required"` + SortOrder int `json:"sort_order" yaml:"sort_order"` +} + +// TemplateVersionDiff is the response from the version diff endpoint. +type TemplateVersionDiff struct { + Left TemplateVersionSide `json:"left" yaml:"left"` + Right TemplateVersionSide `json:"right" yaml:"right"` + ChartDiffs []ChartDiffEntry `json:"chart_diffs" yaml:"chart_diffs"` +} + +// TemplateVersionSide is one side of a version diff. +type TemplateVersionSide struct { + Version string `json:"version" yaml:"version"` + Snapshot TemplateSnapshot `json:"snapshot" yaml:"snapshot"` +} + +// ChartDiffEntry describes chart-level differences between two template versions. +type ChartDiffEntry struct { + ChartName string `json:"chart_name" yaml:"chart_name"` + ChangeType string `json:"change_type" yaml:"change_type"` + HasDifferences bool `json:"has_differences" yaml:"has_differences"` + LeftValues string `json:"left_values,omitempty" yaml:"left_values,omitempty"` + RightValues string `json:"right_values,omitempty" yaml:"right_values,omitempty"` + LeftRepoURL string `json:"left_repo_url,omitempty" yaml:"left_repo_url,omitempty"` + RightRepoURL string `json:"right_repo_url,omitempty" yaml:"right_repo_url,omitempty"` +} diff --git a/cli/test/e2e/cli_e2e_test.go b/cli/test/e2e/cli_e2e_test.go index d77fd20..e347e3a 100644 --- a/cli/test/e2e/cli_e2e_test.go +++ b/cli/test/e2e/cli_e2e_test.go @@ -839,6 +839,33 @@ func startE2ETemplateDefMockServer(t *testing.T) *httptest.Server { w.WriteHeader(http.StatusOK) json.NewEncoder(w).Encode(resp) + // Template versions list + case r.URL.Path == "/api/v1/templates/1/versions" && r.Method == http.MethodGet: + resp := []map[string]interface{}{ + {"id": "1", "template_id": "1", "version": "v1", "change_summary": "Initial publish", "created_by": "admin", "created_at": "2025-01-01T00:00:00Z"}, + {"id": "2", "template_id": "1", "version": "v2", "change_summary": "Updated charts", "created_by": "admin", "created_at": "2025-06-01T00:00:00Z"}, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(resp) + + // Template versions diff + case r.URL.Path == "/api/v1/templates/1/versions/diff" && r.Method == http.MethodGet: + resp := map[string]interface{}{ + "left": map[string]interface{}{"version": "v1", "snapshot": map[string]interface{}{ + "template": map[string]interface{}{"name": "web-template", "is_published": true, "default_branch": "master", "version": "v1"}, + "charts": []map[string]interface{}{{"chart_name": "frontend", "repo_url": "https://charts.example.com"}}, + }}, + "right": map[string]interface{}{"version": "v2", "snapshot": map[string]interface{}{ + "template": map[string]interface{}{"name": "web-template", "is_published": true, "default_branch": "master", "version": "v2"}, + "charts": []map[string]interface{}{{"chart_name": "frontend", "repo_url": "https://charts2.example.com"}}, + }}, + "chart_diffs": []map[string]interface{}{ + {"chart_name": "frontend", "change_type": "modified", "has_differences": true, "left_repo_url": "https://charts.example.com", "right_repo_url": "https://charts2.example.com"}, + }, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(resp) + default: w.WriteHeader(http.StatusNotFound) json.NewEncoder(w).Encode(map[string]string{"error": "not found"}) @@ -1445,3 +1472,20 @@ func TestE2E_TemplateUnpublish_QuietOutput(t *testing.T) { require.NoError(t, err) assert.Equal(t, "1\n", stdout) } + +func TestE2E_TemplateVersionsDiff(t *testing.T) { + if testing.Short() { + t.Skip("skipping e2e test in short mode") + } + + server := startE2ETemplateDefMockServer(t) + defer server.Close() + + dir := t.TempDir() + setupE2EStackContext(t, dir, server.URL) + + stdout, _, err := runStackctl(t, dir, "template", "versions", "diff", "1", "v1", "v2") + require.NoError(t, err) + assert.Contains(t, stdout, "frontend") + assert.Contains(t, stdout, "v1") +} diff --git a/cli/test/integration/template_definition_integration_test.go b/cli/test/integration/template_definition_integration_test.go index 435bcd1..855a1ef 100644 --- a/cli/test/integration/template_definition_integration_test.go +++ b/cli/test/integration/template_definition_integration_test.go @@ -104,19 +104,53 @@ func startTemplateDefMockServer(t *testing.T, state *templateDefMockState) *http return } - // Template get/instantiate/quick-deploy + // Template get/instantiate/quick-deploy/versions if tmplTrim := strings.TrimPrefix(r.URL.Path, "/api/v1/templates/"); tmplTrim != r.URL.Path { parts := strings.Split(tmplTrim, "/") var tmplID string var tmplAction string + var tmplSubAction string switch len(parts) { case 1: tmplID = parts[0] case 2: tmplID = parts[0] tmplAction = parts[1] + case 3: + tmplID = parts[0] + tmplAction = parts[1] + tmplSubAction = parts[2] } if tmplID != "" { + // Handle version routes before template lookup for diff (no template lookup needed) + if tmplAction == "versions" && tmplSubAction == "diff" && r.Method == http.MethodGet { + leftID := r.URL.Query().Get("left") + rightID := r.URL.Query().Get("right") + diff := types.TemplateVersionDiff{ + Left: types.TemplateVersionSide{Version: leftID}, + Right: types.TemplateVersionSide{Version: rightID}, + ChartDiffs: []types.ChartDiffEntry{ + {ChartName: "frontend", ChangeType: "modified", HasDifferences: true}, + }, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(diff) + return + } + + if tmplAction == "versions" && tmplSubAction != "" && r.Method == http.MethodGet { + // Get specific version by version ID + detail := types.TemplateVersionDetail{ + TemplateVersion: types.TemplateVersion{ID: tmplSubAction, TemplateID: tmplID, Version: tmplSubAction}, + Snapshot: types.TemplateSnapshot{ + Template: types.TemplateSnapshotData{Name: "web-app"}, + }, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(detail) + return + } + // Find template var tmpl *types.StackTemplate for i := range state.templates { @@ -131,6 +165,15 @@ func startTemplateDefMockServer(t *testing.T, state *templateDefMockState) *http return } + if tmplAction == "versions" && tmplSubAction == "" && r.Method == http.MethodGet { + versions := []types.TemplateVersion{ + {ID: "1", TemplateID: tmplID, Version: "v1", ChangeSummary: "Initial", CreatedBy: "admin"}, + } + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(versions) + return + } + switch { case tmplAction == "" && r.Method == http.MethodGet: w.WriteHeader(http.StatusOK) @@ -840,3 +883,56 @@ func TestTemplatePublish_NotFound(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "template not found") } + +func TestTemplateVersionsList_Success(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + state := newTemplateDefMockState() + server := startTemplateDefMockServer(t, state) + defer server.Close() + + c := client.New(server.URL) + + versions, err := c.ListTemplateVersions("1") + require.NoError(t, err) + require.GreaterOrEqual(t, len(versions), 1) + assert.Equal(t, "v1", versions[0].Version) +} + +func TestTemplateVersionsGet_Success(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + state := newTemplateDefMockState() + server := startTemplateDefMockServer(t, state) + defer server.Close() + + c := client.New(server.URL) + + v, err := c.GetTemplateVersion("1", "v1") + require.NoError(t, err) + require.NotNil(t, v) + assert.Equal(t, "v1", v.Version) + assert.Equal(t, "web-app", v.Snapshot.Template.Name) +} + +func TestTemplateVersionsDiff_Success(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + state := newTemplateDefMockState() + server := startTemplateDefMockServer(t, state) + defer server.Close() + + c := client.New(server.URL) + + diff, err := c.DiffTemplateVersions("1", "v1", "v2") + require.NoError(t, err) + require.NotNil(t, diff) + require.Len(t, diff.ChartDiffs, 1) + assert.Equal(t, "frontend", diff.ChartDiffs[0].ChartName) +} From f26fff3c8feb52d4fabedffec64dbe1bc23a3415 Mon Sep 17 00:00:00 2001 From: omattsson Date: Sat, 16 May 2026 08:12:04 +0200 Subject: [PATCH 2/4] fix(template): address code review findings on versions commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - types: add Category to TemplateSnapshotData to match backend model - types: add LeftLocked/RightLocked/LeftRequired/RightRequired/ LeftSortOrder/RightSortOrder to ChartDiffEntry (silent data loss fix) - cmd: rename // args to / / — clarify UUID requirement - cmd: update examples to show correct usage pattern with -q pipe - cmd: diff quiet mode — add comment documenting chart-name deviation - cmd: versions get table — show individual chart names instead of count - cmd: use ASCII '->' instead of UTF-8 '→' for Windows compatibility - test: add TestTemplateVersionsGetCmd_NotFound - test: add TestTemplateVersionsDiffCmd_NotFound - test: add assertions on 'Comparing v1 -> v2' and 'CHART' header - e2e: add TestE2E_TemplateVersionsList with table + quiet assertions --- cli/cmd/template.go | 23 +++++++++++++++-------- cli/cmd/template_test.go | 30 ++++++++++++++++++++++++++++++ cli/pkg/types/types.go | 7 +++++++ cli/test/e2e/cli_e2e_test.go | 30 ++++++++++++++++++++++++++++++ 4 files changed, 82 insertions(+), 8 deletions(-) diff --git a/cli/cmd/template.go b/cli/cmd/template.go index f880742..ed3ff5d 100644 --- a/cli/cmd/template.go +++ b/cli/cmd/template.go @@ -507,13 +507,15 @@ Examples: } var templateVersionsGetCmd = &cobra.Command{ - Use: "get ", + Use: "get ", Short: "Show a specific template version", Long: `Show details of a specific template version snapshot. +The is the UUID shown in the ID column of 'template versions list'. + Examples: - stackctl template versions get 1 v1 - stackctl template versions get 1 v1 -o json`, + stackctl template versions get 1 $(stackctl template versions list 1 -q | head -1) + stackctl template versions get 1 -o json`, Args: cobra.ExactArgs(2), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -552,7 +554,9 @@ Examples: {"Change Summary", v.ChangeSummary}, {"Created By", v.CreatedBy}, {"Created At", v.CreatedAt.Format("2006-01-02 15:04")}, - {"Charts", strconv.Itoa(len(v.Snapshot.Charts))}, + } + for _, ch := range v.Snapshot.Charts { + rows = append(rows, []string{"Chart", ch.ChartName}) } return printer.PrintTable(headers, rows) } @@ -560,16 +564,17 @@ Examples: } var templateVersionsDiffCmd = &cobra.Command{ - Use: "diff ", + Use: "diff ", Short: "Compare two template versions", Long: `Compare two template version snapshots side by side. +The version IDs are the UUIDs shown in the ID column of 'template versions list'. In table mode, shows a chart-level diff summary. In JSON or YAML mode, returns the full structured diff. Examples: - stackctl template versions diff 1 v1 v2 - stackctl template versions diff 1 v1 v2 -o json`, + stackctl template versions diff 1 + stackctl template versions diff 1 -o json`, Args: cobra.ExactArgs(3), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -591,6 +596,8 @@ Examples: } if printer.Quiet { + // quiet mode prints chart names with differences, one per line. + // For diff, chart names are the stable identifiers in this context. for _, ch := range diff.ChartDiffs { if ch.HasDifferences { fmt.Fprintln(printer.Writer, ch.ChartName) @@ -605,7 +612,7 @@ Examples: case output.FormatYAML: return printer.PrintYAML(diff) default: - fmt.Fprintf(printer.Writer, "Comparing %s → %s\n\n", diff.Left.Version, diff.Right.Version) + fmt.Fprintf(printer.Writer, "Comparing %s -> %s\n\n", diff.Left.Version, diff.Right.Version) headers := []string{"CHART", "CHANGE", "REPO URL CHANGED", "VALUES CHANGED"} rows := make([][]string, len(diff.ChartDiffs)) for i, ch := range diff.ChartDiffs { diff --git a/cli/cmd/template_test.go b/cli/cmd/template_test.go index de33452..23e1e20 100644 --- a/cli/cmd/template_test.go +++ b/cli/cmd/template_test.go @@ -1508,6 +1508,20 @@ func TestTemplateVersionsGetCmd_QuietOutput(t *testing.T) { assert.Equal(t, "1\n", buf.String()) } +func TestTemplateVersionsGetCmd_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(types.ErrorResponse{Error: "version not found"}) + })) + defer server.Close() + + _ = setupStackTestCmd(t, server.URL) + err := templateVersionsGetCmd.RunE(templateVersionsGetCmd, []string{"10", "uuid-not-found"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + // ---------- template versions diff ---------- func sampleTemplateVersionDiff() types.TemplateVersionDiff { @@ -1537,6 +1551,8 @@ func TestTemplateVersionsDiffCmd_Success(t *testing.T) { out := buf.String() assert.Contains(t, out, "frontend") assert.Contains(t, strings.ToLower(out), "modified") + assert.Contains(t, out, "Comparing v1 -> v2") + assert.Contains(t, out, "CHART") } func TestTemplateVersionsDiffCmd_JSONOutput(t *testing.T) { @@ -1574,3 +1590,17 @@ func TestTemplateVersionsDiffCmd_QuietOutput(t *testing.T) { require.NoError(t, err) assert.Contains(t, buf.String(), "frontend") } + +func TestTemplateVersionsDiffCmd_NotFound(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + json.NewEncoder(w).Encode(types.ErrorResponse{Error: "template not found"}) + })) + defer server.Close() + + _ = setupStackTestCmd(t, server.URL) + err := templateVersionsDiffCmd.RunE(templateVersionsDiffCmd, []string{"99", "uuid-1", "uuid-2"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} diff --git a/cli/pkg/types/types.go b/cli/pkg/types/types.go index 974bf37..3703071 100644 --- a/cli/pkg/types/types.go +++ b/cli/pkg/types/types.go @@ -443,6 +443,7 @@ type TemplateSnapshot struct { type TemplateSnapshotData struct { Name string `json:"name" yaml:"name"` Description string `json:"description,omitempty" yaml:"description,omitempty"` + Category string `json:"category,omitempty" yaml:"category,omitempty"` DefaultBranch string `json:"default_branch" yaml:"default_branch"` IsPublished bool `json:"is_published" yaml:"is_published"` Version string `json:"version" yaml:"version"` @@ -480,4 +481,10 @@ type ChartDiffEntry struct { RightValues string `json:"right_values,omitempty" yaml:"right_values,omitempty"` LeftRepoURL string `json:"left_repo_url,omitempty" yaml:"left_repo_url,omitempty"` RightRepoURL string `json:"right_repo_url,omitempty" yaml:"right_repo_url,omitempty"` + LeftLocked string `json:"left_locked,omitempty" yaml:"left_locked,omitempty"` + RightLocked string `json:"right_locked,omitempty" yaml:"right_locked,omitempty"` + LeftRequired bool `json:"left_required,omitempty" yaml:"left_required,omitempty"` + RightRequired bool `json:"right_required,omitempty" yaml:"right_required,omitempty"` + LeftSortOrder int `json:"left_sort_order,omitempty" yaml:"left_sort_order,omitempty"` + RightSortOrder int `json:"right_sort_order,omitempty" yaml:"right_sort_order,omitempty"` } diff --git a/cli/test/e2e/cli_e2e_test.go b/cli/test/e2e/cli_e2e_test.go index e347e3a..a2175ea 100644 --- a/cli/test/e2e/cli_e2e_test.go +++ b/cli/test/e2e/cli_e2e_test.go @@ -1487,5 +1487,35 @@ func TestE2E_TemplateVersionsDiff(t *testing.T) { stdout, _, err := runStackctl(t, dir, "template", "versions", "diff", "1", "v1", "v2") require.NoError(t, err) assert.Contains(t, stdout, "frontend") + assert.Contains(t, stdout, "Comparing v1 ->") + assert.Contains(t, stdout, "CHART") + assert.Contains(t, stdout, "CHANGE") +} + +func TestE2E_TemplateVersionsList(t *testing.T) { + if testing.Short() { + t.Skip("skipping e2e test in short mode") + } + + server := startE2ETemplateDefMockServer(t) + defer server.Close() + + dir := t.TempDir() + setupE2EStackContext(t, dir, server.URL) + + // Table output — verify headers and data + stdout, _, err := runStackctl(t, dir, "template", "versions", "list", "1") + require.NoError(t, err) + assert.Contains(t, stdout, "ID") + assert.Contains(t, stdout, "VERSION") + assert.Contains(t, stdout, "CHANGE SUMMARY") assert.Contains(t, stdout, "v1") + assert.Contains(t, stdout, "Initial publish") + + // Quiet output — IDs only, one per line + stdout, _, err = runStackctl(t, dir, "template", "versions", "list", "1", "--quiet") + require.NoError(t, err) + lines := strings.Split(strings.TrimSpace(stdout), "\n") + require.Len(t, lines, 2) + assert.NotContains(t, stdout, "VERSION") } From 42876d655dd39788ffc2977b3ac51052a26a3431 Mon Sep 17 00:00:00 2001 From: omattsson Date: Sat, 16 May 2026 08:30:14 +0200 Subject: [PATCH 3/4] fix(template): address Copilot review findings on versions commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cmd: render per-chart unified text diff in table mode (satisfies issue #62 acceptance criteria: 'diff renders unified text in table mode') uses go-difflib to show left/right values diff after summary table - test: add YAML output tests for versions list, get, and diff commands - go.mod: promote go-difflib from indirect to direct dependency Note: Copilot's suggestion to change 'left'/'right' query params to 'from'/'to' is incorrect — the backend (k8s-stack-manager) uses 'left'/'right'. --- cli/cmd/template.go | 24 +++++++++++++++++- cli/cmd/template_test.go | 55 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 1 deletion(-) diff --git a/cli/cmd/template.go b/cli/cmd/template.go index ed3ff5d..5186b21 100644 --- a/cli/cmd/template.go +++ b/cli/cmd/template.go @@ -12,6 +12,7 @@ import ( "github.com/omattsson/stackctl/cli/pkg/client" "github.com/omattsson/stackctl/cli/pkg/output" "github.com/omattsson/stackctl/cli/pkg/types" + "github.com/pmezard/go-difflib/difflib" "github.com/spf13/cobra" ) @@ -631,7 +632,28 @@ Examples: valuesChanged, } } - return printer.PrintTable(headers, rows) + if err := printer.PrintTable(headers, rows); err != nil { + return err + } + // Render per-chart unified text diff for charts with values changes. + for _, ch := range diff.ChartDiffs { + if ch.LeftValues == ch.RightValues { + continue + } + ud := difflib.UnifiedDiff{ + A: difflib.SplitLines(ch.LeftValues), + B: difflib.SplitLines(ch.RightValues), + FromFile: fmt.Sprintf("%s (%s)", ch.ChartName, diff.Left.Version), + ToFile: fmt.Sprintf("%s (%s)", ch.ChartName, diff.Right.Version), + Context: 3, + } + text, err := difflib.GetUnifiedDiffString(ud) + if err != nil || text == "" { + continue + } + fmt.Fprintf(printer.Writer, "\n%s", text) + } + return nil } }, } diff --git a/cli/cmd/template_test.go b/cli/cmd/template_test.go index 23e1e20..77c9f6c 100644 --- a/cli/cmd/template_test.go +++ b/cli/cmd/template_test.go @@ -1454,6 +1454,24 @@ func TestTemplateVersionsListCmd_NotFound(t *testing.T) { assert.Contains(t, err.Error(), "not found") } +func TestTemplateVersionsListCmd_YAMLOutput(t *testing.T) { + v := sampleTemplateVersion() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode([]types.TemplateVersion{v}) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Format = output.FormatYAML + err := templateVersionsListCmd.RunE(templateVersionsListCmd, []string{"10"}) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "version:") + assert.Contains(t, out, "v1") +} + // ---------- template versions get ---------- func TestTemplateVersionsGetCmd_Success(t *testing.T) { @@ -1522,6 +1540,24 @@ func TestTemplateVersionsGetCmd_NotFound(t *testing.T) { assert.Contains(t, err.Error(), "not found") } +func TestTemplateVersionsGetCmd_YAMLOutput(t *testing.T) { + v := sampleTemplateVersionDetail() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(v) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Format = output.FormatYAML + err := templateVersionsGetCmd.RunE(templateVersionsGetCmd, []string{"10", "uuid-v1"}) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "version:") + assert.Contains(t, out, "change_summary:") +} + // ---------- template versions diff ---------- func sampleTemplateVersionDiff() types.TemplateVersionDiff { @@ -1604,3 +1640,22 @@ func TestTemplateVersionsDiffCmd_NotFound(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "not found") } + +func TestTemplateVersionsDiffCmd_YAMLOutput(t *testing.T) { + diff := sampleTemplateVersionDiff() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(diff) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + printer.Format = output.FormatYAML + err := templateVersionsDiffCmd.RunE(templateVersionsDiffCmd, []string{"10", "uuid-v1", "uuid-v2"}) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "chart_name:") + assert.Contains(t, out, "frontend") + assert.Contains(t, out, "change_type:") +} From ea9879ff5473606eb82e54ef5451432350cd0b8c Mon Sep 17 00:00:00 2001 From: omattsson Date: Sat, 16 May 2026 08:37:30 +0200 Subject: [PATCH 4/4] fix(template): address second Copilot review on versions commands - types: remove omitempty from ChartDiffEntry bool/int fields (LeftRequired, RightRequired, LeftSortOrder, RightSortOrder) -- false and 0 are meaningful values that must not be silently dropped in JSON/YAML output - test: add TestTemplateVersionsDiffCmd_UnifiedDiffOutput to verify the unified text diff path is exercised (left/right values differ, asserts ---, +++, @@ markers and changed lines in output) Note: Copilot's repeated suggestion to rename 'left'/'right' to 'from'/'to' is incorrect -- the k8s-stack-manager backend uses 'left'/'right' (confirmed in handlers/template_versions.go:161). --- cli/cmd/template_test.go | 35 +++++++++++++++++++++++++++++++++++ cli/pkg/types/types.go | 8 ++++---- 2 files changed, 39 insertions(+), 4 deletions(-) diff --git a/cli/cmd/template_test.go b/cli/cmd/template_test.go index 77c9f6c..4ace544 100644 --- a/cli/cmd/template_test.go +++ b/cli/cmd/template_test.go @@ -1659,3 +1659,38 @@ func TestTemplateVersionsDiffCmd_YAMLOutput(t *testing.T) { assert.Contains(t, out, "frontend") assert.Contains(t, out, "change_type:") } + +func TestTemplateVersionsDiffCmd_UnifiedDiffOutput(t *testing.T) { + diff := types.TemplateVersionDiff{ + Left: types.TemplateVersionSide{Version: "v1"}, + Right: types.TemplateVersionSide{Version: "v2"}, + ChartDiffs: []types.ChartDiffEntry{ + { + ChartName: "backend", + ChangeType: "modified", + HasDifferences: true, + LeftValues: "replicaCount: 1\nimage:\n tag: v1.0\n", + RightValues: "replicaCount: 2\nimage:\n tag: v2.0\n", + }, + }, + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(diff) + })) + defer server.Close() + + buf := setupStackTestCmd(t, server.URL) + err := templateVersionsDiffCmd.RunE(templateVersionsDiffCmd, []string{"10", "uuid-v1", "uuid-v2"}) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "backend") + // unified diff markers must be present + assert.Contains(t, out, "---") + assert.Contains(t, out, "+++") + assert.Contains(t, out, "@@") + // changed lines must appear + assert.Contains(t, out, "-replicaCount: 1") + assert.Contains(t, out, "+replicaCount: 2") +} diff --git a/cli/pkg/types/types.go b/cli/pkg/types/types.go index 3703071..cf3c436 100644 --- a/cli/pkg/types/types.go +++ b/cli/pkg/types/types.go @@ -483,8 +483,8 @@ type ChartDiffEntry struct { RightRepoURL string `json:"right_repo_url,omitempty" yaml:"right_repo_url,omitempty"` LeftLocked string `json:"left_locked,omitempty" yaml:"left_locked,omitempty"` RightLocked string `json:"right_locked,omitempty" yaml:"right_locked,omitempty"` - LeftRequired bool `json:"left_required,omitempty" yaml:"left_required,omitempty"` - RightRequired bool `json:"right_required,omitempty" yaml:"right_required,omitempty"` - LeftSortOrder int `json:"left_sort_order,omitempty" yaml:"left_sort_order,omitempty"` - RightSortOrder int `json:"right_sort_order,omitempty" yaml:"right_sort_order,omitempty"` + LeftRequired bool `json:"left_required" yaml:"left_required"` + RightRequired bool `json:"right_required" yaml:"right_required"` + LeftSortOrder int `json:"left_sort_order" yaml:"left_sort_order"` + RightSortOrder int `json:"right_sort_order" yaml:"right_sort_order"` }