Skip to content

Conversation

@madhunzv
Copy link

@madhunzv madhunzv commented Dec 12, 2025

Summary
introduce a new --output structured to render machine-readable JSON with API/Kind metadata, resource existence state, and per-field change records (path + field) built from JSON Pointer tokens
structured output was added as a separate format (rather than altering the existing json output) to avoid breaking changes.
Note: this change set was implemented with assistance from an LLM.

@madhunzv
Copy link
Author

madhunzv commented Jan 2, 2026

Hi @yxxhero Any thoughts on this PR for the structured output mode? Would you prefer using the existing JSON mode instead for the proposed change? Happy to address any questions or feedback and refine the implementation further.

@yxxhero
Copy link
Collaborator

yxxhero commented Jan 2, 2026

@madhunzv what's the difference? do you think which is better?

@madhunzv
Copy link
Author

madhunzv commented Jan 2, 2026

The current JSON output reports high-level metadata and the change type (add / remove / replace) at the resource level.
The proposed structured output goes a step further by including which specific fields changed within each resource, making it more useful for programmatic consumption and detailed analysis.

I don’t think one is inherently better than the other, the structured format essentially extends what the JSON output already provides. I chose to introduce it as a separate output mode to avoid changing the existing JSON format and any potentially breaking consumers that rely on it.

@yxxhero
Copy link
Collaborator

yxxhero commented Jan 3, 2026

@madhunzv make senses.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces a new --output structured format for helm-diff that produces machine-readable JSON output with detailed per-field change information. The structured format uses JSON merge patches to track changes and formats paths using a dot-notation with array indices, making the output suitable for programmatic consumption and CI/CD automation.

Key Changes

  • New structured output format with API/Kind metadata, resource existence flags, and field-level change tracking
  • Integration of JSON patch library to compute fine-grained diffs between manifests
  • Modification of exit code behavior to disable detailed-exitcode when structured output is enabled

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
diff/structured.go Implements structured output format with JSON patch-based diff calculation and path formatting logic for field changes
diff/report.go Adds structured report setup and print function, stores mode field to track output format
diff/diff.go Adds StructuredOutput() helper method and integrates structured entry building into diff generation flow
diff/diff_test.go Adds test cases for structured output covering MODIFY, ADD, REMOVE operations and suppressed kinds
cmd/upgrade.go Disables detailed-exitcode when structured output is enabled
cmd/rollback.go Disables detailed-exitcode when structured output is enabled
cmd/revision.go Disables detailed-exitcode when structured output is enabled
cmd/release.go Disables detailed-exitcode when structured output is enabled
cmd/options.go Updates flag documentation to include "structured" as a valid output format
README.md Adds documentation section explaining structured output format with examples

}
encoder := json.NewEncoder(to)
encoder.SetIndent("", " ")
_ = encoder.Encode(entries)
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error from encoder.Encode() should be handled instead of being silently discarded. JSON encoding can fail for various reasons (e.g., I/O errors, circular references) and these errors should be reported to the user. Consider returning the error or at least logging it.

Copilot uses AI. Check for mistakes.
if report.mode == "structured" {
entry, err := buildStructuredEntry(key, changeType, subjectKind, options.SuppressedKinds, oldContent, newContent)
if err != nil {
panic(err)
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The panic on error is inconsistent with standard Go error handling practices. While there is an existing panic at line 52, introducing a new panic here makes error recovery difficult for consumers of this package. Consider propagating the error up the call stack by modifying doDiff to return an error, or at least logging the error with more context before panicking.

Suggested change
panic(err)
fmt.Printf("failed to build structured diff entry for key %s: %v\n", key, err)
return

Copilot uses AI. Check for mistakes.
}

seenAnyChanges := diff.ManifestsOwnership(currentSpecs, newSpecs, newOwnedReleases, &d.Options, os.Stdout)

Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The conditional logic that skips detailed exit code when structured output is enabled should be documented in the README. Users relying on --detailed-exitcode for CI/CD automation need to know that this behavior changes when using --output structured. Consider adding a note to the "Structured JSON output" section explaining that detailed-exitcode is disabled with structured output, or provide justification for why this is the intended behavior.

Suggested change
// When using structured output (e.g. `--output structured`), callers are expected
// to inspect the JSON result to determine whether changes were detected, rather
// than relying on the special exit code used by `--detailed-exitcode`.
// Therefore, detailed exit codes are only honored when structured output is
// disabled to avoid conflicting signaling mechanisms in CI/CD pipelines.

Copilot uses AI. Check for mistakes.
Comment on lines +589 to +717
func TestStructuredOutputModify(t *testing.T) {
ansi.DisableColors(true)
opts := &Options{OutputFormat: "structured"}
oldManifest := `
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: prod
spec:
replicas: 2
template:
spec:
containers:
- name: app
image: demo:v1
`
newManifest := `
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
namespace: prod
spec:
replicas: 3
template:
spec:
containers:
- name: app
image: demo:v2
`
oldIndex := manifest.Parse(oldManifest, "prod", true)
newIndex := manifest.Parse(newManifest, "prod", true)

var buf bytes.Buffer
changed := Manifests(oldIndex, newIndex, opts, &buf)
require.True(t, changed)

var entries []StructuredEntry
require.NoError(t, json.Unmarshal(buf.Bytes(), &entries))
require.Len(t, entries, 1)
entry := entries[0]
require.Equal(t, "MODIFY", entry.ChangeType)
require.Equal(t, "apps/v1", entry.APIVersion)
require.Equal(t, "Deployment", entry.Kind)
require.Equal(t, "prod", entry.Namespace)
require.Equal(t, "web", entry.Name)
require.Len(t, entry.Changes, 2)
replicasChange, ok := findChange(entry.Changes, "spec", "replicas")
require.True(t, ok)
require.InDelta(t, float64(2), replicasChange.OldValue, 0.001)
require.InDelta(t, float64(3), replicasChange.NewValue, 0.001)

imageChange, ok := findChange(entry.Changes, "spec.template.spec.containers[0]", "image")
require.True(t, ok)
require.Equal(t, "demo:v1", imageChange.OldValue)
require.Equal(t, "demo:v2", imageChange.NewValue)
}

func TestStructuredOutputAddAndRemove(t *testing.T) {
ansi.DisableColors(true)
opts := &Options{OutputFormat: "structured"}
newManifest := `
apiVersion: batch/v1
kind: Job
metadata:
name: migrate
namespace: ops
spec: {}
`
newIndex := manifest.Parse(newManifest, "ops", true)

var buf bytes.Buffer
changed := Manifests(map[string]*manifest.MappingResult{}, newIndex, opts, &buf)
require.True(t, changed)

var entries []StructuredEntry
require.NoError(t, json.Unmarshal(buf.Bytes(), &entries))
require.Len(t, entries, 1)
require.Equal(t, "ADD", entries[0].ChangeType)
require.True(t, entries[0].ResourceStatus.NewExists)
require.False(t, entries[0].ResourceStatus.OldExists)

// Now test removal
buf.Reset()
changed = Manifests(newIndex, map[string]*manifest.MappingResult{}, opts, &buf)
require.True(t, changed)
require.NoError(t, json.Unmarshal(buf.Bytes(), &entries))
require.Len(t, entries, 1)
require.Equal(t, "REMOVE", entries[0].ChangeType)
require.True(t, entries[0].ResourceStatus.OldExists)
require.False(t, entries[0].ResourceStatus.NewExists)
}

func TestStructuredOutputSuppressedKind(t *testing.T) {
ansi.DisableColors(true)
opts := &Options{
OutputFormat: "structured",
SuppressedKinds: []string{"Secret"},
}
oldManifest := `
apiVersion: v1
kind: Secret
metadata:
name: creds
data:
password: c29tZQ==
`
newManifest := `
apiVersion: v1
kind: Secret
metadata:
name: creds
data:
password: Zm9v
`
oldIndex := manifest.Parse(oldManifest, "default", true)
newIndex := manifest.Parse(newManifest, "default", true)

var buf bytes.Buffer
changed := Manifests(oldIndex, newIndex, opts, &buf)
require.True(t, changed)

var entries []StructuredEntry
require.NoError(t, json.Unmarshal(buf.Bytes(), &entries))
require.Len(t, entries, 1)
require.True(t, entries[0].ChangesSuppressed)
require.Empty(t, entries[0].Changes)
}
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Test coverage is missing for error paths in structured output. Specifically, there are no tests for scenarios where buildStructuredEntry might fail (e.g., invalid YAML that cannot be converted to JSON, malformed JSON that cannot be unmarshaled). Given that these errors result in panics, it's important to verify that the error handling works correctly and provides useful error messages. Consider adding tests that exercise error paths such as invalid YAML content or JSON parsing failures.

Copilot uses AI. Check for mistakes.
Comment on lines +252 to +277
if report.mode != "structured" && newContent != nil {
emptyMapping := &manifest.MappingResult{}
diffs = diffMappingResults(emptyMapping, newContent, options.StripTrailingCR)
}
case newContent == nil:
changeType = "REMOVE"
if oldContent != nil {
subjectKind = oldContent.Kind
}
if report.mode != "structured" && oldContent != nil {
emptyMapping := &manifest.MappingResult{}
diffs = diffMappingResults(oldContent, emptyMapping, options.StripTrailingCR)
}
default:
changeType = "MODIFY"
subjectKind = oldContent.Kind
if report.mode != "structured" {
diffs = diffMappingResults(oldContent, newContent, options.StripTrailingCR)
if actualChanges(diffs) == 0 {
return
}
}
}

var structured *StructuredEntry
if report.mode == "structured" {
Copy link

Copilot AI Jan 3, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using report.mode to check for "structured" output creates indirect coupling and makes the code less maintainable. The mode field duplicates information already available in options.OutputFormat, and checking the string value directly in multiple places increases the risk of typos or inconsistencies. Consider using the Options.StructuredOutput() method that's already defined in this file, which provides a cleaner, more maintainable interface and is less error-prone.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants