From d98d5b562192db0149bb0d15ee3cb2789859ef1a Mon Sep 17 00:00:00 2001 From: yxxhero Date: Wed, 28 Jan 2026 20:56:58 +0800 Subject: [PATCH 1/2] Add AI-friendly output format for LLM integration Implement a new 'ai' output format that produces structured JSON with complete diff information, making it easy for Large Language Models to parse and analyze Helm chart changes. The new format includes: - Resource metadata (api, kind, namespace, name, change type) - Full diff content with each line categorized (added/removed/common) - Properly escaped JSON output for reliable parsing Usage: helm diff upgrade --output ai release-name chart-name Resolves #919 Signed-off-by: yxx --- README.md | 10 +++---- cmd/options.go | 2 +- diff/diff_test.go | 18 +++++++++++++ diff/report.go | 66 +++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 90 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index c9839acf..ca6e17e2 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ Flags: --no-color remove colors from the output. If both --no-color and --color are unspecified, coloring enabled only when the stdout is a term and TERM is not "dumb" --no-hooks disable diffing of hooks --normalize-manifests normalize manifests before running diff to exclude style differences from the output - --output string Possible values: diff, simple, template, dyff. When set to "template", use the env var HELM_DIFF_TPL to specify the template. (default "diff") + --output string Possible values: diff, simple, template, json, dyff, ai. When set to "template", use the env var HELM_DIFF_TPL to specify the template. (default "diff") --post-renderer string the path to an executable to be used for post rendering. If it exists in $PATH, the binary will be used, otherwise it will try to look for the executable at the given path --post-renderer-args stringArray an argument to the post-renderer (can specify multiple) --repo string specify the chart repository url to locate the requested chart @@ -211,7 +211,7 @@ Flags: --kubeconfig string This flag is ignored, to allow passing of this top level flag to helm --no-hooks disable diffing of hooks --normalize-manifests normalize manifests before running diff to exclude style differences from the output - --output string Possible values: diff, simple, template, dyff. When set to "template", use the env var HELM_DIFF_TPL to specify the template. (default "diff") + --output string Possible values: diff, simple, template, json, dyff, ai. When set to "template", use the env var HELM_DIFF_TPL to specify the template. (default "diff") --post-renderer string the path to an executable to be used for post rendering. If it exists in $PATH, the binary will be used, otherwise it will try to look for the executable at the given path --post-renderer-args stringArray an argument to the post-renderer (can specify multiple) --repo string specify the chart repository url to locate the requested chart @@ -266,7 +266,7 @@ Flags: -h, --help help for release --include-tests enable the diffing of the helm test hooks --normalize-manifests normalize manifests before running diff to exclude style differences from the output - --output string Possible values: diff, simple, template, dyff. When set to "template", use the env var HELM_DIFF_TPL to specify the template. (default "diff") + --output string Possible values: diff, simple, template, json, dyff, ai. When set to "template", use the env var HELM_DIFF_TPL to specify the template. (default "diff") --show-secrets do not redact secret values in the output --strip-trailing-cr strip trailing carriage return on input --suppress stringArray allows suppression of the kinds listed in the diff output (can specify multiple, like '--suppress Deployment --suppress Service') @@ -308,7 +308,7 @@ Flags: -h, --help help for revision --include-tests enable the diffing of the helm test hooks --normalize-manifests normalize manifests before running diff to exclude style differences from the output - --output string Possible values: diff, simple, template, dyff. When set to "template", use the env var HELM_DIFF_TPL to specify the template. (default "diff") + --output string Possible values: diff, simple, template, json, dyff, ai. When set to "template", use the env var HELM_DIFF_TPL to specify the template. (default "diff") --show-secrets do not redact secret values in the output --show-secrets-decoded decode secret values in the output --strip-trailing-cr strip trailing carriage return on input @@ -344,7 +344,7 @@ Flags: -h, --help help for rollback --include-tests enable the diffing of the helm test hooks --normalize-manifests normalize manifests before running diff to exclude style differences from the output - --output string Possible values: diff, simple, template, dyff. When set to "template", use the env var HELM_DIFF_TPL to specify the template. (default "diff") + --output string Possible values: diff, simple, template, json, dyff, ai. When set to "template", use the env var HELM_DIFF_TPL to specify the template. (default "diff") --show-secrets do not redact secret values in the output --show-secrets-decoded decode secret values in the output --strip-trailing-cr strip trailing carriage return on input diff --git a/cmd/options.go b/cmd/options.go index 502b73d7..a013fdd6 100644 --- a/cmd/options.go +++ b/cmd/options.go @@ -13,7 +13,7 @@ func AddDiffOptions(f *pflag.FlagSet, o *diff.Options) { f.BoolVar(&o.ShowSecretsDecoded, "show-secrets-decoded", false, "decode secret values in the output") f.StringArrayVar(&o.SuppressedKinds, "suppress", []string{}, "allows suppression of the kinds listed in the diff output (can specify multiple, like '--suppress Deployment --suppress Service')") f.IntVarP(&o.OutputContext, "context", "C", -1, "output NUM lines of context around changes") - f.StringVar(&o.OutputFormat, "output", "diff", "Possible values: diff, simple, template, dyff. When set to \"template\", use the env var HELM_DIFF_TPL to specify the template.") + f.StringVar(&o.OutputFormat, "output", "diff", "Possible values: diff, simple, template, json, dyff, ai. When set to \"template\", use the env var HELM_DIFF_TPL to specify the template.") f.BoolVar(&o.StripTrailingCR, "strip-trailing-cr", false, "strip trailing carriage return on input") f.Float32VarP(&o.FindRenames, "find-renames", "D", 0, "Enable rename detection if set to any value greater than 0. If specified, the value denotes the maximum fraction of changed content as lines added + removed compared to total lines in a diff for considering it a rename. Only objects of the same Kind are attempted to be matched") f.StringArrayVar(&o.SuppressedOutputLineRegex, "suppress-output-line-regex", []string{}, "a regex to suppress diff output lines that match") diff --git a/diff/diff_test.go b/diff/diff_test.go index 56b844f7..bd64929c 100644 --- a/diff/diff_test.go +++ b/diff/diff_test.go @@ -561,6 +561,24 @@ Plan: 0 to add, 1 to change, 0 to destroy, 0 to change ownership. `, buf1.String()) }) + t.Run("OnChangeAI", func(t *testing.T) { + var buf1 bytes.Buffer + diffOptions := Options{"ai", 10, false, true, false, []string{}, 0.0, []string{}} + + if changesSeen := Manifests(specBeta, specRelease, &diffOptions, &buf1); !changesSeen { + t.Error("Unexpected return value from Manifests: Expected the return value to be `true` to indicate that it has seen any change(s), but was `false`") + } + + require.Contains(t, buf1.String(), `"api": "apps"`) + require.Contains(t, buf1.String(), `"kind": "Deployment"`) + require.Contains(t, buf1.String(), `"namespace": "default"`) + require.Contains(t, buf1.String(), `"name": "nginx"`) + require.Contains(t, buf1.String(), `"change": "MODIFY"`) + require.Contains(t, buf1.String(), `"diffs"`) + require.Contains(t, buf1.String(), `"type"`) + require.Contains(t, buf1.String(), `"content"`) + }) + t.Run("OnNoChangeTemplate", func(t *testing.T) { var buf2 bytes.Buffer diffOptions := Options{"template", 10, false, true, false, []string{}, 0.0, []string{}} diff --git a/diff/report.go b/diff/report.go index ac456365..b28ece32 100644 --- a/diff/report.go +++ b/diff/report.go @@ -1,6 +1,7 @@ package diff import ( + "encoding/json" "errors" "fmt" "io" @@ -65,6 +66,8 @@ func (r *Report) setupReportFormat(format string) { setupJSONReport(r) case "dyff": setupDyffReport(r) + case "ai": + setupAIReport(r) default: setupDiffReport(r) } @@ -74,6 +77,10 @@ func setupDyffReport(r *Report) { r.format.output = printDyffReport } +func setupAIReport(r *Report) { + r.format.output = printAIReport +} + func printDyffReport(r *Report, to io.Writer) { currentFile, _ := os.CreateTemp("", "existing-values") defer func() { @@ -113,6 +120,65 @@ func printDyffReport(r *Report, to io.Writer) { _ = reportWriter.WriteReport(to) } +func printAIReport(r *Report, to io.Writer) { + _, _ = fmt.Fprint(to, "[\n") + for i, entry := range r.Entries { + templateData := ReportTemplateSpec{} + err := templateData.loadFromKey(entry.Key) + if err != nil { + log.Println("error processing report entry") + continue + } + + _, _ = fmt.Fprintf(to, " {\n") + _, _ = fmt.Fprintf(to, " \"api\": \"%s\",\n", escapeJSON(templateData.API)) + _, _ = fmt.Fprintf(to, " \"kind\": \"%s\",\n", escapeJSON(templateData.Kind)) + _, _ = fmt.Fprintf(to, " \"namespace\": \"%s\",\n", escapeJSON(templateData.Namespace)) + _, _ = fmt.Fprintf(to, " \"name\": \"%s\",\n", escapeJSON(templateData.Name)) + _, _ = fmt.Fprintf(to, " \"change\": \"%s\",\n", escapeJSON(entry.ChangeType)) + _, _ = fmt.Fprintf(to, " \"diffs\": [\n") + + for j, record := range entry.Diffs { + deltaType := "common" + switch record.Delta { + case difflib.LeftOnly: + deltaType = "removed" + case difflib.RightOnly: + deltaType = "added" + } + + _, _ = fmt.Fprintf(to, " {\n") + _, _ = fmt.Fprintf(to, " \"type\": \"%s\",\n", deltaType) + _, _ = fmt.Fprintf(to, " \"content\": %s\n", escapeJSONString(record.Payload)) + if j < len(entry.Diffs)-1 { + _, _ = fmt.Fprint(to, " },\n") + } else { + _, _ = fmt.Fprint(to, " }\n") + } + } + + if i < len(r.Entries)-1 { + _, _ = fmt.Fprintf(to, " ]\n },\n") + } else { + _, _ = fmt.Fprintf(to, " ]\n }\n") + } + } + _, _ = fmt.Fprint(to, "]\n") +} + +func escapeJSON(s string) string { + if s == "" { + return "" + } + b, _ := json.Marshal(s) + return string(b[1 : len(b)-1]) +} + +func escapeJSONString(s string) string { + b, _ := json.Marshal(s) + return string(b) +} + // addEntry: stores diff changes. func (r *Report) addEntry(key string, suppressedKinds []string, kind string, context int, diffs []difflib.DiffRecord, changeType string) { entry := ReportEntry{ From 502f37d54752e675f8905a83aed9ed004084f24f Mon Sep 17 00:00:00 2001 From: yxxhero Date: Wed, 28 Jan 2026 21:43:39 +0800 Subject: [PATCH 2/2] refactor: Improve AI report output format for better readability - Replace manual JSON string concatenation with structured Go types and json.Encoder - Restructure output to be more AI-friendly with separate metadata, summary, and content sections - Categorize changes into added/removed/modified arrays for clearer diff presentation - Add human-readable summary field (e.g., "Modified: +1, -1") for quick overview - Remove manual escape functions in favor of automatic JSON encoding - Add comprehensive unit tests covering multiple scenarios - Update existing tests to match new output format The new format provides: - Structured metadata for resource identification - Clear categorization of changes without noise from "common" lines - Summary field for quick understanding of change impact - Better maintainability through type safety and automatic JSON handling Signed-off-by: yxxhero --- diff/diff_test.go | 4 +- diff/report.go | 115 ++++++++++++++++++++++---------- diff/report_test.go | 155 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 237 insertions(+), 37 deletions(-) diff --git a/diff/diff_test.go b/diff/diff_test.go index bd64929c..d0d68986 100644 --- a/diff/diff_test.go +++ b/diff/diff_test.go @@ -574,8 +574,8 @@ Plan: 0 to add, 1 to change, 0 to destroy, 0 to change ownership. require.Contains(t, buf1.String(), `"namespace": "default"`) require.Contains(t, buf1.String(), `"name": "nginx"`) require.Contains(t, buf1.String(), `"change": "MODIFY"`) - require.Contains(t, buf1.String(), `"diffs"`) - require.Contains(t, buf1.String(), `"type"`) + require.Contains(t, buf1.String(), `"summary"`) + require.Contains(t, buf1.String(), `"metadata"`) require.Contains(t, buf1.String(), `"content"`) }) diff --git a/diff/report.go b/diff/report.go index b28ece32..52a79f33 100644 --- a/diff/report.go +++ b/diff/report.go @@ -120,63 +120,108 @@ func printDyffReport(r *Report, to io.Writer) { _ = reportWriter.WriteReport(to) } +type aiResourceChange struct { + Metadata aiResourceMetadata `json:"metadata"` + Change string `json:"change"` + Summary string `json:"summary"` + Content aiChangeContent `json:"content,omitempty"` +} + +type aiResourceMetadata struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + Kind string `json:"kind"` + API string `json:"api"` +} + +type aiChangeContent struct { + Added []string `json:"added,omitempty"` + Removed []string `json:"removed,omitempty"` + Modified []string `json:"modified,omitempty"` +} + func printAIReport(r *Report, to io.Writer) { - _, _ = fmt.Fprint(to, "[\n") - for i, entry := range r.Entries { + encoder := json.NewEncoder(to) + encoder.SetIndent("", " ") + + changes := make([]aiResourceChange, 0, len(r.Entries)) + + for _, entry := range r.Entries { templateData := ReportTemplateSpec{} - err := templateData.loadFromKey(entry.Key) - if err != nil { + if err := templateData.loadFromKey(entry.Key); err != nil { log.Println("error processing report entry") continue } - _, _ = fmt.Fprintf(to, " {\n") - _, _ = fmt.Fprintf(to, " \"api\": \"%s\",\n", escapeJSON(templateData.API)) - _, _ = fmt.Fprintf(to, " \"kind\": \"%s\",\n", escapeJSON(templateData.Kind)) - _, _ = fmt.Fprintf(to, " \"namespace\": \"%s\",\n", escapeJSON(templateData.Namespace)) - _, _ = fmt.Fprintf(to, " \"name\": \"%s\",\n", escapeJSON(templateData.Name)) - _, _ = fmt.Fprintf(to, " \"change\": \"%s\",\n", escapeJSON(entry.ChangeType)) - _, _ = fmt.Fprintf(to, " \"diffs\": [\n") + change := aiResourceChange{ + Metadata: aiResourceMetadata{ + Namespace: templateData.Namespace, + Name: templateData.Name, + Kind: templateData.Kind, + API: templateData.API, + }, + Change: entry.ChangeType, + } + + var added, removed, modified []string - for j, record := range entry.Diffs { - deltaType := "common" + for _, record := range entry.Diffs { switch record.Delta { case difflib.LeftOnly: - deltaType = "removed" + removed = append(removed, record.Payload) case difflib.RightOnly: - deltaType = "added" + added = append(added, record.Payload) + case difflib.Common: + modified = append(modified, record.Payload) } + } - _, _ = fmt.Fprintf(to, " {\n") - _, _ = fmt.Fprintf(to, " \"type\": \"%s\",\n", deltaType) - _, _ = fmt.Fprintf(to, " \"content\": %s\n", escapeJSONString(record.Payload)) - if j < len(entry.Diffs)-1 { - _, _ = fmt.Fprint(to, " },\n") - } else { - _, _ = fmt.Fprint(to, " }\n") + if len(added) > 0 || len(removed) > 0 { + change.Content = aiChangeContent{ + Added: added, + Removed: removed, + Modified: modified, } - } - if i < len(r.Entries)-1 { - _, _ = fmt.Fprintf(to, " ]\n },\n") + change.Summary = generateChangeSummary(added, removed, entry.ChangeType) } else { - _, _ = fmt.Fprintf(to, " ]\n }\n") + change.Summary = getChangeDescription(entry.ChangeType) } + + changes = append(changes, change) } - _, _ = fmt.Fprint(to, "]\n") + + _ = encoder.Encode(changes) } -func escapeJSON(s string) string { - if s == "" { - return "" +func generateChangeSummary(added, removed []string, changeType string) string { + addedCount := len(added) + removedCount := len(removed) + + switch { + case changeType == "ADD" && addedCount > 0: + return fmt.Sprintf("Added %d lines", addedCount) + case changeType == "REMOVE" && removedCount > 0: + return fmt.Sprintf("Removed %d lines", removedCount) + case changeType == "MODIFY": + return fmt.Sprintf("Modified: +%d, -%d", addedCount, removedCount) + default: + return fmt.Sprintf("%d additions, %d deletions", addedCount, removedCount) } - b, _ := json.Marshal(s) - return string(b[1 : len(b)-1]) } -func escapeJSONString(s string) string { - b, _ := json.Marshal(s) - return string(b) +func getChangeDescription(changeType string) string { + descriptions := map[string]string{ + "ADD": "Resource created", + "REMOVE": "Resource deleted", + "MODIFY": "Resource updated", + "OWNERSHIP": "Ownership changed", + "MODIFY_SUPPRESSED": "Resource modified (diff suppressed)", + } + if desc, ok := descriptions[changeType]; ok { + return desc + } + return changeType } // addEntry: stores diff changes. diff --git a/diff/report_test.go b/diff/report_test.go index 1a2aff60..65781dba 100644 --- a/diff/report_test.go +++ b/diff/report_test.go @@ -1,8 +1,12 @@ package diff import ( + "bytes" + "encoding/json" + "strings" "testing" + "github.com/aryann/difflib" "github.com/stretchr/testify/require" ) @@ -34,3 +38,154 @@ func TestLoadFromKey(t *testing.T) { require.Equal(t, expectedTemplateSpec, *templateSpec) } } + +func TestPrintAIReport(t *testing.T) { + tests := []struct { + name string + report *Report + expected string + }{ + { + name: "single entry with modify diff", + report: &Report{ + Entries: []ReportEntry{ + { + Key: "default, nginx, Deployment (apps)", + ChangeType: "MODIFY", + Diffs: []difflib.DiffRecord{ + {Delta: difflib.Common, Payload: "spec:"}, + {Delta: difflib.RightOnly, Payload: " replicas: 3"}, + {Delta: difflib.LeftOnly, Payload: " replicas: 2"}, + }, + }, + }, + }, + expected: `[ + { + "change": "MODIFY", + "summary": "Modified: +1, -1", + "metadata": { + "namespace": "default", + "name": "nginx", + "kind": "Deployment", + "api": "apps" + }, + "content": { + "added": [ + " replicas: 3" + ], + "removed": [ + " replicas: 2" + ], + "modified": [ + "spec:" + ] + } + } +]`, + }, + { + name: "multiple entries", + report: &Report{ + Entries: []ReportEntry{ + { + Key: "default, nginx, Deployment (apps)", + ChangeType: "ADD", + Diffs: []difflib.DiffRecord{{Delta: difflib.RightOnly, Payload: "spec:"}}, + }, + { + Key: "default, redis, Service (v1)", + ChangeType: "REMOVE", + Diffs: []difflib.DiffRecord{{Delta: difflib.LeftOnly, Payload: "spec:"}}, + }, + }, + }, + expected: `[ + { + "change": "ADD", + "summary": "Added 1 lines", + "metadata": { + "namespace": "default", + "name": "nginx", + "kind": "Deployment", + "api": "apps" + }, + "content": { + "added": [ + "spec:" + ] + } + }, + { + "change": "REMOVE", + "summary": "Removed 1 lines", + "metadata": { + "namespace": "default", + "name": "redis", + "kind": "Service", + "api": "v1" + }, + "content": { + "removed": [ + "spec:" + ] + } + } +]`, + }, + { + name: "empty report", + report: &Report{Entries: []ReportEntry{}}, + expected: "[]\n", + }, + { + name: "entry with special characters in content", + report: &Report{ + Entries: []ReportEntry{ + { + Key: "default, test-config, ConfigMap (v1)", + ChangeType: "MODIFY", + Diffs: []difflib.DiffRecord{ + {Delta: difflib.RightOnly, Payload: `key: "value with \"quotes\""`}, + {Delta: difflib.RightOnly, Payload: "another: new\nline"}, + }, + }, + }, + }, + expected: `[ + { + "change": "MODIFY", + "summary": "Modified: +2, -0", + "metadata": { + "namespace": "default", + "name": "test-config", + "kind": "ConfigMap", + "api": "v1" + }, + "content": { + "added": [ + "key: \"value with \\\"quotes\\\"\"", + "another: new\nline" + ] + } + } +]`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + printAIReport(tt.report, &buf) + + actual := strings.TrimSpace(buf.String()) + expected := strings.TrimSpace(tt.expected) + + var actualJSON, expectedJSON interface{} + require.NoError(t, json.Unmarshal([]byte(actual), &actualJSON)) + require.NoError(t, json.Unmarshal([]byte(expected), &expectedJSON)) + + require.Equal(t, expectedJSON, actualJSON) + }) + } +}