From 5d2f91488ad617d2c15b18bfa82b27871aa68523 Mon Sep 17 00:00:00 2001 From: quobix Date: Wed, 17 Dec 2025 11:23:35 -0500 Subject: [PATCH 1/2] Added config feature for breaking changes. https://pb33f.io/openapi-changes/configuring/ --- README.md | 81 +++++ builder/tree.go | 98 +++-- cmd/config.go | 216 +++++++++++ cmd/config_test.go | 339 ++++++++++++++++++ cmd/console.go | 33 +- cmd/html_report.go | 39 +- cmd/markdown_report.go | 39 +- cmd/report.go | 33 +- cmd/root.go | 1 + cmd/summary.go | 33 +- cmd/test_files/spec_left.yaml | 19 + cmd/test_files/spec_right.yaml | 19 + cmd/test_files/test-config.yaml | 34 ++ cmd/tree.go | 263 ++++++++++---- git/github.go | 13 +- git/read_local.go | 13 +- go.mod | 31 +- go.sum | 96 ++--- internal/security/scope_detection.go | 75 ++++ .../sample_configs/invalid-config.yaml | 12 + .../sample_configs/partial-config.yaml | 17 + sample-specs/sample_configs/valid-config.yaml | 104 ++++++ tui/build_tree.go | 81 ++++- 23 files changed, 1452 insertions(+), 237 deletions(-) create mode 100644 cmd/config.go create mode 100644 cmd/config_test.go create mode 100644 cmd/test_files/spec_left.yaml create mode 100644 cmd/test_files/spec_right.yaml create mode 100644 cmd/test_files/test-config.yaml create mode 100644 internal/security/scope_detection.go create mode 100644 sample-specs/sample_configs/invalid-config.yaml create mode 100644 sample-specs/sample_configs/partial-config.yaml create mode 100644 sample-specs/sample_configs/valid-config.yaml diff --git a/README.md b/README.md index ce274e6..b36e971 100644 --- a/README.md +++ b/README.md @@ -41,6 +41,7 @@ What about a terminal UI that does the same? See all the documentation at https://pb33f.io/openapi-changes/ - [Installing openapi-changes](https://pb33f.io/openapi-changes/installing/) +- [Configuring breaking changes](https://pb33f.io/openapi-changes/configuring/) - [Command arguments](https://pb33f.io/openapi-changes/command-arguments/) - CLI Commands - [`console` command](https://pb33f.io/openapi-changes/console/) @@ -99,4 +100,84 @@ docker run --rm -v $PWD:/work:rw pb33f/openapi-changes summary . sample-specs/pe --- +## Custom Breaking Rules Configuration + +> Supported in `v0.91+` + +openapi-changes uses [libopenapi](https://github.com/pb33f/libopenapi)'s configurable breaking change +detection system. You can customize which changes are considered "breaking" by providing a configuration file. + +### Using a Config File + +```bash +# Use explicit config file +openapi-changes summary -c my-rules.yaml old.yaml new.yaml + +# Or place changes-rules.yaml in current directory (auto-detected) +openapi-changes summary old.yaml new.yaml +``` + +### Default Config Locations + +openapi-changes searches for `changes-rules.yaml` in: +1. Current working directory (`./changes-rules.yaml`) +2. User config directory (`~/.config/changes-rules.yaml`) + +### Example Configuration + +Create a `changes-rules.yaml` file: + +```yaml +# Custom breaking rules configuration +# Only specify overrides - unspecified rules use defaults + +# Make operation removal non-breaking (for deprecation workflows) +pathItem: + get: + removed: false + post: + removed: false + put: + removed: false + delete: + removed: false + +# Make enum value removal non-breaking +schema: + enum: + removed: false + +# Make parameter changes non-breaking +parameter: + required: + modified: false +``` + +### Configuration Structure + +Each rule has three options: +- `added`: Is adding this property a breaking change? (true/false) +- `modified`: Is modifying this property a breaking change? (true/false) +- `removed`: Is removing this property a breaking change? (true/false) + +### Available Components + +You can configure rules for these OpenAPI components: + +| Component | Description | +|-----------------------|----------------------------------------------------| +| `paths` | Path definitions | +| `pathItem` | Operations (get, post, put, delete, etc.) | +| `operation` | Operation details (operationId, requestBody, etc.) | +| `parameter` | Parameter properties (name, required, schema) | +| `schema` | Schema properties (type, format, enum, properties) | +| `response` | Response definitions | +| `securityScheme` | Security scheme properties | +| `securityRequirement` | Security requirements | + +For the complete list of configurable properties and more examples, see the +[full configuration documentation](https://pb33f.io/openapi-changes/configuring/). + +--- + Check out all the docs at https://pb33f.io/openapi-changes/ diff --git a/builder/tree.go b/builder/tree.go index 167495a..c0a92fa 100644 --- a/builder/tree.go +++ b/builder/tree.go @@ -4,15 +4,17 @@ package builder import ( + "reflect" + "strings" + "github.com/google/uuid" v3 "github.com/pb33f/libopenapi/datamodel/low/v3" wcModel "github.com/pb33f/libopenapi/what-changed/model" "github.com/pb33f/libopenapi/what-changed/reports" + "github.com/pb33f/openapi-changes/internal/security" "github.com/pb33f/openapi-changes/model" "golang.org/x/text/cases" "golang.org/x/text/language" - "reflect" - "strings" ) var upper cases.Caser @@ -50,13 +52,23 @@ func exploreTreeObject(parent *model.TreeNode, object any) { topChanges := field.Elem().Interface().(wcModel.PropertyChanges).Changes for x := range topChanges { title := topChanges[x].Property - if strings.ToLower(topChanges[x].Property) == "codes" { - switch topChanges[x].ChangeType { - case wcModel.Modified, wcModel.PropertyRemoved, wcModel.ObjectRemoved: - title = topChanges[x].Original - break - case wcModel.ObjectAdded, wcModel.PropertyAdded: - title = topChanges[x].New + + // Special handling for security scope changes (scheme/scope format) + if security.IsSecurityScopeChange(topChanges[x]) { + title = security.FormatSecurityScopeTitle(topChanges[x]) + } else { + lowerProp := strings.ToLower(topChanges[x].Property) + if lowerProp == "codes" || lowerProp == "tags" { + switch topChanges[x].ChangeType { + case wcModel.Modified, wcModel.PropertyRemoved, wcModel.ObjectRemoved: + if topChanges[x].Original != "" { + title = topChanges[x].Original + } + case wcModel.ObjectAdded, wcModel.PropertyAdded: + if topChanges[x].New != "" { + title = topChanges[x].New + } + } } } @@ -239,7 +251,7 @@ func exploreTreeObject(parent *model.TreeNode, object any) { case reflect.TypeOf(map[string]*wcModel.CallbackChanges{}): if !field.IsZero() && len(field.MapKeys()) > 0 { - BuildTreeMapNode(parent, field) + BuildTreeMapNodeWithLabel(parent, field, "Callbacks") } case reflect.TypeOf(map[string]*wcModel.ExampleChanges{}): @@ -317,29 +329,37 @@ func transformLabel(in string) string { } func DigIntoTreeNodeSlice[T any](parent *model.TreeNode, field reflect.Value, label string) { - if !field.IsZero() { + if !field.IsZero() && field.Len() > 0 { + // Create ONE parent node for all elements + parentNode := &model.TreeNode{ + TitleString: transformLabel(label), + Key: uuid.New().String(), + IsLeaf: false, + Selectable: false, + Disabled: false, + } + + totalChanges := 0 + breakingChanges := 0 + for k := 0; k < field.Len(); k++ { f := field.Index(k) if f.Elem().IsValid() && !f.Elem().IsZero() { - e := &model.TreeNode{ - TitleString: transformLabel(label), - Key: uuid.New().String(), - IsLeaf: false, - Selectable: false, - Disabled: false, - } obj := f.Elem().Interface().(T) ch, br := countChanges(obj) if ch > -1 { - e.TotalChanges = ch + totalChanges += ch } if br > -1 { - e.BreakingChanges = br + breakingChanges += br } - parent.Children = append(parent.Children, e) - exploreTreeObject(e, &obj) + exploreTreeObject(parentNode, &obj) } } + + parentNode.TotalChanges = totalChanges + parentNode.BreakingChanges = breakingChanges + parent.Children = append(parent.Children, parentNode) } } @@ -370,6 +390,40 @@ func BuildTreeMapNode(parent *model.TreeNode, field reflect.Value) { } } +// BuildTreeMapNodeWithLabel creates a labeled parent node and adds map children under it. +// Use this for map fields that should appear as a named section (e.g., "Callbacks"). +func BuildTreeMapNodeWithLabel(parent *model.TreeNode, field reflect.Value, label string) { + if !field.IsZero() && len(field.MapKeys()) > 0 { + // Calculate total changes for the label node + totalChanges := 0 + breakingChanges := 0 + for _, e := range field.MapKeys() { + v := field.MapIndex(e) + if ch, br := countChanges(v.Interface()); ch > -1 { + totalChanges += ch + if br > -1 { + breakingChanges += br + } + } + } + + // Create the labeled parent node + labelNode := &model.TreeNode{ + TitleString: label, + Key: uuid.New().String(), + IsLeaf: false, + Selectable: false, + Disabled: false, + TotalChanges: totalChanges, + BreakingChanges: breakingChanges, + } + parent.Children = append(parent.Children, labelNode) + + // Add map entries as children of the label node + BuildTreeMapNode(labelNode, field) + } +} + func countChanges(i any) (int, int) { if ch, ok := i.(reports.HasChanges); ok { return ch.TotalChanges(), ch.TotalBreakingChanges() diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..007bf34 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,216 @@ +// Copyright 2022-2025 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/pb33f/libopenapi/what-changed/model" + "github.com/pterm/pterm" + "go.yaml.in/yaml/v4" +) + +const ( + // DefaultConfigFileName is the default name for the breaking rules config file + DefaultConfigFileName = "changes-rules.yaml" +) + +// LoadBreakingRulesConfig loads a breaking rules configuration from the specified path. +// If configPath is empty, it searches default locations (current directory, then ~/.config). +// Returns nil config if no config is found in default locations (uses libopenapi defaults). +// Returns error if user-specified config path doesn't exist or has invalid YAML. +func LoadBreakingRulesConfig(configPath string) (*model.BreakingRulesConfig, error) { + // If user specified a config path, it must exist + if configPath != "" { + return loadConfigFromPath(configPath, true) + } + + // Check default locations + defaultPaths := getDefaultConfigPaths() + for _, path := range defaultPaths { + config, err := loadConfigFromPath(path, false) + if err != nil { + // Return error only for YAML parsing errors, not for missing files + return nil, err + } + if config != nil { + return config, nil + } + } + + // No config found in default locations - return nil to use libopenapi defaults + return nil, nil +} + +// loadConfigFromPath loads config from a specific path. +// If required is true, returns error if file doesn't exist. +// If required is false, returns nil, nil if file doesn't exist. +func loadConfigFromPath(configPath string, required bool) (*model.BreakingRulesConfig, error) { + // Expand ~ to home directory + expandedPath, err := expandUserPath(configPath) + if err != nil { + return nil, fmt.Errorf("failed to expand config path '%s': %w", configPath, err) + } + + _, err = os.Stat(expandedPath) + if os.IsNotExist(err) { + if required { + return nil, fmt.Errorf("config file not found: %s", expandedPath) + } + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to access config file '%s': %w", expandedPath, err) + } + + data, err := os.ReadFile(expandedPath) + if err != nil { + return nil, fmt.Errorf("failed to read config file '%s': %w", expandedPath, err) + } + + // Validate config structure before parsing + if validationResult := model.ValidateBreakingRulesConfigYAML(data); validationResult != nil { + return nil, &ConfigValidationError{ + FilePath: expandedPath, + Result: validationResult, + } + } + + var config model.BreakingRulesConfig + if err := yaml.Unmarshal(data, &config); err != nil { + return nil, &ConfigParseError{ + FilePath: expandedPath, + Err: err, + } + } + + return &config, nil +} + +// getDefaultConfigPaths returns the list of default paths to search for config files. +// order: current directory, then ~/.config +// silently skips paths if directory cannot be resolved +func getDefaultConfigPaths() []string { + paths := make([]string, 0, 2) + + cwd, err := os.Getwd() + if err == nil { + paths = append(paths, filepath.Join(cwd, DefaultConfigFileName)) + } + + home, err := os.UserHomeDir() + if err == nil { + paths = append(paths, filepath.Join(home, ".config", DefaultConfigFileName)) + } + + return paths +} + +// expandUserPath expands ~ to the user's home directory +func expandUserPath(path string) (string, error) { + if path == "" { + return "", nil + } + + if strings.HasPrefix(path, "~") { + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("unable to resolve home directory: %w", err) + } + if path == "~" { + return home, nil + } + if strings.HasPrefix(path, "~/") || strings.HasPrefix(path, "~\\") { + return filepath.Join(home, path[2:]), nil + } + } + + return path, nil +} + +// ApplyBreakingRulesConfig sets the active breaking rules config in libopenapi. +// If config is nil, uses libopenapi defaults. +// The config is merged on top of defaults, so only specified rules are overridden. +func ApplyBreakingRulesConfig(config *model.BreakingRulesConfig) { + if config == nil { + model.ResetActiveBreakingRulesConfig() + return + } + + // Get defaults and merge user config on top + defaults := model.GenerateDefaultBreakingRules() + defaults.Merge(config) + model.SetActiveBreakingRulesConfig(defaults) +} + +// ResetBreakingRulesConfig resets the active breaking rules to libopenapi defaults. +func ResetBreakingRulesConfig() { + model.ResetActiveBreakingRulesConfig() +} + +// ConfigParseError represents a YAML parsing error with context +type ConfigParseError struct { + FilePath string + Err error +} + +func (e *ConfigParseError) Error() string { + return fmt.Sprintf("failed to parse config file '%s': %v", e.FilePath, e.Err) +} + +func (e *ConfigParseError) Unwrap() error { + return e.Err +} + +// ConfigValidationError represents validation errors in the config structure +type ConfigValidationError struct { + FilePath string + Result *model.ConfigValidationResult +} + +func (e *ConfigValidationError) Error() string { + return fmt.Sprintf("config validation failed for '%s': %d error(s) found", e.FilePath, len(e.Result.Errors)) +} + +// PrintConfigError prints a config error with nice formatting using pterm. +// Displays in red with spacing above and below. +func PrintConfigError(err error) { + fmt.Println() // space above + + if validationErr, ok := err.(*ConfigValidationError); ok { + pterm.Error.Printf("Breaking rules config has %d error(s)\n", len(validationErr.Result.Errors)) + fmt.Println() + pterm.FgRed.Printf(" File: %s\n", validationErr.FilePath) + fmt.Println() + + for _, e := range validationErr.Result.Errors { + if e.Line > 0 { + pterm.FgRed.Printf(" ✗ Line %d: %s\n", e.Line, e.Message) + } else { + pterm.FgRed.Printf(" ✗ %s\n", e.Message) + } + pterm.FgLightYellow.Printf(" → Move '%s' to the top level of your config\n", e.FoundKey) + fmt.Println() + } + + pterm.FgLightCyan.Println(" Components like 'discriminator', 'xml', 'contact', 'license', etc.") + pterm.FgLightCyan.Println(" must be defined at the top level, not nested under other components.") + pterm.FgLightCyan.Println(" See: https://pb33f.io/libopenapi/what-changed/") + } else if parseErr, ok := err.(*ConfigParseError); ok { + pterm.Error.Println("Failed to parse breaking rules config file") + fmt.Println() + pterm.FgRed.Printf(" File: %s\n", parseErr.FilePath) + pterm.FgRed.Printf(" Error: %s\n", parseErr.Err.Error()) + fmt.Println() + pterm.FgLightYellow.Println(" Ensure the YAML structure matches libopenapi's BreakingRulesConfig format.") + pterm.FgLightYellow.Println(" See: https://pb33f.io/libopenapi/what-changed/") + } else { + pterm.Error.Printf("Config error: %s\n", err.Error()) + } + + fmt.Println() // space below +} diff --git a/cmd/config_test.go b/cmd/config_test.go new file mode 100644 index 0000000..e993b69 --- /dev/null +++ b/cmd/config_test.go @@ -0,0 +1,339 @@ +// Copyright 2022-2025 Princess Beef Heavy Industries, LLC / Dave Shanley +// SPDX-License-Identifier: MIT + +package cmd + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/pb33f/libopenapi/what-changed/model" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadBreakingRulesConfig_ValidFile(t *testing.T) { + config, err := LoadBreakingRulesConfig("../sample-specs/sample_configs/valid-config.yaml") + + require.NoError(t, err) + require.NotNil(t, config) + + // verify some rules were loaded + assert.NotNil(t, config.PathItem) + assert.NotNil(t, config.PathItem.Get) + assert.NotNil(t, config.PathItem.Get.Removed) + assert.False(t, *config.PathItem.Get.Removed) + + assert.NotNil(t, config.Schema) + assert.NotNil(t, config.Schema.Enum) + assert.NotNil(t, config.Schema.Enum.Removed) + assert.False(t, *config.Schema.Enum.Removed) +} + +func TestLoadBreakingRulesConfig_MissingUserSpecifiedFile(t *testing.T) { + config, err := LoadBreakingRulesConfig("nonexistent-file.yaml") + + assert.Error(t, err) + assert.Nil(t, config) + assert.Contains(t, err.Error(), "config file not found") +} + +func TestLoadBreakingRulesConfig_MissingDefaultFile(t *testing.T) { + // when no config path is specified and no default config exists, + // should return nil config (no error) to use libopenapi defaults + _, err := LoadBreakingRulesConfig("") + + // this may or may not find a config depending on the test environment + // but it should not error - it either finds one or returns nil + assert.NoError(t, err) +} + +func TestLoadBreakingRulesConfig_InvalidYAML(t *testing.T) { + config, err := LoadBreakingRulesConfig("../sample-specs/sample_configs/invalid-config.yaml") + + assert.Error(t, err) + assert.Nil(t, config) + // Could be either a validation error (invalid YAML caught during validation) + // or a parse error (caught during unmarshal) + assert.True(t, + strings.Contains(err.Error(), "invalid YAML") || + strings.Contains(err.Error(), "failed to parse config file") || + strings.Contains(err.Error(), "config validation failed"), + "error should indicate invalid config: %s", err.Error()) +} + +func TestLoadBreakingRulesConfig_PartialConfig(t *testing.T) { + config, err := LoadBreakingRulesConfig("../sample-specs/sample_configs/partial-config.yaml") + + require.NoError(t, err) + require.NotNil(t, config) + + // verify partial config loaded correctly + assert.NotNil(t, config.Schema) + assert.NotNil(t, config.Schema.Enum) + assert.NotNil(t, config.Schema.Enum.Removed) + assert.False(t, *config.Schema.Enum.Removed) + + // unspecified fields should be nil + assert.Nil(t, config.PathItem) +} + +func TestExpandUserPath_TildeExpansion(t *testing.T) { + home, err := os.UserHomeDir() + require.NoError(t, err) + + tests := []struct { + name string + input string + expected string + }{ + { + name: "bare tilde", + input: "~", + expected: home, + }, + { + name: "tilde with path", + input: "~/config/rules.yaml", + expected: filepath.Join(home, "config/rules.yaml"), + }, + { + name: "absolute path unchanged", + input: "/etc/config.yaml", + expected: "/etc/config.yaml", + }, + { + name: "relative path unchanged", + input: "config/rules.yaml", + expected: "config/rules.yaml", + }, + { + name: "empty path", + input: "", + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := expandUserPath(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestExpandUserPath_NoTilde(t *testing.T) { + result, err := expandUserPath("/absolute/path/to/config.yaml") + + require.NoError(t, err) + assert.Equal(t, "/absolute/path/to/config.yaml", result) +} + +func TestGetDefaultConfigPaths(t *testing.T) { + paths := getDefaultConfigPaths() + + // should have at least one path (current directory) + assert.NotEmpty(t, paths) + + // first path should be in current directory + cwd, err := os.Getwd() + require.NoError(t, err) + assert.Equal(t, filepath.Join(cwd, DefaultConfigFileName), paths[0]) + + // if we have two paths, second should be in ~/.config + if len(paths) > 1 { + home, err := os.UserHomeDir() + require.NoError(t, err) + assert.Equal(t, filepath.Join(home, ".config", DefaultConfigFileName), paths[1]) + } +} + +func TestApplyBreakingRulesConfig_MergesWithDefaults(t *testing.T) { + // reset to clean state + model.ResetDefaultBreakingRules() + model.ResetActiveBreakingRulesConfig() + defer func() { + model.ResetActiveBreakingRulesConfig() + model.ResetDefaultBreakingRules() + }() + + // create a custom config that overrides one rule + falseVal := false + customConfig := &model.BreakingRulesConfig{ + Schema: &model.SchemaRules{ + Enum: &model.BreakingChangeRule{ + Removed: &falseVal, + }, + }, + } + + ApplyBreakingRulesConfig(customConfig) + + // verify the custom rule is applied + activeConfig := model.GetActiveBreakingRulesConfig() + require.NotNil(t, activeConfig) + require.NotNil(t, activeConfig.Schema) + require.NotNil(t, activeConfig.Schema.Enum) + require.NotNil(t, activeConfig.Schema.Enum.Removed) + assert.False(t, *activeConfig.Schema.Enum.Removed) + + // verify other defaults are still present (not nil) + assert.NotNil(t, activeConfig.PathItem) +} + +func TestApplyBreakingRulesConfig_NilResets(t *testing.T) { + // reset to clean state + model.ResetDefaultBreakingRules() + model.ResetActiveBreakingRulesConfig() + defer func() { + model.ResetActiveBreakingRulesConfig() + model.ResetDefaultBreakingRules() + }() + + // apply a custom config + falseVal := false + customConfig := &model.BreakingRulesConfig{ + Schema: &model.SchemaRules{ + Enum: &model.BreakingChangeRule{ + Removed: &falseVal, + }, + }, + } + ApplyBreakingRulesConfig(customConfig) + + // verify custom config was applied + activeConfig := model.GetActiveBreakingRulesConfig() + require.NotNil(t, activeConfig) + require.NotNil(t, activeConfig.Schema) + require.NotNil(t, activeConfig.Schema.Enum) + require.NotNil(t, activeConfig.Schema.Enum.Removed) + assert.False(t, *activeConfig.Schema.Enum.Removed) + + // now reset by passing nil + ApplyBreakingRulesConfig(nil) + + // verify active config was reset (returns non-nil config from defaults) + activeConfig = model.GetActiveBreakingRulesConfig() + require.NotNil(t, activeConfig) + // after reset, we should have a valid config with expected structure + require.NotNil(t, activeConfig.Schema) +} + +func TestResetBreakingRulesConfig(t *testing.T) { + // reset to clean state + model.ResetDefaultBreakingRules() + model.ResetActiveBreakingRulesConfig() + defer func() { + model.ResetActiveBreakingRulesConfig() + model.ResetDefaultBreakingRules() + }() + + // apply a custom config + falseVal := false + customConfig := &model.BreakingRulesConfig{ + Schema: &model.SchemaRules{ + Enum: &model.BreakingChangeRule{ + Removed: &falseVal, + }, + }, + } + ApplyBreakingRulesConfig(customConfig) + + // verify custom config was applied + activeConfig := model.GetActiveBreakingRulesConfig() + require.NotNil(t, activeConfig) + assert.False(t, *activeConfig.Schema.Enum.Removed) + + // reset + ResetBreakingRulesConfig() + + // verify reset returns a valid config (from defaults) + activeConfig = model.GetActiveBreakingRulesConfig() + require.NotNil(t, activeConfig) + require.NotNil(t, activeConfig.Schema) +} + +func TestLoadConfigFromPath_Required(t *testing.T) { + // test that required=true returns error for missing file + config, err := loadConfigFromPath("nonexistent.yaml", true) + + assert.Error(t, err) + assert.Nil(t, config) + assert.Contains(t, err.Error(), "config file not found") +} + +func TestLoadConfigFromPath_NotRequired(t *testing.T) { + // test that required=false returns nil, nil for missing file + config, err := loadConfigFromPath("nonexistent.yaml", false) + + assert.NoError(t, err) + assert.Nil(t, config) +} + +func TestDefaultConfigFileName(t *testing.T) { + assert.Equal(t, "changes-rules.yaml", DefaultConfigFileName) +} + +func TestLoadBreakingRulesConfig_ValidationError(t *testing.T) { + // Create a temp file with misplaced config + tmpFile, err := os.CreateTemp("", "bad-config-*.yaml") + require.NoError(t, err) + defer os.Remove(tmpFile.Name()) + + badConfig := `schema: + discriminator: + propertyName: + modified: false + xml: + name: + added: true +` + _, err = tmpFile.WriteString(badConfig) + require.NoError(t, err) + tmpFile.Close() + + config, err := LoadBreakingRulesConfig(tmpFile.Name()) + + assert.Error(t, err) + assert.Nil(t, config) + + // Check it's a validation error + validationErr, ok := err.(*ConfigValidationError) + require.True(t, ok, "expected ConfigValidationError") + assert.Equal(t, 2, len(validationErr.Result.Errors)) + + // Check error details + foundDiscriminator := false + foundXML := false + for _, e := range validationErr.Result.Errors { + if e.FoundKey == "discriminator" { + foundDiscriminator = true + // Path includes full traversal to where the misplaced key was found + assert.Contains(t, e.Path, "schema.discriminator") + } + if e.FoundKey == "xml" { + foundXML = true + // Path includes full traversal to where the misplaced key was found + assert.Contains(t, e.Path, "schema.xml") + } + } + assert.True(t, foundDiscriminator, "should detect nested discriminator") + assert.True(t, foundXML, "should detect nested xml") +} + +func TestConfigValidationError_Error(t *testing.T) { + result := &model.ConfigValidationResult{ + Errors: []*model.ConfigValidationError{ + {Message: "test error"}, + }, + } + err := &ConfigValidationError{ + FilePath: "/path/to/config.yaml", + Result: result, + } + + assert.Equal(t, "config validation failed for '/path/to/config.yaml': 1 error(s) found", err.Error()) +} diff --git a/cmd/console.go b/cmd/console.go index 3d3eb6a..07b1179 100644 --- a/cmd/console.go +++ b/cmd/console.go @@ -14,6 +14,7 @@ import ( "time" "github.com/google/uuid" + whatChangedModel "github.com/pb33f/libopenapi/what-changed/model" "github.com/pb33f/openapi-changes/git" "github.com/pb33f/openapi-changes/model" "github.com/pb33f/openapi-changes/tui" @@ -44,6 +45,14 @@ func GetConsoleCommand() *cobra.Command { baseCommitFlag, _ := cmd.Flags().GetString("base-commit") remoteFlag, _ := cmd.Flags().GetBool("remote") extRefs, _ := cmd.Flags().GetBool("ext-refs") + configFlag, _ := cmd.Flags().GetString("config") + + // load breaking rules configuration + breakingConfig, err := LoadBreakingRulesConfig(configFlag) + if err != nil { + PrintConfigError(err) + return err + } noBanner, _ := cmd.Flags().GetBool("no-logo") if !noBanner { @@ -148,7 +157,7 @@ func GetConsoleCommand() *cobra.Command { return err } commits, e := runGithubHistoryConsole(user, repo, filePath, baseCommitFlag, latestFlag, limitFlag, limitTimeFlag, updateChan, - errorChan, baseFlag, remoteFlag, extRefs) + errorChan, baseFlag, remoteFlag, extRefs, breakingConfig) // wait for things to be completed. <-doneChan @@ -213,7 +222,7 @@ func GetConsoleCommand() *cobra.Command { go listenForUpdates(updateChan, errorChan) commits, errs := runGitHistoryConsole(args[0], args[1], baseCommitFlag, latestFlag, globalRevisionsFlag, limitFlag, limitTimeFlag, - updateChan, errorChan, baseFlag, remoteFlag, extRefs) + updateChan, errorChan, baseFlag, remoteFlag, extRefs, breakingConfig) // wait. <-doneChan @@ -253,7 +262,7 @@ func GetConsoleCommand() *cobra.Command { return urlErr } - errs := runLeftRightCompare(left, right, updateChan, errorChan, baseFlag, remoteFlag, extRefs) + errs := runLeftRightCompare(left, right, updateChan, errorChan, baseFlag, remoteFlag, extRefs, breakingConfig) // wait. <-doneChan if len(errs) > 0 { @@ -275,9 +284,11 @@ func GetConsoleCommand() *cobra.Command { // TODO: we have got to clean up these methods and replace with a message based design. func runGithubHistoryConsole(username, repo, filePath, baseCommit string, latest bool, limit int, limitTime int, - progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool) ([]*model.Commit, []error) { + progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool, + breakingConfig *whatChangedModel.BreakingRulesConfig, +) ([]*model.Commit, []error) { - commitHistory, errs := git.ProcessGithubRepo(username, repo, filePath, baseCommit, progressChan, errorChan, true, limit, limitTime, base, remote, extRefs) + commitHistory, errs := git.ProcessGithubRepo(username, repo, filePath, baseCommit, progressChan, errorChan, true, limit, limitTime, base, remote, extRefs, breakingConfig) if latest && len(commitHistory) > 1 { commitHistory = commitHistory[:1] @@ -310,7 +321,9 @@ func runGithubHistoryConsole(username, repo, filePath, baseCommit string, latest } func runGitHistoryConsole(gitPath, filePath, baseCommit string, latest bool, globalRevisions bool, limit int, limitTime int, - updateChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool) ([]*model.Commit, []error) { + updateChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool, + breakingConfig *whatChangedModel.BreakingRulesConfig, +) ([]*model.Commit, []error) { if gitPath == "" || filePath == "" { err := errors.New("please supply a path to a git repo via -r, and a path to a file via -f") @@ -332,7 +345,7 @@ func runGitHistoryConsole(gitPath, filePath, baseCommit string, latest bool, glo } // populate history with changes and data - git.PopulateHistoryWithChanges(commitHistory, limit, limitTime, updateChan, errorChan, base, remote, extRefs) + git.PopulateHistoryWithChanges(commitHistory, limit, limitTime, updateChan, errorChan, base, remote, extRefs, breakingConfig) if latest { commitHistory = commitHistory[:1] @@ -347,7 +360,9 @@ func runGitHistoryConsole(gitPath, filePath, baseCommit string, latest bool, glo } func runLeftRightCompare(left, right string, updateChan chan *model.ProgressUpdate, - errorChan chan model.ProgressError, base string, remote, extRefs bool) []error { + errorChan chan model.ProgressError, base string, remote, extRefs bool, + breakingConfig *whatChangedModel.BreakingRulesConfig, +) []error { var leftBytes, rightBytes []byte var errs []error @@ -383,7 +398,7 @@ func runLeftRightCompare(left, right string, updateChan chan *model.ProgressUpda }, } - commits, errs = git.BuildCommitChangelog(commits, updateChan, errorChan, base, remote, extRefs) + commits, errs = git.BuildCommitChangelog(commits, updateChan, errorChan, base, remote, extRefs, breakingConfig) if len(errs) > 0 { return errs } diff --git a/cmd/html_report.go b/cmd/html_report.go index ede7e41..e4b2f05 100644 --- a/cmd/html_report.go +++ b/cmd/html_report.go @@ -15,6 +15,7 @@ import ( "time" "github.com/google/uuid" + whatChangedModel "github.com/pb33f/libopenapi/what-changed/model" "github.com/pb33f/openapi-changes/git" htmlReport "github.com/pb33f/openapi-changes/html-report" "github.com/pb33f/openapi-changes/model" @@ -48,6 +49,14 @@ func GetHTMLReportCommand() *cobra.Command { remoteFlag, _ := cmd.Flags().GetBool("remote") reportFile, _ := cmd.Flags().GetString("report-file") extRefs, _ := cmd.Flags().GetBool("ext-refs") + configFlag, _ := cmd.Flags().GetString("config") + + // load breaking rules configuration + breakingConfig, err := LoadBreakingRulesConfig(configFlag) + if err != nil { + PrintConfigError(err) + return err + } if noColorFlag { pterm.DisableStyling() @@ -172,7 +181,7 @@ func GetHTMLReportCommand() *cobra.Command { return err } report, _, er := RunGithubHistoryHTMLReport(user, repo, filePath, baseCommitFlag, latestFlag, cdnFlag, - false, updateChan, errorChan, limitFlag, limitTimeFlag, baseFlag, remoteFlag, extRefs) + false, updateChan, errorChan, limitFlag, limitTimeFlag, baseFlag, remoteFlag, extRefs, breakingConfig) // wait for things to be completed. <-doneChan @@ -223,7 +232,7 @@ func GetHTMLReportCommand() *cobra.Command { go listenForUpdates(updateChan, errorChan) report, _, er := RunGitHistoryHTMLReport(args[0], args[1], baseCommitFlag, latestFlag, cdnFlag, - updateChan, errorChan, baseFlag, remoteFlag, extRefs, globalRevisionsFlag, limitFlag, limitTimeFlag) + updateChan, errorChan, baseFlag, remoteFlag, extRefs, globalRevisionsFlag, limitFlag, limitTimeFlag, breakingConfig) <-doneChan if er != nil { for x := range er { @@ -253,7 +262,7 @@ func GetHTMLReportCommand() *cobra.Command { return urlErr } - report, errs := RunLeftRightHTMLReport(left, right, cdnFlag, updateChan, errorChan, baseFlag, remoteFlag, extRefs) + report, errs := RunLeftRightHTMLReport(left, right, cdnFlag, updateChan, errorChan, baseFlag, remoteFlag, extRefs, breakingConfig) <-doneChan if len(errs) > 0 { for e := range errs { @@ -303,7 +312,9 @@ func ExtractGithubDetailsFromURL(url *url.URL) (string, string, string, error) { } func RunGitHistoryHTMLReport(gitPath, filePath, baseCommit string, latest, useCDN bool, - progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool, globalRevisions bool, limit int, limitTime int) ([]byte, []*model.Report, []error) { + progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool, globalRevisions bool, limit int, limitTime int, + breakingConfig *whatChangedModel.BreakingRulesConfig, +) ([]byte, []*model.Report, []error) { if gitPath == "" || filePath == "" { err := errors.New("please supply a path to a git repo via -r, and a path to a file via -f") model.SendProgressError("reading paths", @@ -322,7 +333,7 @@ func RunGitHistoryHTMLReport(gitPath, filePath, baseCommit string, latest, useCD } // populate history with changes and data - commitHistory, err = git.PopulateHistoryWithChanges(commitHistory, 0, limitTime, progressChan, errorChan, base, remote, extRefs) + commitHistory, err = git.PopulateHistoryWithChanges(commitHistory, 0, limitTime, progressChan, errorChan, base, remote, extRefs, breakingConfig) if err != nil { model.SendFatalError("extraction", fmt.Sprintf("cannot extract history %s", errors.Join(err...)), errorChan) @@ -353,9 +364,11 @@ func RunGitHistoryHTMLReport(gitPath, filePath, baseCommit string, latest, useCD } func RunGithubHistoryHTMLReport(username, repo, filePath, baseCommit string, latest, useCDN, embeddedMode bool, - progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, limit int, limitTime int, base string, remote, extRefs bool) ([]byte, []*model.Report, []error) { + progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, limit int, limitTime int, base string, remote, extRefs bool, + breakingConfig *whatChangedModel.BreakingRulesConfig, +) ([]byte, []*model.Report, []error) { - commitHistory, errs := git.ProcessGithubRepo(username, repo, filePath, baseCommit, progressChan, errorChan, true, limit, limitTime, base, remote, extRefs) + commitHistory, errs := git.ProcessGithubRepo(username, repo, filePath, baseCommit, progressChan, errorChan, true, limit, limitTime, base, remote, extRefs, breakingConfig) if latest && len(commitHistory) > 1 { commitHistory = commitHistory[:1] } @@ -388,7 +401,9 @@ func RunGithubHistoryHTMLReport(username, repo, filePath, baseCommit string, lat } func RunLeftRightHTMLReport(left, right string, useCDN bool, - progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool) ([]byte, []error) { + progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool, + breakingConfig *whatChangedModel.BreakingRulesConfig, +) ([]byte, []error) { var leftBytes, rightBytes []byte var errs []error @@ -425,7 +440,7 @@ func RunLeftRightHTMLReport(left, right string, useCDN bool, }, } - commits, errs = git.BuildCommitChangelog(commits, progressChan, errorChan, base, remote, extRefs) + commits, errs = git.BuildCommitChangelog(commits, progressChan, errorChan, base, remote, extRefs, breakingConfig) if len(errs) > 0 { close(progressChan) return nil, errs @@ -438,7 +453,9 @@ func RunLeftRightHTMLReport(left, right string, useCDN bool, } func RunLeftRightHTMLReportViaString(left, right string, useCDN, embedded bool, - progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool) ([]byte, []error) { + progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool, + breakingConfig *whatChangedModel.BreakingRulesConfig, +) ([]byte, []error) { var errs []error @@ -457,7 +474,7 @@ func RunLeftRightHTMLReportViaString(left, right string, useCDN, embedded bool, }, } - commits, errs = git.BuildCommitChangelog(commits, progressChan, errorChan, base, remote, extRefs) + commits, errs = git.BuildCommitChangelog(commits, progressChan, errorChan, base, remote, extRefs, breakingConfig) if len(errs) > 0 { close(progressChan) return nil, errs diff --git a/cmd/markdown_report.go b/cmd/markdown_report.go index 1b600b7..520c3f1 100644 --- a/cmd/markdown_report.go +++ b/cmd/markdown_report.go @@ -14,6 +14,7 @@ import ( "time" "github.com/google/uuid" + whatChangedModel "github.com/pb33f/libopenapi/what-changed/model" "github.com/pb33f/openapi-changes/git" markdownReport "github.com/pb33f/openapi-changes/markdown-report" "github.com/pb33f/openapi-changes/model" @@ -48,6 +49,14 @@ func GetMarkdownReportCommand() *cobra.Command { remoteFlag, _ := cmd.Flags().GetBool("remote") reportFile, _ := cmd.Flags().GetString("report-file") extRefs, _ := cmd.Flags().GetBool("ext-refs") + configFlag, _ := cmd.Flags().GetString("config") + + // load breaking rules configuration + breakingConfig, err := LoadBreakingRulesConfig(configFlag) + if err != nil { + PrintConfigError(err) + return err + } if noColorFlag { pterm.DisableStyling() @@ -172,7 +181,7 @@ func GetMarkdownReportCommand() *cobra.Command { return err } report, _, er := RunGithubHistoryMarkdownReport(user, repo, filePath, baseCommitFlag, latestFlag, cdnFlag, - false, updateChan, errorChan, limitFlag, limitTimeFlag, baseFlag, remoteFlag, extRefs) + false, updateChan, errorChan, limitFlag, limitTimeFlag, baseFlag, remoteFlag, extRefs, breakingConfig) // wait for things to be completed. <-doneChan @@ -223,7 +232,7 @@ func GetMarkdownReportCommand() *cobra.Command { go listenForUpdates(updateChan, errorChan) report, _, er := RunGitHistoryMarkdownReport(args[0], args[1], baseCommitFlag, latestFlag, cdnFlag, - updateChan, errorChan, baseFlag, remoteFlag, extRefs, globalRevisionsFlag, limitFlag, limitTimeFlag) + updateChan, errorChan, baseFlag, remoteFlag, extRefs, globalRevisionsFlag, limitFlag, limitTimeFlag, breakingConfig) <-doneChan if er != nil { for x := range er { @@ -253,7 +262,7 @@ func GetMarkdownReportCommand() *cobra.Command { return urlErr } - report, errs := RunLeftRightMarkDownReport(left, right, cdnFlag, updateChan, errorChan, baseFlag, remoteFlag, extRefs) + report, errs := RunLeftRightMarkDownReport(left, right, cdnFlag, updateChan, errorChan, baseFlag, remoteFlag, extRefs, breakingConfig) <-doneChan if len(errs) > 0 { for e := range errs { @@ -277,7 +286,9 @@ func GetMarkdownReportCommand() *cobra.Command { } func RunGitHistoryMarkdownReport(gitPath, filePath, baseCommit string, latest, useCDN bool, - progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool, globalRevisions bool, limit int, limitTime int) ([]byte, []*model.Report, []error) { + progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool, globalRevisions bool, limit int, limitTime int, + breakingConfig *whatChangedModel.BreakingRulesConfig, +) ([]byte, []*model.Report, []error) { if gitPath == "" || filePath == "" { err := errors.New("please supply a path to a git repo via -r, and a path to a file via -f") model.SendProgressError("reading paths", @@ -296,7 +307,7 @@ func RunGitHistoryMarkdownReport(gitPath, filePath, baseCommit string, latest, u } // populate history with changes and data - commitHistory, err = git.PopulateHistoryWithChanges(commitHistory, 0, limitTime, progressChan, errorChan, base, remote, extRefs) + commitHistory, err = git.PopulateHistoryWithChanges(commitHistory, 0, limitTime, progressChan, errorChan, base, remote, extRefs, breakingConfig) if err != nil { model.SendFatalError("extraction", fmt.Sprintf("cannot extract history %s", errors.Join(err...)), errorChan) @@ -327,9 +338,11 @@ func RunGitHistoryMarkdownReport(gitPath, filePath, baseCommit string, latest, u } func RunGithubHistoryMarkdownReport(username, repo, filePath, baseCommit string, latest, useCDN, embeddedMode bool, - progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, limit int, limitTime int, base string, remote, extRefs bool) ([]byte, []*model.Report, []error) { + progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, limit int, limitTime int, base string, remote, extRefs bool, + breakingConfig *whatChangedModel.BreakingRulesConfig, +) ([]byte, []*model.Report, []error) { - commitHistory, errs := git.ProcessGithubRepo(username, repo, filePath, baseCommit, progressChan, errorChan, true, limit, limitTime, base, remote, extRefs) + commitHistory, errs := git.ProcessGithubRepo(username, repo, filePath, baseCommit, progressChan, errorChan, true, limit, limitTime, base, remote, extRefs, breakingConfig) if latest && len(commitHistory) > 1 { commitHistory = commitHistory[:1] } @@ -362,7 +375,9 @@ func RunGithubHistoryMarkdownReport(username, repo, filePath, baseCommit string, } func RunLeftRightMarkDownReport(left, right string, useCDN bool, - progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool) ([]byte, []error) { + progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool, + breakingConfig *whatChangedModel.BreakingRulesConfig, +) ([]byte, []error) { var leftBytes, rightBytes []byte var errs []error @@ -399,7 +414,7 @@ func RunLeftRightMarkDownReport(left, right string, useCDN bool, }, } - commits, errs = git.BuildCommitChangelog(commits, progressChan, errorChan, base, remote, extRefs) + commits, errs = git.BuildCommitChangelog(commits, progressChan, errorChan, base, remote, extRefs, breakingConfig) if len(errs) > 0 { close(progressChan) return nil, errs @@ -411,7 +426,9 @@ func RunLeftRightMarkDownReport(left, right string, useCDN bool, } func RunLeftRightMarkDownReportViaString(left, right string, useCDN, embedded bool, - progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool) ([]byte, []error) { + progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool, + breakingConfig *whatChangedModel.BreakingRulesConfig, +) ([]byte, []error) { var errs []error @@ -430,7 +447,7 @@ func RunLeftRightMarkDownReportViaString(left, right string, useCDN, embedded bo }, } - commits, errs = git.BuildCommitChangelog(commits, progressChan, errorChan, base, remote, extRefs) + commits, errs = git.BuildCommitChangelog(commits, progressChan, errorChan, base, remote, extRefs, breakingConfig) if len(errs) > 0 { close(progressChan) return nil, errs diff --git a/cmd/report.go b/cmd/report.go index 8e28f30..301af6c 100644 --- a/cmd/report.go +++ b/cmd/report.go @@ -14,6 +14,7 @@ import ( "time" "github.com/google/uuid" + whatChangedModel "github.com/pb33f/libopenapi/what-changed/model" "github.com/pb33f/libopenapi/what-changed/reports" "github.com/pb33f/openapi-changes/git" "github.com/pb33f/openapi-changes/model" @@ -44,6 +45,14 @@ func GetReportCommand() *cobra.Command { noColorFlag, _ := cmd.Flags().GetBool("no-color") remoteFlag, _ := cmd.Flags().GetBool("remote") extRefs, _ := cmd.Flags().GetBool("ext-refs") + configFlag, _ := cmd.Flags().GetString("config") + + // load breaking rules configuration + breakingConfig, err := LoadBreakingRulesConfig(configFlag) + if err != nil { + PrintConfigError(err) + return err + } if noColorFlag { pterm.DisableStyling() @@ -95,7 +104,7 @@ func GetReportCommand() *cobra.Command { return err } report, er := runGithubHistoryReport(user, repo, filePath, baseCommitFlag, latestFlag, limitFlag, limitTimeFlag, updateChan, - errorChan, baseFlag, remoteFlag, extRefs) + errorChan, baseFlag, remoteFlag, extRefs, breakingConfig) // wait for things to be completed. <-doneChan @@ -160,7 +169,7 @@ func GetReportCommand() *cobra.Command { go listenForUpdates(updateChan, errorChan) report, er := runGitHistoryReport(repo, p, baseCommitFlag, latestFlag, updateChan, errorChan, baseFlag, - remoteFlag, extRefs, globalRevisionsFlag, limitFlag, limitTimeFlag) + remoteFlag, extRefs, globalRevisionsFlag, limitFlag, limitTimeFlag, breakingConfig) <-doneChan @@ -200,7 +209,7 @@ func GetReportCommand() *cobra.Command { return urlErr } - report, errs := runLeftRightReport(left, right, updateChan, errorChan, baseFlag, remoteFlag, extRefs) + report, errs := runLeftRightReport(left, right, updateChan, errorChan, baseFlag, remoteFlag, extRefs, breakingConfig) <-doneChan if len(errs) > 0 { for e := range errs { @@ -231,7 +240,9 @@ func GetReportCommand() *cobra.Command { } func runGitHistoryReport(gitPath, filePath, baseCommit string, latest bool, - updateChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool, globalRevisions bool, limit int, limitTime int) (*model.HistoricalReport, []error) { + updateChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool, globalRevisions bool, limit int, limitTime int, + breakingConfig *whatChangedModel.BreakingRulesConfig, +) (*model.HistoricalReport, []error) { if gitPath == "" || filePath == "" { err := errors.New("please supply a path to a git repo via -r, and a path to a file via -f") @@ -253,7 +264,7 @@ func runGitHistoryReport(gitPath, filePath, baseCommit string, latest bool, } // populate history with changes and data - commitHistory, err = git.PopulateHistoryWithChanges(commitHistory, 0, limitTime, updateChan, errorChan, base, remote, extRefs) + commitHistory, err = git.PopulateHistoryWithChanges(commitHistory, 0, limitTime, updateChan, errorChan, base, remote, extRefs, breakingConfig) if err != nil { model.SendProgressError("git", fmt.Sprintf("%d errors found extracting history", len(err)), errorChan) close(updateChan) @@ -286,10 +297,12 @@ func runGitHistoryReport(gitPath, filePath, baseCommit string, latest bool, } func runGithubHistoryReport(username, repo, filePath, baseCommit string, latest bool, limit int, limitTime int, - progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool) (*model.HistoricalReport, []error) { + progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool, + breakingConfig *whatChangedModel.BreakingRulesConfig, +) (*model.HistoricalReport, []error) { commitHistory, errs := git.ProcessGithubRepo(username, repo, filePath, baseCommit, progressChan, errorChan, - false, limit, limitTime, base, remote, extRefs) + false, limit, limitTime, base, remote, extRefs, breakingConfig) if errs != nil { model.SendProgressError("git", errors.Join(errs...).Error(), errorChan) close(progressChan) @@ -322,7 +335,9 @@ func runGithubHistoryReport(username, repo, filePath, baseCommit string, latest } func runLeftRightReport(left, right string, - updateChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool) (*model.Report, []error) { + updateChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool, + breakingConfig *whatChangedModel.BreakingRulesConfig, +) (*model.Report, []error) { var leftBytes, rightBytes []byte var errs []error @@ -358,7 +373,7 @@ func runLeftRightReport(left, right string, }, } - commits, errs = git.BuildCommitChangelog(commits, updateChan, errorChan, base, remote, extRefs) + commits, errs = git.BuildCommitChangelog(commits, updateChan, errorChan, base, remote, extRefs, breakingConfig) close(updateChan) if len(errs) > 0 { diff --git a/cmd/root.go b/cmd/root.go index ec0e66f..deb8db3 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -71,6 +71,7 @@ func init() { rootCmd.PersistentFlags().StringP("base-commit", "", "", "Base commit to compare against (will check until commit is found or limit is reached -- make sure to not shallow clone)") rootCmd.PersistentFlags().BoolP("remote", "r", true, "Allow remote reference (URLs and files) to be auto resolved, without a base URL or path (default is on)") rootCmd.PersistentFlags().BoolP("ext-refs", "", false, "Turn on $ref lookups and resolving for extensions (x-) objects") + rootCmd.PersistentFlags().StringP("config", "c", "", "Path to breaking rules config file (default: ./changes-rules.yaml or ~/.config/changes-rules.yaml)") } func initConfig() { diff --git a/cmd/summary.go b/cmd/summary.go index ebade8b..f4c0c14 100644 --- a/cmd/summary.go +++ b/cmd/summary.go @@ -19,6 +19,7 @@ import ( "github.com/pb33f/openapi-changes/builder" "github.com/google/uuid" + whatChangedModel "github.com/pb33f/libopenapi/what-changed/model" "github.com/pb33f/libopenapi/what-changed/reports" "github.com/pb33f/openapi-changes/git" "github.com/pb33f/openapi-changes/model" @@ -51,6 +52,14 @@ func GetSummaryCommand() *cobra.Command { markdownFlag, _ := cmd.Flags().GetBool("markdown") extRefs, _ := cmd.Flags().GetBool("ext-refs") errOnDiff, _ := cmd.Flags().GetBool("error-on-diff") + configFlag, _ := cmd.Flags().GetString("config") + + // load breaking rules configuration + breakingConfig, err := LoadBreakingRulesConfig(configFlag) + if err != nil { + PrintConfigError(err) + return err + } if noColorFlag { pterm.DisableStyling() @@ -184,7 +193,7 @@ func GetSummaryCommand() *cobra.Command { } er := runGithubHistorySummary(user, repo, filePath, baseCommitFlag, latestFlag, limitFlag, limitTimeFlag, updateChan, - errorChan, baseFlag, remoteFlag, markdownFlag, extRefs, errOnDiff) + errorChan, baseFlag, remoteFlag, markdownFlag, extRefs, errOnDiff, breakingConfig) // wait for things to be completed. <-doneChan if er != nil { @@ -239,7 +248,7 @@ func GetSummaryCommand() *cobra.Command { go listenForUpdates(updateChan, errorChan) err = runGitHistorySummary(args[0], args[1], baseCommitFlag, latestFlag, updateChan, errorChan, - baseFlag, remoteFlag, markdownFlag, extRefs, errOnDiff, globalRevisionsFlag, limitFlag, limitTimeFlag) + baseFlag, remoteFlag, markdownFlag, extRefs, errOnDiff, globalRevisionsFlag, limitFlag, limitTimeFlag, breakingConfig) <-doneChan @@ -266,7 +275,7 @@ func GetSummaryCommand() *cobra.Command { return urlErr } - errs := runLeftRightSummary(left, right, updateChan, errorChan, baseFlag, remoteFlag, markdownFlag, extRefs, errOnDiff) + errs := runLeftRightSummary(left, right, updateChan, errorChan, baseFlag, remoteFlag, markdownFlag, extRefs, errOnDiff, breakingConfig) <-doneChan if len(errs) > 0 { for e := range errs { @@ -325,7 +334,9 @@ func checkURL(urlString string, errorChan chan model.ProgressError) (string, err } func runLeftRightSummary(left, right string, updateChan chan *model.ProgressUpdate, - errorChan chan model.ProgressError, base string, remote, markdown, extRefs, errOnDiff bool) []error { + errorChan chan model.ProgressError, base string, remote, markdown, extRefs, errOnDiff bool, + breakingConfig *whatChangedModel.BreakingRulesConfig, +) []error { var leftBytes, rightBytes []byte // var errs []error @@ -362,7 +373,7 @@ func runLeftRightSummary(left, right string, updateChan chan *model.ProgressUpda } var errs []error - commits, errs = git.BuildCommitChangelog(commits, updateChan, errorChan, base, remote, extRefs) + commits, errs = git.BuildCommitChangelog(commits, updateChan, errorChan, base, remote, extRefs, breakingConfig) if len(errs) > 0 { close(updateChan) return errs @@ -387,9 +398,11 @@ func runLeftRightSummary(left, right string, updateChan chan *model.ProgressUpda } func runGithubHistorySummary(username, repo, filePath, baseCommit string, latest bool, limit int, limitTime int, - progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, markdown, extRefs, errOnDiff bool) error { + progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, markdown, extRefs, errOnDiff bool, + breakingConfig *whatChangedModel.BreakingRulesConfig, +) error { commitHistory, _ := git.ProcessGithubRepo(username, repo, filePath, baseCommit, progressChan, errorChan, - false, limit, limitTime, base, remote, extRefs) + false, limit, limitTime, base, remote, extRefs, breakingConfig) if latest { commitHistory = commitHistory[:1] @@ -405,7 +418,9 @@ func runGithubHistorySummary(username, repo, filePath, baseCommit string, latest func runGitHistorySummary(gitPath, filePath, baseCommit string, latest bool, updateChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, markdown, extRefs, errOnDiff bool, - globalRevisions bool, limit int, limitTime int) error { + globalRevisions bool, limit int, limitTime int, + breakingConfig *whatChangedModel.BreakingRulesConfig, +) error { if gitPath == "" || filePath == "" { err := errors.New("please supply a path to a git repo via -r, and a path to a file via -f") @@ -426,7 +441,7 @@ func runGitHistorySummary(gitPath, filePath, baseCommit string, latest bool, } // populate history with changes and data - git.PopulateHistoryWithChanges(commitHistory, 0, limitTime, updateChan, errorChan, base, remote, extRefs) + git.PopulateHistoryWithChanges(commitHistory, 0, limitTime, updateChan, errorChan, base, remote, extRefs, breakingConfig) if latest { commitHistory = commitHistory[:1] diff --git a/cmd/test_files/spec_left.yaml b/cmd/test_files/spec_left.yaml new file mode 100644 index 0000000..2403f56 --- /dev/null +++ b/cmd/test_files/spec_left.yaml @@ -0,0 +1,19 @@ +openapi: 3.2 +$self: hello +jsonSchemaDialect: https://fish-and-fiddle.com +info: + title: a nice cup of tea + summary: a lovely test + description: testing code on a sunday morning. + termsOfService: https://pb33f.com/tos + version: 1.2.3.4 + contact: + name: Jim + url: https://pb33f.io + email: hello@pb33f.io + license: + name: MIT + identifier: MIT + url: https://somewhereoutthere.com + servers: + - url: https://api.pb33f.io diff --git a/cmd/test_files/spec_right.yaml b/cmd/test_files/spec_right.yaml new file mode 100644 index 0000000..486ee29 --- /dev/null +++ b/cmd/test_files/spec_right.yaml @@ -0,0 +1,19 @@ +openapi: 3.2.3 +$self: there +jsonSchemaDialect: https://fishyfiddly.com/wimley +info: + title: a lovely horse. + summary: what a nice sunday morning. + description: testing changes on a sunday morning + termsOfService: https://pb33f.com/tos/and-a-cake + version: 1.2.3.4.5.6.7 + contact: + name: James + url: https://pb33f.io/nice-shoes + email: goodbye@pb33f.io + license: + name: ApacheWare + identifier: APACHE + url: https://somewhereoutthere.com/in-time-and-space + servers: + - url: https://api.pb33f.io diff --git a/cmd/test_files/test-config.yaml b/cmd/test_files/test-config.yaml new file mode 100644 index 0000000..3759038 --- /dev/null +++ b/cmd/test_files/test-config.yaml @@ -0,0 +1,34 @@ +openapi: + modified: false +$self: + modified: false +jsonSchemaDialect: + modified: false + +info: + title: + modified: true + summary: + modified: true + description: + modified: true + termsOfService: + modified: true + version: + modified: true + +contact: + name: + modified: true + url: + modified: true + email: + modified: true + +license: + name: + modified: true + identifier: + modified: true + url: + modified: true diff --git a/cmd/tree.go b/cmd/tree.go index 81ed966..709fbd6 100644 --- a/cmd/tree.go +++ b/cmd/tree.go @@ -5,16 +5,22 @@ package cmd import ( "fmt" + "reflect" + "strings" + v3 "github.com/pb33f/libopenapi/datamodel/low/v3" wcModel "github.com/pb33f/libopenapi/what-changed/model" + "github.com/pb33f/openapi-changes/internal/security" "github.com/pterm/pterm" "github.com/pterm/pterm/putils" "golang.org/x/text/cases" "golang.org/x/text/language" - "reflect" - "strings" ) +// pendingParamChanges stores parameter addition/removal changes from PropertyChanges +// to be merged with ParameterChanges when building the tree +var pendingParamChanges []*wcModel.Change + func buildConsoleTreeNode(list *[]pterm.LeveledListItem, object any, level int, markdown bool) { if object == nil { return @@ -32,8 +38,39 @@ func buildConsoleTreeNode(list *[]pterm.LeveledListItem, object any, level int, case reflect.TypeOf(&wcModel.PropertyChanges{}): topChanges := field.Elem().Interface().(wcModel.PropertyChanges).Changes + + // Separate tag and parameter changes from other changes so we can group them + var tagChanges []*wcModel.Change + var paramChanges []*wcModel.Change + var otherChanges []*wcModel.Change for y := range topChanges { - *list = append(*list, pterm.LeveledListItem{Level: level, Text: generateTreeState(topChanges[y], markdown)}) + prop := strings.ToLower(topChanges[y].Property) + if prop == "tags" { + tagChanges = append(tagChanges, topChanges[y]) + } else if prop == "parameters" { + paramChanges = append(paramChanges, topChanges[y]) + } else { + otherChanges = append(otherChanges, topChanges[y]) + } + } + + // Create Tags node if there are tag changes + if len(tagChanges) > 0 { + *list = append(*list, pterm.LeveledListItem{Level: level, Text: "Tags"}) + for _, change := range tagChanges { + *list = append(*list, pterm.LeveledListItem{Level: level + 1, Text: generateTreeState(change, markdown)}) + } + } + + // Store parameter changes to be merged with ParameterChanges later + // Only set if there are actual parameter changes (to avoid overwriting from nested objects) + if len(paramChanges) > 0 { + pendingParamChanges = paramChanges + } + + // Process other changes normally + for y := range otherChanges { + *list = append(*list, pterm.LeveledListItem{Level: level, Text: generateTreeState(otherChanges[y], markdown)}) } continue case reflect.TypeOf(&wcModel.Change{}): @@ -67,7 +104,7 @@ func buildConsoleTreeNode(list *[]pterm.LeveledListItem, object any, level int, DigIntoObject[wcModel.ComponentsChanges](list, field, level, upper.String(v3.ComponentsLabel), markdown) case reflect.TypeOf(&wcModel.RequestBodyChanges{}): - DigIntoObject[wcModel.RequestBodyChanges](list, field, level, upper.String(v3.RequestBodyLabel), markdown) + DigIntoObject[wcModel.RequestBodyChanges](list, field, level, "Request Body", markdown) case reflect.TypeOf([]*wcModel.TagChanges{}): BuildSliceTreeNode[wcModel.TagChanges](list, field, level, upper.String(v3.TagsLabel), markdown) @@ -82,7 +119,7 @@ func buildConsoleTreeNode(list *[]pterm.LeveledListItem, object any, level int, BuildSliceTreeNode[wcModel.SecurityRequirementChanges](list, field, level, "Security Requirements", markdown) case reflect.TypeOf([]*wcModel.ParameterChanges{}): - BuildSliceTreeNode[wcModel.ParameterChanges](list, field, level, upper.String(v3.ParametersLabel), markdown) + BuildParameterSliceTreeNode(list, field, level, upper.String(v3.ParametersLabel), markdown) case reflect.TypeOf(&wcModel.SchemaChanges{}): DigIntoObject[wcModel.SchemaChanges](list, field, level, upper.String(v3.SchemaLabel), markdown) @@ -114,59 +151,21 @@ func buildConsoleTreeNode(list *[]pterm.LeveledListItem, object any, level int, case reflect.TypeOf(&wcModel.ResponsesChanges{}): DigIntoObject[wcModel.ResponsesChanges](list, field, level, upper.String(v3.ResponsesLabel), markdown) - case reflect.TypeOf(map[string]*wcModel.PathItemChanges{}): - if !field.IsZero() && len(field.MapKeys()) > 0 { - BuildTreeMapNode(list, field, level, markdown) - } - - case reflect.TypeOf(map[string]*wcModel.ResponseChanges{}): - if !field.IsZero() && len(field.MapKeys()) > 0 { - BuildTreeMapNode(list, field, level, markdown) - } - - case reflect.TypeOf(map[string]*wcModel.SchemaChanges{}): - if !field.IsZero() && len(field.MapKeys()) > 0 { - BuildTreeMapNode(list, field, level, markdown) - } - - case reflect.TypeOf(map[string]*wcModel.CallbackChanges{}): - if !field.IsZero() && len(field.MapKeys()) > 0 { - BuildTreeMapNode(list, field, level, markdown) - } - - case reflect.TypeOf(map[string]*wcModel.ExampleChanges{}): - if !field.IsZero() && len(field.MapKeys()) > 0 { - BuildTreeMapNode(list, field, level, markdown) - } - - case reflect.TypeOf(map[string]*wcModel.EncodingChanges{}): - if !field.IsZero() && len(field.MapKeys()) > 0 { - BuildTreeMapNode(list, field, level, markdown) - } - - case reflect.TypeOf(map[string]*wcModel.HeaderChanges{}): - if !field.IsZero() && len(field.MapKeys()) > 0 { - BuildTreeMapNode(list, field, level, markdown) - } - - case reflect.TypeOf(map[string]*wcModel.ServerVariableChanges{}): + case reflect.TypeOf(map[string]*wcModel.PathItemChanges{}), + reflect.TypeOf(map[string]*wcModel.ResponseChanges{}), + reflect.TypeOf(map[string]*wcModel.SchemaChanges{}), + reflect.TypeOf(map[string]*wcModel.CallbackChanges{}), + reflect.TypeOf(map[string]*wcModel.ExampleChanges{}), + reflect.TypeOf(map[string]*wcModel.EncodingChanges{}), + reflect.TypeOf(map[string]*wcModel.HeaderChanges{}), + reflect.TypeOf(map[string]*wcModel.ServerVariableChanges{}), + reflect.TypeOf(map[string]*wcModel.MediaTypeChanges{}), + reflect.TypeOf(map[string]*wcModel.SecuritySchemeChanges{}), + reflect.TypeOf(map[string]*wcModel.LinkChanges{}), + reflect.TypeOf(map[string]*wcModel.OperationChanges{}): if !field.IsZero() && len(field.MapKeys()) > 0 { - BuildTreeMapNode(list, field, level, markdown) - } - - case reflect.TypeOf(map[string]*wcModel.MediaTypeChanges{}): - if !field.IsZero() && len(field.MapKeys()) > 0 { - BuildTreeMapNode(list, field, level, markdown) - } - - case reflect.TypeOf(map[string]*wcModel.SecuritySchemeChanges{}): - if !field.IsZero() && len(field.MapKeys()) > 0 { - BuildTreeMapNode(list, field, level, markdown) - } - - case reflect.TypeOf(map[string]*wcModel.LinkChanges{}): - if !field.IsZero() && len(field.MapKeys()) > 0 { - BuildTreeMapNode(list, field, level, markdown) + label := getLabelForMapField(fName) + BuildLabeledTreeMapNode(list, field, level, label, markdown) } } @@ -179,7 +178,7 @@ func generateTreeState(change *wcModel.Change, markdown bool) string { if change.Breaking { breaking = "❌ " } - + // Helper function to safely dereference int pointers safeDeref := func(ptr *int) int { if ptr == nil { @@ -187,28 +186,55 @@ func generateTreeState(change *wcModel.Change, markdown bool) string { } return *ptr } - + + // For "codes", "tags", and "parameters" properties, use the actual value instead of the generic label + property := change.Property + + // Special handling for security scope changes (scheme/scope format) + if security.IsSecurityScopeChange(change) { + property = security.FormatSecurityScopeTitle(change) + } else { + lowerProp := strings.ToLower(change.Property) + // Handle "component/name" format (e.g., "schemas/Tree") - extract just the name + // But don't transform callback expressions like "{$request.body#/callbackUrl}" + isCallbackExpression := strings.HasPrefix(change.Property, "{$") && strings.HasSuffix(change.Property, "}") + if idx := strings.LastIndex(change.Property, "/"); idx != -1 && idx < len(change.Property)-1 && !isCallbackExpression { + property = change.Property[idx+1:] + } else if lowerProp == "codes" || lowerProp == "tags" || lowerProp == "parameters" { + switch change.ChangeType { + case wcModel.Modified, wcModel.PropertyRemoved, wcModel.ObjectRemoved: + if change.Original != "" { + property = change.Original + } + case wcModel.ObjectAdded, wcModel.PropertyAdded: + if change.New != "" { + property = change.New + } + } + } + } + switch change.ChangeType { case wcModel.Modified: if markdown { - return fmt.Sprintf("[🔀] %s (%d:%d)%s", change.Property, + return fmt.Sprintf("[🔀] %s (%d:%d)%s", property, safeDeref(change.Context.NewLine), safeDeref(change.Context.NewColumn), breaking) } - return fmt.Sprintf("[M] %s (%d:%d)%s", change.Property, + return fmt.Sprintf("[M] %s (%d:%d)%s", property, safeDeref(change.Context.NewLine), safeDeref(change.Context.NewColumn), breaking) case wcModel.ObjectAdded, wcModel.PropertyAdded: if markdown { - return fmt.Sprintf("[➕] %s (%d:%d)%s", change.Property, + return fmt.Sprintf("[➕] %s (%d:%d)%s", property, safeDeref(change.Context.NewLine), safeDeref(change.Context.NewColumn), breaking) } - return fmt.Sprintf("[+] %s (%d:%d)%s", change.Property, + return fmt.Sprintf("[+] %s (%d:%d)%s", property, safeDeref(change.Context.NewLine), safeDeref(change.Context.NewColumn), breaking) case wcModel.ObjectRemoved, wcModel.PropertyRemoved: if markdown { - return fmt.Sprintf("[➖] %s (%d:%d)%s", change.Property, + return fmt.Sprintf("[➖] %s (%d:%d)%s", property, safeDeref(change.Context.OriginalLine), safeDeref(change.Context.OriginalColumn), breaking) } - return fmt.Sprintf("[-] %s (%d:%d)%s", change.Property, + return fmt.Sprintf("[-] %s (%d:%d)%s", property, safeDeref(change.Context.OriginalLine), safeDeref(change.Context.OriginalColumn), breaking) } return "" @@ -237,18 +263,55 @@ func buildConsoleTree(doc *wcModel.DocumentChanges, markdown bool) { } func BuildSliceTreeNode[T any](list *[]pterm.LeveledListItem, field reflect.Value, level int, label string, markdown bool) { - if !field.IsZero() { + if !field.IsZero() && field.Len() > 0 { + // Add the label ONCE for the entire slice + *list = append(*list, pterm.LeveledListItem{Level: level, Text: label}) for k := 0; k < field.Len(); k++ { f := field.Index(k) if f.Elem().IsValid() && !f.Elem().IsZero() { ob := f.Elem().Interface().(T) - *list = append(*list, pterm.LeveledListItem{Level: level, Text: label}) buildConsoleTreeNode(list, &ob, level+1, markdown) } } } } +// BuildParameterSliceTreeNode builds tree nodes for parameter changes, adding the parameter name as a label. +// It also includes any pending parameter addition/removal changes from PropertyChanges. +func BuildParameterSliceTreeNode(list *[]pterm.LeveledListItem, field reflect.Value, level int, label string, markdown bool) { + hasParamChanges := !field.IsZero() && field.Len() > 0 + hasPendingChanges := len(pendingParamChanges) > 0 + + if hasParamChanges || hasPendingChanges { + // Add the "Parameters" label ONCE for the entire section + *list = append(*list, pterm.LeveledListItem{Level: level, Text: label}) + + // First, add changes for existing parameters (property modifications) + if hasParamChanges { + for k := 0; k < field.Len(); k++ { + f := field.Index(k) + if f.Elem().IsValid() && !f.Elem().IsZero() { + paramChanges := f.Elem().Interface().(wcModel.ParameterChanges) + // Add the parameter name as a label + if paramChanges.Name != "" { + *list = append(*list, pterm.LeveledListItem{Level: level + 1, Text: paramChanges.Name}) + buildConsoleTreeNode(list, ¶mChanges, level+2, markdown) + } else { + buildConsoleTreeNode(list, ¶mChanges, level+1, markdown) + } + } + } + } + + // Then, add parameter addition/removal changes from PropertyChanges + for _, change := range pendingParamChanges { + *list = append(*list, pterm.LeveledListItem{Level: level + 1, Text: generateTreeState(change, markdown)}) + } + // Clear pending changes after use + pendingParamChanges = nil + } +} + func DigIntoObject[T any](list *[]pterm.LeveledListItem, field reflect.Value, level int, label string, markdown bool) { if !field.IsZero() && field.Elem().IsValid() && !field.Elem().IsZero() { *list = append(*list, pterm.LeveledListItem{Level: level, Text: label}) @@ -259,7 +322,18 @@ func DigIntoObject[T any](list *[]pterm.LeveledListItem, field reflect.Value, le } func BuildTreeMapNode(list *[]pterm.LeveledListItem, field reflect.Value, level int, markdown bool) { + BuildLabeledTreeMapNode(list, field, level, "", markdown) +} + +// BuildLabeledTreeMapNode builds tree nodes for a map field with an optional label. +// If label is non-empty, it adds the label as a parent node before the map entries. +func BuildLabeledTreeMapNode(list *[]pterm.LeveledListItem, field reflect.Value, level int, label string, markdown bool) { if !field.IsZero() { + entryLevel := level + if label != "" { + *list = append(*list, pterm.LeveledListItem{Level: level, Text: label}) + entryLevel = level + 1 + } for _, e := range field.MapKeys() { v := field.MapIndex(e) @@ -267,10 +341,61 @@ func BuildTreeMapNode(list *[]pterm.LeveledListItem, field reflect.Value, level default: if t != nil { - *list = append(*list, pterm.LeveledListItem{Level: level, Text: fmt.Sprint(e)}) - buildConsoleTreeNode(list, t, level+1, markdown) + *list = append(*list, pterm.LeveledListItem{Level: entryLevel, Text: fmt.Sprint(e)}) + buildConsoleTreeNode(list, t, entryLevel+1, markdown) } } } } } + +// getLabelForMapField returns the appropriate tree label for a map field based on its name. +// Returns empty string if no label should be added (e.g., for path items where the key is self-explanatory). +func getLabelForMapField(fieldName string) string { + switch fieldName { + // Components maps + case "SchemaChanges": + return "Schemas" + case "SecuritySchemeChanges": + return "Security Schemes" + // Schema property maps + case "SchemaPropertyChanges": + return "Properties" + case "DependentSchemasChanges": + return "Dependent Schemas" + case "PatternPropertiesChanges": + return "Pattern Properties" + // Response/Parameter/Header/RequestBody content + case "ContentChanges": + return "Content" + case "HeadersChanges", "HeaderChanges": + return "Headers" + case "LinkChanges": + return "Links" + // Examples + case "ExamplesChanges", "ExampleChanges": + return "Examples" + // Encoding + case "EncodingChanges": + return "Encoding" + case "ItemEncodingChanges": + return "Item Encoding" + // Server variables + case "ServerVariableChanges": + return "Variables" + // Callbacks + case "CallbackChanges": + return "Callbacks" + // Document webhooks + case "WebhookChanges": + return "Webhooks" + // Additional operations (OpenAPI 3.2+) + case "AdditionalOperationChanges": + return "Additional Operations" + // These don't need labels - the keys are self-explanatory + case "PathItemsChanges", "ResponseChanges", "ExpressionChanges": + return "" + default: + return "" + } +} diff --git a/git/github.go b/git/github.go index ff05354..9160b04 100644 --- a/git/github.go +++ b/git/github.go @@ -17,6 +17,7 @@ import ( "time" "github.com/araddon/dateparse" + whatChangedModel "github.com/pb33f/libopenapi/what-changed/model" "github.com/pb33f/openapi-changes/model" ) @@ -280,7 +281,9 @@ func GetCommitsForGithubFile(user, repo, path string, baseCommit string, } func ConvertGithubCommitsIntoModel(ghCommits []*APICommit, - progressChan chan *model.ProgressUpdate, progressErrorChan chan model.ProgressError, base string, remote, extRefs bool) ([]*model.Commit, []error) { + progressChan chan *model.ProgressUpdate, progressErrorChan chan model.ProgressError, base string, remote, extRefs bool, + breakingConfig *whatChangedModel.BreakingRulesConfig, +) ([]*model.Commit, []error) { var normalized []*model.Commit if len(ghCommits) > 0 { @@ -310,7 +313,7 @@ func ConvertGithubCommitsIntoModel(ghCommits []*APICommit, model.SendProgressUpdate("converting commits", "building data models...", false, progressChan) } - normalized, errs = BuildCommitChangelog(normalized, progressChan, progressErrorChan, base, remote, extRefs) + normalized, errs = BuildCommitChangelog(normalized, progressChan, progressErrorChan, base, remote, extRefs, breakingConfig) if len(errs) > 0 { model.SendProgressError("converting commits", @@ -329,7 +332,9 @@ func ConvertGithubCommitsIntoModel(ghCommits []*APICommit, func ProcessGithubRepo(username, repo, filePath, baseCommit string, progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, - forceCutoff bool, limit int, limitTime int, base string, remote, extRefs bool) ([]*model.Commit, []error) { + forceCutoff bool, limit int, limitTime int, base string, remote, extRefs bool, + breakingConfig *whatChangedModel.BreakingRulesConfig, +) ([]*model.Commit, []error) { if username == "" || repo == "" || filePath == "" { err := errors.New("please supply valid github username/repo and filepath") @@ -343,7 +348,7 @@ func ProcessGithubRepo(username, repo, filePath, baseCommit string, return nil, []error{err} } - commitHistory, errs := ConvertGithubCommitsIntoModel(githubCommits, progressChan, errorChan, base, remote, extRefs) + commitHistory, errs := ConvertGithubCommitsIntoModel(githubCommits, progressChan, errorChan, base, remote, extRefs, breakingConfig) if errs != nil { for x := range errs { model.SendProgressError("git", errs[x].Error(), errorChan) diff --git a/git/read_local.go b/git/read_local.go index c8b51ad..5046012 100644 --- a/git/read_local.go +++ b/git/read_local.go @@ -19,6 +19,7 @@ import ( "github.com/araddon/dateparse" "github.com/pb33f/libopenapi" "github.com/pb33f/libopenapi/datamodel" + whatChangedModel "github.com/pb33f/libopenapi/what-changed/model" "github.com/pb33f/openapi-changes/model" "github.com/pterm/pterm" ) @@ -140,6 +141,7 @@ func ExtractHistoryFromFile(repoDirectory, filePath, baseCommit string, func PopulateHistoryWithChanges(commitHistory []*model.Commit, limit int, limitTime int, progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool, + breakingConfig *whatChangedModel.BreakingRulesConfig, ) ([]*model.Commit, []error) { for c := range commitHistory { var err error @@ -157,7 +159,7 @@ func PopulateHistoryWithChanges(commitHistory []*model.Commit, limit int, limitT commitHistory = commitHistory[0 : limit+1] } - cleaned, errs := BuildCommitChangelog(commitHistory, progressChan, errorChan, base, remote, extRefs) + cleaned, errs := BuildCommitChangelog(commitHistory, progressChan, errorChan, base, remote, extRefs, breakingConfig) if len(errs) > 0 { model.SendProgressError("git", fmt.Sprintf("%d error(s) found building commit change log", len(errs)), errorChan) @@ -172,10 +174,19 @@ func PopulateHistoryWithChanges(commitHistory []*model.Commit, limit int, limitT // TODO: we have reached peak argument count, we have to fix this. func BuildCommitChangelog(commitHistory []*model.Commit, progressChan chan *model.ProgressUpdate, errorChan chan model.ProgressError, base string, remote, extRefs bool, + breakingConfig *whatChangedModel.BreakingRulesConfig, ) ([]*model.Commit, []error) { var changeErrors []error var cleaned []*model.Commit + // apply breaking rules configuration before comparisons + if breakingConfig != nil { + defaults := whatChangedModel.GenerateDefaultBreakingRules() + defaults.Merge(breakingConfig) + whatChangedModel.SetActiveBreakingRulesConfig(defaults) + defer whatChangedModel.ResetActiveBreakingRulesConfig() + } + // create a new document config and set to default closed state, // enable it if the user has specified a base url or a path. docConfig := datamodel.NewDocumentConfiguration() diff --git a/go.mod b/go.mod index f8d3cb5..bccc09a 100644 --- a/go.mod +++ b/go.mod @@ -1,18 +1,21 @@ module github.com/pb33f/openapi-changes -go 1.24.7 +go 1.25 + +replace github.com/pb33f/libopenapi => ../libopenapi require ( github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de - github.com/gdamore/tcell/v2 v2.8.1 + github.com/gdamore/tcell/v2 v2.13.4 github.com/google/uuid v1.6.0 - github.com/pb33f/libopenapi v0.28.0 + github.com/pb33f/libopenapi v0.30.0 github.com/pmezard/go-difflib v1.0.0 - github.com/pterm/pterm v0.12.81 - github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb - github.com/spf13/cobra v1.9.1 + github.com/pterm/pterm v0.12.82 + github.com/rivo/tview v0.42.0 + github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.11.1 - golang.org/x/text v0.27.0 + go.yaml.in/yaml/v4 v4.0.0-rc.3 + golang.org/x/text v0.32.0 ) require ( @@ -27,20 +30,16 @@ require ( github.com/gookit/color v1.5.4 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect - github.com/mailru/easyjson v0.9.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/pb33f/jsonpath v0.1.2 // indirect + github.com/pb33f/jsonpath v0.7.0 // indirect github.com/pb33f/ordered-map/v2 v2.3.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/speakeasy-api/jsonpath v0.6.2 // indirect - github.com/spf13/pflag v1.0.7 // indirect - github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240815153524-6ea36470d1bd // indirect + github.com/spf13/pflag v1.0.9 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/exp v0.0.0-20241004190924-225e2abe05e6 // indirect - golang.org/x/sys v0.33.0 // indirect - golang.org/x/term v0.32.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/term v0.37.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 0cf89d9..fed03a6 100644 --- a/go.sum +++ b/go.sum @@ -31,9 +31,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw= github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo= -github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU= -github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/gdamore/tcell/v2 v2.13.4 h1:k4fdtdHGvLsLr2RttPnWEGTZEkEuTaL+rL6AOVFyRWU= +github.com/gdamore/tcell/v2 v2.13.4/go.mod h1:+Wfe208WDdB7INEtCsNrAN6O2m+wsTPk1RAovjaILlo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQHCoQ= @@ -48,28 +47,23 @@ github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuOb github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= -github.com/kr/pretty v0.2.1 h1:Fmg33tUaq4/8ym9TJN1x7sLJnHVwhP33CNkpYV/7rwI= github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lithammer/fuzzysearch v1.1.8 h1:/HIuJnjHuXS8bKaiTMeeDlW2/AyIWk2brx1V8LFgLN4= github.com/lithammer/fuzzysearch v1.1.8/go.mod h1:IdqeyBClc3FFqSzYq/MXESsS4S0FsZ5ajtkr5xPLts4= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4= -github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/pb33f/jsonpath v0.1.2 h1:PlqXjEyecMqoYJupLxYeClCGWEpAFnh4pmzgspbXDPI= -github.com/pb33f/jsonpath v0.1.2/go.mod h1:TtKnUnfqZm48q7a56DxB3WtL3ipkVtukMKGKxaR/uXU= -github.com/pb33f/libopenapi v0.25.0 h1:ZFmPoqr9+SUPtrFz62hbiyP00MZT55Mib4Gkp3LLxPs= -github.com/pb33f/libopenapi v0.25.0/go.mod h1:utT5sD2/mnN7YK68FfZT5yEPbI1wwRBpSS4Hi0oOrBU= -github.com/pb33f/libopenapi v0.28.0 h1:j8o3Tttxo1AvX/QknIVXvmF1ixiR8Bl93FOgB+OQPt0= -github.com/pb33f/libopenapi v0.28.0/go.mod h1:mHMHA3ZKSZDTInNAuUtqkHlKLIjPm2HN1vgsGR57afc= +github.com/pb33f/jsonpath v0.7.0 h1:3oG6yu1RqNoMZpqnRjBMqi8fSIXWoDAKDrsB0QGTcoU= +github.com/pb33f/jsonpath v0.7.0/go.mod h1:/+JlSIjWA2ijMVYGJ3IQPF4Q1nLMYbUTYNdk0exCDPQ= github.com/pb33f/ordered-map/v2 v2.3.0 h1:k2OhVEQkhTCQMhAicQ3Z6iInzoZNQ7L9MVomwKBZ5WQ= github.com/pb33f/ordered-map/v2 v2.3.0/go.mod h1:oe5ue+6ZNhy7QN9cPZvPA23Hx0vMHnNVeMg4fGdCANw= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= @@ -81,68 +75,50 @@ github.com/pterm/pterm v0.12.31/go.mod h1:32ZAWZVXD7ZfG0s8qqHXePte42kdz8ECtRyEej github.com/pterm/pterm v0.12.33/go.mod h1:x+h2uL+n7CP/rel9+bImHD5lF3nM9vJj80k9ybiiTTE= github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5bUw8T8= github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s= -github.com/pterm/pterm v0.12.81 h1:ju+j5I2++FO1jBKMmscgh5h5DPFDFMB7epEjSoKehKA= -github.com/pterm/pterm v0.12.81/go.mod h1:TyuyrPjnxfwP+ccJdBTeWHtd/e0ybQHkOS/TakajZCw= -github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb h1:n7UJ8X9UnrTZBYXnd1kAIBc067SWyuPIrsocjketYW8= -github.com/rivo/tview v0.0.0-20250625164341-a4a78f1e05cb/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= +github.com/pterm/pterm v0.12.82 h1:+D9wYhCaeaK0FIQoZtqbNQuNpe2lB2tajKKsTd5paVQ= +github.com/pterm/pterm v0.12.82/go.mod h1:TyuyrPjnxfwP+ccJdBTeWHtd/e0ybQHkOS/TakajZCw= +github.com/rivo/tview v0.42.0 h1:b/ftp+RxtDsHSaynXTbJb+/n/BxDEi+W3UfF5jILK6c= +github.com/rivo/tview v0.42.0/go.mod h1:cSfIYfhpSGCjp3r/ECJb+GKS7cGJnqV8vfjQPwoXyfY= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= -github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= +github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= -github.com/speakeasy-api/jsonpath v0.6.2 h1:Mys71yd6u8kuowNCR0gCVPlVAHCmKtoGXYoAtcEbqXQ= -github.com/speakeasy-api/jsonpath v0.6.2/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= -github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo= -github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0= -github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= -github.com/spf13/pflag v1.0.7 h1:vN6T9TfwStFPFM5XzjsvmzZkLuaLX+HS+0SeFLRgU6M= -github.com/spf13/pflag v1.0.7/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= -github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240815153524-6ea36470d1bd h1:dLuIF2kX9c+KknGJUdJi1Il1SDiTSK158/BB9kdgAew= -github.com/wk8/go-ordered-map/v2 v2.1.9-0.20240815153524-6ea36470d1bd/go.mod h1:DbzwytT4g/odXquuOCqroKvtxxldI4nb3nuesHF/Exo= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= -go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v4 v4.0.0-rc.3 h1:3h1fjsh1CTAPjW7q/EMe+C8shx5d8ctzZTrLcs/j8Go= +go.yaml.in/yaml/v4 v4.0.0-rc.3/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= -golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= -golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= -golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= golang.org/x/exp v0.0.0-20241004190924-225e2abe05e6 h1:1wqE9dj9NpSm04INVsJhhEUzhuDVjbcyKH91sVyPATw= golang.org/x/exp v0.0.0-20241004190924-225e2abe05e6/go.mod h1:NQtJDoLvd6faHhE7m4T/1IY708gDefGGjR/iUW8yQQ8= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= -golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= -golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= -golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= -golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= -golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -154,43 +130,27 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= -golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= -golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= -golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= -golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= -golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg= -golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= -golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= -golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= -golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= -golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/security/scope_detection.go b/internal/security/scope_detection.go new file mode 100644 index 0000000..c7b4c2d --- /dev/null +++ b/internal/security/scope_detection.go @@ -0,0 +1,75 @@ +// Copyright 2022 Princess B33f Heavy Industries / Dave Shanley +// SPDX-License-Identifier: MIT + +package security + +import ( + "strings" + + wcModel "github.com/pb33f/libopenapi/what-changed/model" +) + +// knownNonSecurityProperties is a package-level constant - allocated once at startup. +// This avoids allocating a new map on every call to IsSecurityScopeChange. +var knownNonSecurityProperties = map[string]bool{ + "servers": true, "parent": true, "kind": true, "enum": true, + "default": true, "description": true, "name": true, "url": true, + "summary": true, "title": true, "version": true, "email": true, + "license": true, "contact": true, "termsofservice": true, + "openapi": true, "info": true, "tags": true, "paths": true, + "components": true, "security": true, "externaldocs": true, + "identifier": true, "jsonschemadialect": true, "$self": true, + "parameters": true, "codes": true, "callbacks": true, + "required": true, "type": true, +} + +// IsSecurityScopeChange checks if this is a security scope change where +// Property contains scheme name and New/Original contains scope value. +// We need to be restrictive to avoid false positives on other change types. +func IsSecurityScopeChange(change *wcModel.Change) bool { + if change.Property == "" { + return false + } + + // Security scheme names don't contain "/" - if Property has "/" it's something else + // (e.g., "parameters/chaps" is a parameter addition, not a security scope) + if strings.Contains(change.Property, "/") { + return false + } + + // Use lowercase for single lookup (all keys in map are lowercase) + if knownNonSecurityProperties[strings.ToLower(change.Property)] { + return false + } + + // Only consider ObjectAdded/ObjectRemoved, not property changes + switch change.ChangeType { + case wcModel.ObjectAdded: + // Security scopes are simple strings without URLs or special patterns + if change.New != "" && change.New != change.Property && + !strings.Contains(change.New, "://") && !strings.Contains(change.New, "/") { + return true + } + case wcModel.ObjectRemoved: + if change.Original != "" && change.Original != change.Property && + !strings.Contains(change.Original, "://") && !strings.Contains(change.Original, "/") { + return true + } + } + return false +} + +// FormatSecurityScopeTitle formats security scope changes as "scheme/scope" +func FormatSecurityScopeTitle(change *wcModel.Change) string { + switch change.ChangeType { + case wcModel.ObjectAdded, wcModel.PropertyAdded: + if change.New != "" { + return change.Property + "/" + change.New + } + case wcModel.ObjectRemoved, wcModel.PropertyRemoved: + if change.Original != "" { + return change.Property + "/" + change.Original + } + } + return change.Property +} diff --git a/sample-specs/sample_configs/invalid-config.yaml b/sample-specs/sample_configs/invalid-config.yaml new file mode 100644 index 0000000..1c3080e --- /dev/null +++ b/sample-specs/sample_configs/invalid-config.yaml @@ -0,0 +1,12 @@ +# INVALID CONFIG - For Testing Only +# ================================== +# This file contains intentionally malformed YAML to test error handling. +# DO NOT use this as a reference for your own configuration! +# +# openapi-changes will report a clear error message when attempting +# to load this file, demonstrating the config validation behavior. + +pathItem: + get: + removed: [not a boolean + this is broken yaml diff --git a/sample-specs/sample_configs/partial-config.yaml b/sample-specs/sample_configs/partial-config.yaml new file mode 100644 index 0000000..c490504 --- /dev/null +++ b/sample-specs/sample_configs/partial-config.yaml @@ -0,0 +1,17 @@ +# Partial Breaking Rules Configuration +# ===================================== +# You don't need to specify every rule - just the ones you want to override. +# All unspecified rules will use the default values from libopenapi. +# +# This is useful when you only need to tweak a few specific behaviors +# without having to maintain a complete configuration file. +# +# Example: Your team has decided that removing enum values is acceptable +# because you use a deprecation process that gives clients time to migrate. + +schema: + enum: + removed: false # Allow enum value removal without flagging as breaking + +# That's it! All other rules (hundreds of them) use sensible defaults. +# See valid-config.yaml for a more comprehensive example. diff --git a/sample-specs/sample_configs/valid-config.yaml b/sample-specs/sample_configs/valid-config.yaml new file mode 100644 index 0000000..21f28a6 --- /dev/null +++ b/sample-specs/sample_configs/valid-config.yaml @@ -0,0 +1,104 @@ +# Breaking Rules Configuration for openapi-changes +# ================================================ +# This file customizes which OpenAPI changes are considered "breaking". +# Only specify overrides - unspecified rules inherit defaults from libopenapi. +# +# Each rule has three options: +# added: true/false - Is adding this element a breaking change? +# modified: true/false - Is modifying this element a breaking change? +# removed: true/false - Is removing this element a breaking change? +# +# Documentation: https://pb33f.io/openapi-changes/configuring/ + +# Path Item Rules +# --------------- +# Controls breaking detection for HTTP operations on paths. +# Common use case: Allow operation removal during deprecation workflows. +pathItem: + get: + removed: false # Removing GET operations is not breaking + post: + removed: false # Removing POST operations is not breaking + put: + removed: false + delete: + removed: false + patch: + removed: false + +# Operation Rules +# --------------- +# Controls breaking detection for operation-level changes. +operation: + operationId: + modified: false # Changing operationId is not breaking (client codegen may differ) + tags: + added: false # Adding tags is not breaking + removed: false # Removing tags is not breaking + requestBody: + added: true # Adding a request body IS breaking (clients need to send data) + removed: false # Removing request body is not breaking + callbacks: + added: false # Adding callbacks is not breaking + +# Parameter Rules +# --------------- +# Controls breaking detection for parameter changes. +parameter: + required: + modified: true # Changing required status IS breaking + in: + modified: true # Changing parameter location IS breaking + allowEmptyValue: + modified: false # Changing allowEmptyValue is not breaking + +# Schema Rules +# ------------ +# Controls breaking detection for schema/model changes. +# These rules apply to all schemas (request bodies, responses, parameters). +schema: + type: + modified: true # Changing type IS breaking + format: + modified: false # Changing format is not breaking (e.g., int32 -> int64) + enum: + added: false # Adding enum values is not breaking + removed: false # Removing enum values is not breaking (if using deprecation workflow) + required: + added: true # Adding required properties IS breaking + removed: false # Removing required properties is not breaking + properties: + removed: true # Removing properties IS breaking + minLength: + modified: true # Tightening constraints IS breaking + maxLength: + modified: true + minimum: + modified: true + maximum: + modified: true + +# Response Rules +# -------------- +# Controls breaking detection for response changes. +response: + description: + modified: false # Changing descriptions is not breaking + headers: + removed: false # Removing response headers is not breaking + +# Security Scheme Rules +# --------------------- +# Controls breaking detection for security-related changes. +securityScheme: + type: + modified: true # Changing auth type IS breaking + scheme: + modified: true # Changing auth scheme IS breaking + +# Security Requirement Rules +# -------------------------- +securityRequirement: + scopes: + added: true # Requiring new scopes IS breaking + removed: false # Removing scope requirements is not breaking diff --git a/tui/build_tree.go b/tui/build_tree.go index cdcb928..df9e575 100644 --- a/tui/build_tree.go +++ b/tui/build_tree.go @@ -240,31 +240,96 @@ func buildTreeNode(root *tview.TreeNode, object any) *tview.TreeNode { } caser := cases.Title(language.AmericanEnglish) + + // Separate tag changes from other changes so we can group them + var tagChanges []*whatChangedModel.Change + var otherChanges []*whatChangedModel.Change for i := range topChanges { + if strings.ToLower(topChanges[i].Property) == "tags" { + tagChanges = append(tagChanges, topChanges[i]) + } else { + otherChanges = append(otherChanges, topChanges[i]) + } + } + + // Create Tags node if there are tag changes + if len(tagChanges) > 0 { + tagsNode := CreateNode("Tags", object) + root.AddChild(tagsNode) + for _, change := range tagChanges { + msg := "" + var color RGB + title := change.New + if change.Original != "" && change.New == "" { + title = change.Original + } + if change.ChangeType == whatChangedModel.PropertyRemoved || change.ChangeType == whatChangedModel.ObjectRemoved { + br := "" + color = CYAN_RGB + if change.Breaking { + br = "{X}" + color = RGB{255, 0, 0} + } + msg = fmt.Sprintf(" - %s Removed %s", title, br) + } + if change.ChangeType == whatChangedModel.PropertyAdded || change.ChangeType == whatChangedModel.ObjectAdded { + msg = fmt.Sprintf(" + %s Added", title) + color = CYAN_RGB + } + if change.ChangeType == whatChangedModel.Modified { + msg = fmt.Sprintf(" %s Changed", title) + color = MAGENTA_RGB + } + node := tview.NewTreeNode(msg). + SetReference(change). + SetSelectable(true) + node.SetColor(tcell.NewRGBColor(color.R(), color.G(), color.B())) + tagsNode.AddChild(node) + } + } + // Process other changes normally + for i := range otherChanges { msg := "" var color RGB - if topChanges[i].ChangeType == whatChangedModel.PropertyRemoved || topChanges[i].ChangeType == whatChangedModel.ObjectRemoved { + + // For "codes" properties, use the actual value instead of the generic label + title := otherChanges[i].Property + lowerProp := strings.ToLower(otherChanges[i].Property) + if lowerProp == "codes" { + switch otherChanges[i].ChangeType { + case whatChangedModel.Modified, whatChangedModel.PropertyRemoved, whatChangedModel.ObjectRemoved: + if otherChanges[i].Original != "" { + title = otherChanges[i].Original + } + case whatChangedModel.ObjectAdded, whatChangedModel.PropertyAdded: + if otherChanges[i].New != "" { + title = otherChanges[i].New + } + } + } + + if otherChanges[i].ChangeType == whatChangedModel.PropertyRemoved || otherChanges[i].ChangeType == whatChangedModel.ObjectRemoved { var br = "" color = CYAN_RGB - if topChanges[i].Breaking { + if otherChanges[i].Breaking { br = "{X}" color = RGB{255, 0, 0} } - msg = fmt.Sprintf(" - %s Removed %s", caser.String(topChanges[i].Property), br) + msg = fmt.Sprintf(" - %s Removed %s", caser.String(title), br) } - if topChanges[i].ChangeType == whatChangedModel.PropertyAdded || topChanges[i].ChangeType == whatChangedModel.ObjectAdded { - msg = fmt.Sprintf(" + %s Added", caser.String(topChanges[i].Property)) + if otherChanges[i].ChangeType == whatChangedModel.PropertyAdded || otherChanges[i].ChangeType == whatChangedModel.ObjectAdded { + msg = fmt.Sprintf(" + %s Added", caser.String(title)) color = CYAN_RGB } - if topChanges[i].ChangeType == whatChangedModel.Modified { - msg = fmt.Sprintf(" %s Changed", caser.String(topChanges[i].Property)) + if otherChanges[i].ChangeType == whatChangedModel.Modified { + msg = fmt.Sprintf(" %s Changed", caser.String(title)) color = MAGENTA_RGB } node := tview.NewTreeNode(msg). - SetReference(topChanges[i]). + SetReference(otherChanges[i]). SetSelectable(true) node.SetColor(tcell.NewRGBColor(color.R(), color.G(), color.B())) From cb20bb0c2be964789b52f58b755132c9768fa0aa Mon Sep 17 00:00:00 2001 From: quobix Date: Wed, 17 Dec 2025 11:33:50 -0500 Subject: [PATCH 2/2] fixed replace mistake --- go.mod | 2 -- 1 file changed, 2 deletions(-) diff --git a/go.mod b/go.mod index bccc09a..2dc9e9e 100644 --- a/go.mod +++ b/go.mod @@ -2,8 +2,6 @@ module github.com/pb33f/openapi-changes go 1.25 -replace github.com/pb33f/libopenapi => ../libopenapi - require ( github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de github.com/gdamore/tcell/v2 v2.13.4