diff --git a/go.mod b/go.mod index c6c6e2967..a02997d6c 100644 --- a/go.mod +++ b/go.mod @@ -28,7 +28,7 @@ require ( github.com/lithammer/fuzzysearch v1.1.8 github.com/mailru/easyjson v0.7.7 // indirect github.com/modelcontextprotocol/go-sdk v1.2.0 - github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/rogpeppe/go-internal v1.13.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect @@ -50,5 +50,5 @@ require ( golang.org/x/text v0.28.0 // indirect gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect + gopkg.in/yaml.v3 v3.0.1 ) diff --git a/pkg/github/__toolsnaps__/compare_file_contents.snap b/pkg/github/__toolsnaps__/compare_file_contents.snap new file mode 100644 index 000000000..88bc283ad --- /dev/null +++ b/pkg/github/__toolsnaps__/compare_file_contents.snap @@ -0,0 +1,40 @@ +{ + "annotations": { + "readOnlyHint": true, + "title": "Compare file contents between revisions" + }, + "description": "Compare two versions of a file in a GitHub repository.\nFor structured formats (JSON, YAML, CSV, TOML), produces a semantic diff that shows only meaningful changes, ignoring formatting differences.\nFor other file types, produces a standard unified diff.\nThis is useful for understanding what actually changed between two versions of a file, especially for configuration files and data files where reformatting can obscure real changes.", + "inputSchema": { + "properties": { + "base": { + "description": "Base ref to compare from (commit SHA, branch name, or tag name)", + "type": "string" + }, + "head": { + "description": "Head ref to compare to (commit SHA, branch name, or tag name)", + "type": "string" + }, + "owner": { + "description": "Repository owner (username or organization)", + "type": "string" + }, + "path": { + "description": "Path to the file to compare", + "type": "string" + }, + "repo": { + "description": "Repository name", + "type": "string" + } + }, + "required": [ + "owner", + "repo", + "path", + "base", + "head" + ], + "type": "object" + }, + "name": "compare_file_contents" +} \ No newline at end of file diff --git a/pkg/github/compare_file_contents.go b/pkg/github/compare_file_contents.go new file mode 100644 index 000000000..63855baca --- /dev/null +++ b/pkg/github/compare_file_contents.go @@ -0,0 +1,159 @@ +package github + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/github/github-mcp-server/pkg/inventory" + "github.com/github/github-mcp-server/pkg/scopes" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/github/github-mcp-server/pkg/utils" + "github.com/google/go-github/v79/github" + "github.com/google/jsonschema-go/jsonschema" + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +// FeatureFlagCompareFileContents is the feature flag for the compare_file_contents tool. +const FeatureFlagCompareFileContents = "mcp_compare_file_contents" + +// CompareFileContents creates a tool to compare two versions of a file in a GitHub repository. +// For supported formats (JSON, YAML, CSV, TOML), it produces semantic diffs showing +// only meaningful changes. For other formats, it falls back to unified diff. +func CompareFileContents(t translations.TranslationHelperFunc) inventory.ServerTool { + tool := NewTool( + ToolsetMetadataRepos, + mcp.Tool{ + Name: "compare_file_contents", + Description: t("TOOL_COMPARE_FILE_CONTENTS_DESCRIPTION", `Compare two versions of a file in a GitHub repository. +For structured formats (JSON, YAML, CSV, TOML), produces a semantic diff that shows only meaningful changes, ignoring formatting differences. +For other file types, produces a standard unified diff. +This is useful for understanding what actually changed between two versions of a file, especially for configuration files and data files where reformatting can obscure real changes.`), + Annotations: &mcp.ToolAnnotations{ + Title: t("TOOL_COMPARE_FILE_CONTENTS_USER_TITLE", "Compare file contents between revisions"), + ReadOnlyHint: true, + }, + InputSchema: &jsonschema.Schema{ + Type: "object", + Properties: map[string]*jsonschema.Schema{ + "owner": { + Type: "string", + Description: "Repository owner (username or organization)", + }, + "repo": { + Type: "string", + Description: "Repository name", + }, + "path": { + Type: "string", + Description: "Path to the file to compare", + }, + "base": { + Type: "string", + Description: "Base ref to compare from (commit SHA, branch name, or tag name)", + }, + "head": { + Type: "string", + Description: "Head ref to compare to (commit SHA, branch name, or tag name)", + }, + }, + Required: []string{"owner", "repo", "path", "base", "head"}, + }, + }, + []scopes.Scope{scopes.Repo}, + func(ctx context.Context, deps ToolDependencies, _ *mcp.CallToolRequest, args map[string]any) (*mcp.CallToolResult, any, error) { + owner, err := RequiredParam[string](args, "owner") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + repo, err := RequiredParam[string](args, "repo") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + path, err := RequiredParam[string](args, "path") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + base, err := RequiredParam[string](args, "base") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + head, err := RequiredParam[string](args, "head") + if err != nil { + return utils.NewToolResultError(err.Error()), nil, nil + } + + client, err := deps.GetClient(ctx) + if err != nil { + return nil, nil, fmt.Errorf("failed to get GitHub client: %w", err) + } + + baseContent, baseErr := getFileAtRef(ctx, client, owner, repo, path, base) + headContent, headErr := getFileAtRef(ctx, client, owner, repo, path, head) + + // If both sides fail, report the errors + if baseErr != nil && headErr != nil { + return utils.NewToolResultError(fmt.Sprintf("failed to get file at both refs: base %q: %s, head %q: %s", base, baseErr, head, headErr)), nil, nil + } + + // A nil content with no error won't happen from getFileAtRef, + // but a non-nil error on one side means the file doesn't exist at that ref. + // Pass nil to SemanticDiff to indicate added/deleted file. + if baseErr != nil { + baseContent = nil + } + if headErr != nil { + headContent = nil + } + + result := SemanticDiff(path, baseContent, headContent) + + output, err := json.Marshal(result) + if err != nil { + return nil, nil, fmt.Errorf("failed to marshal diff result: %w", err) + } + + return utils.NewToolResultText(string(output)), nil, nil + }, + ) + tool.FeatureFlagEnable = FeatureFlagCompareFileContents + return tool +} + +// getFileAtRef fetches file content from a GitHub repository at a specific ref. +func getFileAtRef(ctx context.Context, client *github.Client, owner, repo, path, ref string) ([]byte, error) { + opts := &github.RepositoryContentGetOptions{Ref: ref} + fileContent, _, resp, err := client.Repositories.GetContents(ctx, owner, repo, path, opts) + if err != nil { + return nil, err + } + if resp == nil { + return nil, fmt.Errorf("no response received") + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %w", err) + } + return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + + if fileContent == nil { + return nil, fmt.Errorf("path %q is a directory, not a file", path) + } + + content, err := fileContent.GetContent() + if err != nil { + return nil, fmt.Errorf("failed to decode file content: %w", err) + } + + if len(content) > MaxSemanticDiffFileSize { + return nil, fmt.Errorf("file exceeds maximum size of %d bytes", MaxSemanticDiffFileSize) + } + + return []byte(content), nil +} diff --git a/pkg/github/compare_file_contents_test.go b/pkg/github/compare_file_contents_test.go new file mode 100644 index 000000000..af0b9d5a2 --- /dev/null +++ b/pkg/github/compare_file_contents_test.go @@ -0,0 +1,275 @@ +package github + +import ( + "context" + "encoding/base64" + "encoding/json" + "net/http" + "testing" + + "github.com/github/github-mcp-server/internal/toolsnaps" + "github.com/github/github-mcp-server/pkg/translations" + "github.com/google/go-github/v79/github" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_CompareFileContents(t *testing.T) { + serverTool := CompareFileContents(translations.NullTranslationHelper) + tool := serverTool.Tool + + require.NoError(t, toolsnaps.Test(tool.Name, tool)) + + assert.Equal(t, "compare_file_contents", tool.Name) + assert.NotEmpty(t, tool.Description) + assert.True(t, tool.Annotations.ReadOnlyHint, "compare_file_contents should be read-only") + assert.Equal(t, FeatureFlagCompareFileContents, serverTool.FeatureFlagEnable) + + // Helper to create a mock handler that returns file content for a specific ref + mockContentsForRef := func(contentsByRef map[string]string) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + ref := r.URL.Query().Get("ref") + content, ok := contentsByRef[ref] + if !ok { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message": "Not Found"}`)) + return + } + encoded := base64.StdEncoding.EncodeToString([]byte(content)) + fileContent := &github.RepositoryContent{ + Name: github.Ptr("config.json"), + Path: github.Ptr("config.json"), + SHA: github.Ptr("abc123"), + Type: github.Ptr("file"), + Encoding: github.Ptr("base64"), + Content: github.Ptr(encoded), + } + w.WriteHeader(http.StatusOK) + data, _ := json.Marshal(fileContent) + _, _ = w.Write(data) + } + } + + tests := []struct { + name string + mockedClient *http.Client + requestArgs map[string]interface{} + expectError bool + expectedErrMsg string + expectFormat string + expectDiff string + }{ + { + name: "JSON semantic diff - value change", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposContentsByOwnerByRepoByPath: mockContentsForRef(map[string]string{ + "main": `{"theme": "light", "version": "1.0"}`, + "feature": `{"theme": "dark", "version": "1.0"}`, + }), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "config.json", + "base": "main", + "head": "feature", + }, + expectFormat: "json", + expectDiff: `theme: "light" → "dark"`, + }, + { + name: "JSON semantic diff - no changes after reformatting", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposContentsByOwnerByRepoByPath: mockContentsForRef(map[string]string{ + "main": `{"key":"value","num":42}`, + "feature": "{\n \"key\": \"value\",\n \"num\": 42\n}", + }), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "config.json", + "base": "main", + "head": "feature", + }, + expectFormat: "json", + expectDiff: "no changes detected", + }, + { + name: "YAML semantic diff", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposContentsByOwnerByRepoByPath: mockContentsForRef(map[string]string{ + "v1": "host: localhost\nport: 5432\n", + "v2": "host: production.db\nport: 5432\n", + }), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "config.yaml", + "base": "v1", + "head": "v2", + }, + expectFormat: "yaml", + expectDiff: `host: "localhost" → "production.db"`, + }, + { + name: "unsupported format falls back to unified diff", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposContentsByOwnerByRepoByPath: mockContentsForRef(map[string]string{ + "main": "func main() {}\n", + "feature": "func main() {\n\tfmt.Println(\"hello\")\n}\n", + }), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "main.go", + "base": "main", + "head": "feature", + }, + expectFormat: "unified", + expectDiff: "--- a/main.go", + }, + { + name: "missing required parameter - owner", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]interface{}{ + "repo": "repo", + "path": "config.json", + "base": "main", + "head": "feature", + }, + expectError: true, + expectedErrMsg: "missing required parameter: owner", + }, + { + name: "missing required parameter - base", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{}), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "config.json", + "head": "feature", + }, + expectError: true, + expectedErrMsg: "missing required parameter: base", + }, + { + name: "new file - base not found", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposContentsByOwnerByRepoByPath: mockContentsForRef(map[string]string{ + "feature": `{"key": "value"}`, + }), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "config.json", + "base": "main", + "head": "feature", + }, + expectFormat: "json", + expectDiff: "file added", + }, + { + name: "deleted file - head not found", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposContentsByOwnerByRepoByPath: mockContentsForRef(map[string]string{ + "main": `{"key": "value"}`, + }), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "config.json", + "base": "main", + "head": "feature", + }, + expectFormat: "json", + expectDiff: "file deleted", + }, + { + name: "both refs not found", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposContentsByOwnerByRepoByPath: mockContentsForRef(map[string]string{}), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "config.json", + "base": "main", + "head": "feature", + }, + expectError: true, + expectedErrMsg: "failed to get file at both refs", + }, + { + name: "CSV semantic diff", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposContentsByOwnerByRepoByPath: mockContentsForRef(map[string]string{ + "main": "name,status\nAlice,active\nBob,pending\n", + "feature": "name,status\nAlice,active\nBob,shipped\n", + }), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "data.csv", + "base": "main", + "head": "feature", + }, + expectFormat: "csv", + expectDiff: `row 2.status: "pending" → "shipped"`, + }, + { + name: "TOML semantic diff", + mockedClient: MockHTTPClientWithHandlers(map[string]http.HandlerFunc{ + GetReposContentsByOwnerByRepoByPath: mockContentsForRef(map[string]string{ + "main": "[database]\nhost = \"localhost\"\n", + "feature": "[database]\nhost = \"production.db\"\n", + }), + }), + requestArgs: map[string]interface{}{ + "owner": "owner", + "repo": "repo", + "path": "config.toml", + "base": "main", + "head": "feature", + }, + expectFormat: "toml", + expectDiff: `database.host: "localhost" → "production.db"`, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + client := github.NewClient(tc.mockedClient) + deps := BaseDeps{ + Client: client, + } + handler := serverTool.Handler(deps) + + request := createMCPRequest(tc.requestArgs) + result, err := handler(ContextWithDeps(context.Background(), deps), &request) + require.NoError(t, err) + + if tc.expectError { + require.True(t, result.IsError) + errorContent := getErrorResult(t, result) + assert.Contains(t, errorContent.Text, tc.expectedErrMsg) + return + } + + require.False(t, result.IsError) + textContent := getTextResult(t, result) + + var diffResult SemanticDiffResult + err = json.Unmarshal([]byte(textContent.Text), &diffResult) + require.NoError(t, err) + + assert.Equal(t, DiffFormat(tc.expectFormat), diffResult.Format) + assert.Contains(t, diffResult.Diff, tc.expectDiff) + }) + } +} diff --git a/pkg/github/semantic_diff.go b/pkg/github/semantic_diff.go new file mode 100644 index 000000000..f48d3625a --- /dev/null +++ b/pkg/github/semantic_diff.go @@ -0,0 +1,547 @@ +package github + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "fmt" + "path/filepath" + "sort" + "strings" + + "github.com/pelletier/go-toml/v2" + "gopkg.in/yaml.v3" +) + +// MaxSemanticDiffFileSize is the maximum file size (in bytes) for semantic diff processing. +// Files larger than this fall back to unified diff to prevent excessive server-side processing. +const MaxSemanticDiffFileSize = 1024 * 1024 // 1MB + +// DiffFormat represents the format used for diffing. +type DiffFormat string + +const ( + DiffFormatJSON DiffFormat = "json" + DiffFormatYAML DiffFormat = "yaml" + DiffFormatCSV DiffFormat = "csv" + DiffFormatTOML DiffFormat = "toml" + DiffFormatUnified DiffFormat = "unified" + DiffFormatFallback DiffFormat = "fallback" +) + +// SemanticDiffResult holds the output of a semantic diff operation. +type SemanticDiffResult struct { + Format DiffFormat `json:"format"` + Diff string `json:"diff"` + Message string `json:"message,omitempty"` +} + +// SemanticDiff compares two versions of a file and returns a semantic diff +// for supported formats, or a unified diff as a fallback. +// A nil base indicates a new file; a nil head indicates a deleted file. +func SemanticDiff(path string, base, head []byte) SemanticDiffResult { + if base == nil && head == nil { + return SemanticDiffResult{ + Format: DiffFormatUnified, + Diff: "no changes detected", + } + } + + if base == nil { + return SemanticDiffResult{ + Format: DetectDiffFormat(path), + Diff: "file added", + } + } + + if head == nil { + return SemanticDiffResult{ + Format: DetectDiffFormat(path), + Diff: "file deleted", + } + } + + if len(base) > MaxSemanticDiffFileSize || len(head) > MaxSemanticDiffFileSize { + return SemanticDiffResult{ + Format: DiffFormatFallback, + Diff: unifiedDiff(path, base, head), + Message: "file exceeds maximum size for semantic diff, using unified diff", + } + } + + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".json": + return semanticDiffJSON(path, base, head) + case ".yaml", ".yml": + return semanticDiffYAML(path, base, head) + case ".csv": + return semanticDiffCSV(path, base, head) + case ".toml": + return semanticDiffTOML(path, base, head) + default: + return SemanticDiffResult{ + Format: DiffFormatUnified, + Diff: unifiedDiff(path, base, head), + } + } +} + +// semanticDiffJSON parses both versions as JSON and produces a path-based diff. +func semanticDiffJSON(path string, base, head []byte) SemanticDiffResult { + var baseVal, headVal any + if err := json.Unmarshal(base, &baseVal); err != nil { + return fallbackResult(path, base, head, "failed to parse base as JSON") + } + if err := json.Unmarshal(head, &headVal); err != nil { + return fallbackResult(path, base, head, "failed to parse head as JSON") + } + + changes := compareValues("", baseVal, headVal) + if len(changes) == 0 { + return SemanticDiffResult{ + Format: DiffFormatJSON, + Diff: "no changes detected", + } + } + + return SemanticDiffResult{ + Format: DiffFormatJSON, + Diff: strings.Join(changes, "\n"), + } +} + +// semanticDiffYAML parses both versions as YAML and produces a path-based diff. +func semanticDiffYAML(path string, base, head []byte) SemanticDiffResult { + var baseVal, headVal any + if err := yaml.Unmarshal(base, &baseVal); err != nil { + return fallbackResult(path, base, head, "failed to parse base as YAML") + } + if err := yaml.Unmarshal(head, &headVal); err != nil { + return fallbackResult(path, base, head, "failed to parse head as YAML") + } + + changes := compareValues("", baseVal, headVal) + if len(changes) == 0 { + return SemanticDiffResult{ + Format: DiffFormatYAML, + Diff: "no changes detected", + } + } + + return SemanticDiffResult{ + Format: DiffFormatYAML, + Diff: strings.Join(changes, "\n"), + } +} + +// semanticDiffCSV parses both versions as CSV and produces row/cell-level diffs. +func semanticDiffCSV(path string, base, head []byte) SemanticDiffResult { + baseRows, err := csv.NewReader(bytes.NewReader(base)).ReadAll() + if err != nil { + return fallbackResult(path, base, head, "failed to parse base as CSV") + } + headRows, err := csv.NewReader(bytes.NewReader(head)).ReadAll() + if err != nil { + return fallbackResult(path, base, head, "failed to parse head as CSV") + } + + changes := compareCSV(baseRows, headRows) + if len(changes) == 0 { + return SemanticDiffResult{ + Format: DiffFormatCSV, + Diff: "no changes detected", + } + } + + return SemanticDiffResult{ + Format: DiffFormatCSV, + Diff: strings.Join(changes, "\n"), + } +} + +// semanticDiffTOML parses both versions as TOML and produces a path-based diff. +func semanticDiffTOML(path string, base, head []byte) SemanticDiffResult { + var baseVal, headVal map[string]any + if err := toml.Unmarshal(base, &baseVal); err != nil { + return fallbackResult(path, base, head, "failed to parse base as TOML") + } + if err := toml.Unmarshal(head, &headVal); err != nil { + return fallbackResult(path, base, head, "failed to parse head as TOML") + } + + changes := compareValues("", any(baseVal), any(headVal)) + if len(changes) == 0 { + return SemanticDiffResult{ + Format: DiffFormatTOML, + Diff: "no changes detected", + } + } + + return SemanticDiffResult{ + Format: DiffFormatTOML, + Diff: strings.Join(changes, "\n"), + } +} + +// compareValues recursively compares two decoded values and returns change descriptions. +// Note: JSON integers larger than 2^53 may lose precision due to float64 representation. +func compareValues(path string, base, head any) []string { + // Normalize numeric types from different decoders (JSON uses float64, YAML may use int) + base = normalizeValue(base) + head = normalizeValue(head) + + baseIsNil := base == nil + headIsNil := head == nil + + if baseIsNil && headIsNil { + return nil + } + if baseIsNil { + return []string{formatChange(path, "changed", formatValue(base), formatValue(head))} + } + if headIsNil { + return []string{formatChange(path, "changed", formatValue(base), formatValue(head))} + } + + switch b := base.(type) { + case map[string]any: + h, ok := head.(map[string]any) + if !ok { + return []string{formatChange(path, "changed type", formatValue(base), formatValue(head))} + } + return compareMaps(path, b, h) + + case []any: + h, ok := head.([]any) + if !ok { + return []string{formatChange(path, "changed type", formatValue(base), formatValue(head))} + } + return compareSlices(path, b, h) + + default: + if fmt.Sprintf("%v", base) != fmt.Sprintf("%v", head) { + return []string{formatChange(path, "changed", formatValue(base), formatValue(head))} + } + return nil + } +} + +// normalizeValue converts numeric types to float64 for consistent comparison. +func normalizeValue(v any) any { + switch n := v.(type) { + case int: + return float64(n) + case int64: + return float64(n) + case int32: + return float64(n) + case float32: + return float64(n) + case uint: + return float64(n) + case uint64: + return float64(n) + case map[any]any: + // YAML can produce map[any]any, convert to map[string]any + result := make(map[string]any, len(n)) + for k, val := range n { + result[fmt.Sprintf("%v", k)] = val + } + return result + default: + return v + } +} + +// compareMaps compares two maps and returns change descriptions. +func compareMaps(path string, base, head map[string]any) []string { + var changes []string + + // Collect all keys from both maps + allKeys := make(map[string]bool) + for k := range base { + allKeys[k] = true + } + for k := range head { + allKeys[k] = true + } + + // Sort keys for deterministic output + sortedKeys := make([]string, 0, len(allKeys)) + for k := range allKeys { + sortedKeys = append(sortedKeys, k) + } + sort.Strings(sortedKeys) + + for _, key := range sortedKeys { + childPath := joinPath(path, key) + baseVal, inBase := base[key] + headVal, inHead := head[key] + + switch { + case inBase && !inHead: + changes = append(changes, formatChange(childPath, "removed", formatValue(baseVal), "")) + case !inBase && inHead: + changes = append(changes, formatChange(childPath, "added", "", formatValue(headVal))) + default: + changes = append(changes, compareValues(childPath, baseVal, headVal)...) + } + } + + return changes +} + +// compareSlices compares two slices and returns change descriptions. +func compareSlices(path string, base, head []any) []string { + var changes []string + + maxLen := len(base) + if len(head) > maxLen { + maxLen = len(head) + } + + for i := range maxLen { + childPath := fmt.Sprintf("%s[%d]", path, i) + switch { + case i >= len(base): + changes = append(changes, formatChange(childPath, "added", "", formatValue(head[i]))) + case i >= len(head): + changes = append(changes, formatChange(childPath, "removed", formatValue(base[i]), "")) + default: + changes = append(changes, compareValues(childPath, base[i], head[i])...) + } + } + + return changes +} + +// compareCSV compares CSV data with header awareness. +func compareCSV(base, head [][]string) []string { + var changes []string + + // Use headers from base if available + var headers []string + if len(base) > 0 { + headers = base[0] + } else if len(head) > 0 { + headers = head[0] + } + + // Check if headers changed + if len(base) > 0 && len(head) > 0 { + baseHeaders := base[0] + headHeaders := head[0] + if !slicesEqual(baseHeaders, headHeaders) { + changes = append(changes, fmt.Sprintf("headers changed: %v → %v", baseHeaders, headHeaders)) + // If headers changed, fall back to row-level comparison + headers = nil + } + } + + // Compare data rows (skip header row) + baseStart, headStart := 1, 1 + if len(base) == 0 { + baseStart = 0 + } + if len(head) == 0 { + headStart = 0 + } + + baseData := safeSlice(base, baseStart) + headData := safeSlice(head, headStart) + + maxRows := len(baseData) + if len(headData) > maxRows { + maxRows = len(headData) + } + + for i := range maxRows { + rowLabel := fmt.Sprintf("row %d", i+1) + switch { + case i >= len(baseData): + changes = append(changes, fmt.Sprintf("%s: added %v", rowLabel, headData[i])) + case i >= len(headData): + changes = append(changes, fmt.Sprintf("%s: removed %v", rowLabel, baseData[i])) + default: + rowChanges := compareCSVRow(rowLabel, headers, baseData[i], headData[i]) + changes = append(changes, rowChanges...) + } + } + + return changes +} + +// compareCSVRow compares individual CSV rows cell by cell. +func compareCSVRow(rowLabel string, headers, base, head []string) []string { + var changes []string + + maxCols := len(base) + if len(head) > maxCols { + maxCols = len(head) + } + + for i := range maxCols { + var colLabel string + if headers != nil && i < len(headers) { + colLabel = fmt.Sprintf("%s.%s", rowLabel, headers[i]) + } else { + colLabel = fmt.Sprintf("%s[%d]", rowLabel, i) + } + + var baseVal, headVal string + if i < len(base) { + baseVal = base[i] + } + if i < len(head) { + headVal = head[i] + } + + if baseVal != headVal { + changes = append(changes, formatChange(colLabel, "changed", quote(baseVal), quote(headVal))) + } + } + + return changes +} + +// formatChange formats a single change entry. +func formatChange(path, changeType, oldVal, newVal string) string { + switch changeType { + case "added": + return fmt.Sprintf("%s: added %s", path, newVal) + case "removed": + return fmt.Sprintf("%s: removed (was %s)", path, oldVal) + case "changed", "changed type": + return fmt.Sprintf("%s: %s → %s", path, oldVal, newVal) + default: + return fmt.Sprintf("%s: %s", path, changeType) + } +} + +// formatValue formats a value for display in a diff. +func formatValue(v any) string { + switch val := v.(type) { + case string: + return quote(val) + case nil: + return "null" + case map[string]any, []any: + b, err := json.Marshal(val) + if err != nil { + return fmt.Sprintf("%v", val) + } + return string(b) + case float64: + if val == float64(int64(val)) { + return fmt.Sprintf("%d", int64(val)) + } + return fmt.Sprintf("%g", val) + default: + return fmt.Sprintf("%v", val) + } +} + +// quote wraps a string in double quotes. +func quote(s string) string { + return fmt.Sprintf("%q", s) +} + +// joinPath creates a dotted path, handling the root case. +func joinPath(parent, child string) string { + if parent == "" { + return child + } + return parent + "." + child +} + +// unifiedDiff produces a simple unified diff between two byte slices. +func unifiedDiff(path string, base, head []byte) string { + baseLines := splitLines(string(base)) + headLines := splitLines(string(head)) + + var buf strings.Builder + buf.WriteString(fmt.Sprintf("--- a/%s\n", path)) + buf.WriteString(fmt.Sprintf("+++ b/%s\n", path)) + + // Simple line-by-line comparison (not a full Myers diff, but sufficient for context) + maxLines := len(baseLines) + if len(headLines) > maxLines { + maxLines = len(headLines) + } + + for i := range maxLines { + switch { + case i >= len(baseLines): + buf.WriteString(fmt.Sprintf("+%s\n", headLines[i])) + case i >= len(headLines): + buf.WriteString(fmt.Sprintf("-%s\n", baseLines[i])) + case baseLines[i] != headLines[i]: + buf.WriteString(fmt.Sprintf("-%s\n", baseLines[i])) + buf.WriteString(fmt.Sprintf("+%s\n", headLines[i])) + } + } + + return buf.String() +} + +// splitLines splits text into lines, handling various line endings. +func splitLines(s string) []string { + if s == "" { + return nil + } + s = strings.ReplaceAll(s, "\r\n", "\n") + lines := strings.Split(s, "\n") + // Remove trailing empty line from final newline + if len(lines) > 0 && lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + return lines +} + +// slicesEqual checks if two string slices are equal. +func slicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +// safeSlice returns a sub-slice starting at index, or empty if index is out of bounds. +func safeSlice(s [][]string, start int) [][]string { + if start >= len(s) { + return nil + } + return s[start:] +} + +// fallbackResult returns a unified diff with a message explaining why semantic diff failed. +func fallbackResult(path string, base, head []byte, message string) SemanticDiffResult { + return SemanticDiffResult{ + Format: DiffFormatFallback, + Diff: unifiedDiff(path, base, head), + Message: message + ", using unified diff", + } +} + +// DetectDiffFormat returns the DiffFormat for a file path based on extension. +func DetectDiffFormat(path string) DiffFormat { + ext := strings.ToLower(filepath.Ext(path)) + switch ext { + case ".json": + return DiffFormatJSON + case ".yaml", ".yml": + return DiffFormatYAML + case ".csv": + return DiffFormatCSV + case ".toml": + return DiffFormatTOML + default: + return DiffFormatUnified + } +} diff --git a/pkg/github/semantic_diff_test.go b/pkg/github/semantic_diff_test.go new file mode 100644 index 000000000..147547822 --- /dev/null +++ b/pkg/github/semantic_diff_test.go @@ -0,0 +1,460 @@ +package github + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSemanticDiffJSON(t *testing.T) { + tests := []struct { + name string + path string + base string + head string + expectedFormat DiffFormat + expectedDiff string + notContains string + }{ + { + name: "no changes", + path: "config.json", + base: `{"key": "value"}`, + head: `{"key": "value"}`, + expectedFormat: DiffFormatJSON, + expectedDiff: "no changes detected", + }, + { + name: "simple value change", + path: "config.json", + base: `{"theme": "light"}`, + head: `{"theme": "dark"}`, + expectedFormat: DiffFormatJSON, + expectedDiff: `theme: "light" → "dark"`, + }, + { + name: "added key", + path: "config.json", + base: `{"a": 1}`, + head: `{"a": 1, "b": 2}`, + expectedFormat: DiffFormatJSON, + expectedDiff: "b: added 2", + }, + { + name: "removed key", + path: "config.json", + base: `{"a": 1, "b": 2}`, + head: `{"a": 1}`, + expectedFormat: DiffFormatJSON, + expectedDiff: "b: removed (was 2)", + }, + { + name: "nested object change", + path: "config.json", + base: `{"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bob"}]}`, + head: `{"users": [{"id": 1, "name": "Alice"}, {"id": 2, "name": "Bobby"}]}`, + expectedFormat: DiffFormatJSON, + expectedDiff: `users[1].name: "Bob" → "Bobby"`, + }, + { + name: "reformatting only - no semantic change", + path: "config.json", + base: `{"key":"value","number":42}`, + head: "{\n \"key\": \"value\",\n \"number\": 42\n}", + expectedFormat: DiffFormatJSON, + expectedDiff: "no changes detected", + }, + { + name: "array element added", + path: "data.json", + base: `[1, 2, 3]`, + head: `[1, 2, 3, 4]`, + expectedFormat: DiffFormatJSON, + expectedDiff: "[3]: added 4", + }, + { + name: "array element removed", + path: "data.json", + base: `[1, 2, 3]`, + head: `[1, 2]`, + expectedFormat: DiffFormatJSON, + expectedDiff: "[2]: removed (was 3)", + }, + { + name: "type change", + path: "config.json", + base: `{"val": "string"}`, + head: `{"val": 123}`, + expectedFormat: DiffFormatJSON, + expectedDiff: `val: "string" → 123`, + }, + { + name: "null values", + path: "config.json", + base: `{"val": null}`, + head: `{"val": "something"}`, + expectedFormat: DiffFormatJSON, + expectedDiff: `val: null → "something"`, + }, + { + name: "boolean change", + path: "config.json", + base: `{"enabled": true}`, + head: `{"enabled": false}`, + expectedFormat: DiffFormatJSON, + expectedDiff: `enabled: true → false`, + }, + { + name: "invalid base JSON falls back", + path: "config.json", + base: `not json`, + head: `{"key": "value"}`, + expectedFormat: DiffFormatFallback, + }, + { + name: "invalid head JSON falls back", + path: "config.json", + base: `{"key": "value"}`, + head: `not json`, + expectedFormat: DiffFormatFallback, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := SemanticDiff(tc.path, []byte(tc.base), []byte(tc.head)) + assert.Equal(t, tc.expectedFormat, result.Format) + if tc.expectedDiff != "" { + assert.Contains(t, result.Diff, tc.expectedDiff) + } + if tc.notContains != "" { + assert.NotContains(t, result.Diff, tc.notContains) + } + }) + } +} + +func TestSemanticDiffYAML(t *testing.T) { + tests := []struct { + name string + path string + base string + head string + expectedFormat DiffFormat + expectedDiff string + }{ + { + name: "no changes", + path: "config.yaml", + base: "key: value\n", + head: "key: value\n", + expectedFormat: DiffFormatYAML, + expectedDiff: "no changes detected", + }, + { + name: "simple value change", + path: "config.yml", + base: "theme: light\n", + head: "theme: dark\n", + expectedFormat: DiffFormatYAML, + expectedDiff: `theme: "light" → "dark"`, + }, + { + name: "nested key change", + path: "config.yaml", + base: "database:\n host: localhost\n port: 5432\n", + head: "database:\n host: production.db\n port: 5432\n", + expectedFormat: DiffFormatYAML, + expectedDiff: `database.host: "localhost" → "production.db"`, + }, + { + name: "added key", + path: "config.yaml", + base: "a: 1\n", + head: "a: 1\nb: 2\n", + expectedFormat: DiffFormatYAML, + expectedDiff: "b: added 2", + }, + { + name: "invalid YAML falls back", + path: "config.yaml", + base: ":\n bad:\nyaml", + head: "key: value\n", + expectedFormat: DiffFormatFallback, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := SemanticDiff(tc.path, []byte(tc.base), []byte(tc.head)) + assert.Equal(t, tc.expectedFormat, result.Format) + if tc.expectedDiff != "" { + assert.Contains(t, result.Diff, tc.expectedDiff) + } + }) + } +} + +func TestSemanticDiffCSV(t *testing.T) { + tests := []struct { + name string + base string + head string + expectedFormat DiffFormat + expectedDiff string + }{ + { + name: "no changes", + base: "name,age\nAlice,30\nBob,25\n", + head: "name,age\nAlice,30\nBob,25\n", + expectedFormat: DiffFormatCSV, + expectedDiff: "no changes detected", + }, + { + name: "cell value change", + base: "name,status\nAlice,active\nBob,pending\n", + head: "name,status\nAlice,active\nBob,shipped\n", + expectedFormat: DiffFormatCSV, + expectedDiff: `row 2.status: "pending" → "shipped"`, + }, + { + name: "row added", + base: "name,age\nAlice,30\n", + head: "name,age\nAlice,30\nBob,25\n", + expectedFormat: DiffFormatCSV, + expectedDiff: "row 2: added", + }, + { + name: "row removed", + base: "name,age\nAlice,30\nBob,25\n", + head: "name,age\nAlice,30\n", + expectedFormat: DiffFormatCSV, + expectedDiff: "row 2: removed", + }, + { + name: "header change", + base: "name,age\nAlice,30\n", + head: "name,email\nAlice,alice@example.com\n", + expectedFormat: DiffFormatCSV, + expectedDiff: "headers changed", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := SemanticDiff("data.csv", []byte(tc.base), []byte(tc.head)) + assert.Equal(t, tc.expectedFormat, result.Format) + if tc.expectedDiff != "" { + assert.Contains(t, result.Diff, tc.expectedDiff) + } + }) + } +} + +func TestSemanticDiffTOML(t *testing.T) { + tests := []struct { + name string + base string + head string + expectedFormat DiffFormat + expectedDiff string + }{ + { + name: "no changes", + base: "key = \"value\"\n", + head: "key = \"value\"\n", + expectedFormat: DiffFormatTOML, + expectedDiff: "no changes detected", + }, + { + name: "value change", + base: "title = \"old\"\n", + head: "title = \"new\"\n", + expectedFormat: DiffFormatTOML, + expectedDiff: `title: "old" → "new"`, + }, + { + name: "nested table change", + base: "[database]\nhost = \"localhost\"\nport = 5432\n", + head: "[database]\nhost = \"production.db\"\nport = 5432\n", + expectedFormat: DiffFormatTOML, + expectedDiff: `database.host: "localhost" → "production.db"`, + }, + { + name: "invalid TOML falls back", + base: "not valid toml [[[", + head: "key = \"value\"\n", + expectedFormat: DiffFormatFallback, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := SemanticDiff("config.toml", []byte(tc.base), []byte(tc.head)) + assert.Equal(t, tc.expectedFormat, result.Format) + if tc.expectedDiff != "" { + assert.Contains(t, result.Diff, tc.expectedDiff) + } + }) + } +} + +func TestSemanticDiffUnifiedFallback(t *testing.T) { + tests := []struct { + name string + path string + base string + head string + expectedDiff string + }{ + { + name: "unsupported extension uses unified diff", + path: "main.go", + base: "func main() {\n}\n", + head: "func main() {\n\tfmt.Println(\"hello\")\n}\n", + expectedDiff: "--- a/main.go", + }, + { + name: "no extension uses unified diff", + path: "Makefile", + base: "all:\n\techo hello\n", + head: "all:\n\techo world\n", + expectedDiff: "--- a/Makefile", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + result := SemanticDiff(tc.path, []byte(tc.base), []byte(tc.head)) + assert.Equal(t, DiffFormatUnified, result.Format) + assert.Contains(t, result.Diff, tc.expectedDiff) + }) + } +} + +func TestSemanticDiffFileSizeLimit(t *testing.T) { + path := "config.json" + // Create data larger than MaxSemanticDiffFileSize + large := strings.Repeat("x", MaxSemanticDiffFileSize+1) + + t.Run("large base file", func(t *testing.T) { + result := SemanticDiff(path, []byte(large), []byte(`{"key":"value"}`)) + assert.Equal(t, DiffFormatFallback, result.Format) + assert.Contains(t, result.Message, "exceeds maximum size") + }) + + t.Run("large head file", func(t *testing.T) { + result := SemanticDiff(path, []byte(`{"key":"value"}`), []byte(large)) + assert.Equal(t, DiffFormatFallback, result.Format) + assert.Contains(t, result.Message, "exceeds maximum size") + }) +} + +func TestSemanticDiffNewAndDeletedFiles(t *testing.T) { + t.Run("new JSON file", func(t *testing.T) { + result := SemanticDiff("config.json", nil, []byte(`{"key":"value"}`)) + assert.Equal(t, DiffFormatJSON, result.Format) + assert.Equal(t, "file added", result.Diff) + }) + + t.Run("deleted JSON file", func(t *testing.T) { + result := SemanticDiff("config.json", []byte(`{"key":"value"}`), nil) + assert.Equal(t, DiffFormatJSON, result.Format) + assert.Equal(t, "file deleted", result.Diff) + }) + + t.Run("new YAML file", func(t *testing.T) { + result := SemanticDiff("config.yaml", nil, []byte("key: value\n")) + assert.Equal(t, DiffFormatYAML, result.Format) + assert.Equal(t, "file added", result.Diff) + }) + + t.Run("deleted Go file", func(t *testing.T) { + result := SemanticDiff("main.go", []byte("package main\n"), nil) + assert.Equal(t, DiffFormatUnified, result.Format) + assert.Equal(t, "file deleted", result.Diff) + }) + + t.Run("both nil", func(t *testing.T) { + result := SemanticDiff("config.json", nil, nil) + assert.Equal(t, "no changes detected", result.Diff) + }) +} + +func TestDetectDiffFormat(t *testing.T) { + tests := []struct { + path string + expected DiffFormat + }{ + {"config.json", DiffFormatJSON}, + {"config.JSON", DiffFormatJSON}, + {"config.yaml", DiffFormatYAML}, + {"config.yml", DiffFormatYAML}, + {"data.csv", DiffFormatCSV}, + {"config.toml", DiffFormatTOML}, + {"main.go", DiffFormatUnified}, + {"README.md", DiffFormatUnified}, + {"Makefile", DiffFormatUnified}, + } + + for _, tc := range tests { + t.Run(tc.path, func(t *testing.T) { + assert.Equal(t, tc.expected, DetectDiffFormat(tc.path)) + }) + } +} + +func TestCompareValuesDeepNesting(t *testing.T) { + base := `{ + "level1": { + "level2": { + "level3": { + "value": "old" + } + } + } + }` + head := `{ + "level1": { + "level2": { + "level3": { + "value": "new" + } + } + } + }` + + result := SemanticDiff("config.json", []byte(base), []byte(head)) + require.Equal(t, DiffFormatJSON, result.Format) + assert.Contains(t, result.Diff, `level1.level2.level3.value: "old" → "new"`) +} + +func TestSemanticDiffMultipleChanges(t *testing.T) { + base := `{ + "name": "my-app", + "version": "1.0.0", + "dependencies": { + "lodash": "4.17.20", + "express": "4.17.1" + } + }` + head := `{ + "name": "my-app", + "version": "1.1.0", + "dependencies": { + "lodash": "4.17.21", + "express": "4.17.1", + "axios": "0.21.1" + } + }` + + result := SemanticDiff("package.json", []byte(base), []byte(head)) + require.Equal(t, DiffFormatJSON, result.Format) + assert.Contains(t, result.Diff, `version: "1.0.0" → "1.1.0"`) + assert.Contains(t, result.Diff, `dependencies.lodash: "4.17.20" → "4.17.21"`) + assert.Contains(t, result.Diff, `dependencies.axios: added "0.21.1"`) + assert.NotContains(t, result.Diff, "express") + assert.NotContains(t, result.Diff, "name") +} diff --git a/pkg/github/tools.go b/pkg/github/tools.go index 676976140..d8165b65c 100644 --- a/pkg/github/tools.go +++ b/pkg/github/tools.go @@ -164,6 +164,7 @@ func AllTools(t translations.TranslationHelperFunc) []inventory.ServerTool { // Repository tools SearchRepositories(t), GetFileContents(t), + CompareFileContents(t), ListCommits(t), SearchCode(t), GetCommit(t), diff --git a/third-party-licenses.darwin.md b/third-party-licenses.darwin.md index 6028ecfda..0842fe85f 100644 --- a/third-party-licenses.darwin.md +++ b/third-party-licenses.darwin.md @@ -49,5 +49,6 @@ The following packages are included for the amd64, arm64 architectures. - [golang.org/x/sys/unix](https://pkg.go.dev/golang.org/x/sys/unix) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) + - [gopkg.in/yaml.v3](https://pkg.go.dev/gopkg.in/yaml.v3) ([MIT](https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party-licenses.linux.md b/third-party-licenses.linux.md index 3d7b8b3fe..2dd75ef7d 100644 --- a/third-party-licenses.linux.md +++ b/third-party-licenses.linux.md @@ -49,5 +49,6 @@ The following packages are included for the 386, amd64, arm64 architectures. - [golang.org/x/sys/unix](https://pkg.go.dev/golang.org/x/sys/unix) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) + - [gopkg.in/yaml.v3](https://pkg.go.dev/gopkg.in/yaml.v3) ([MIT](https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party-licenses.windows.md b/third-party-licenses.windows.md index 48bad011e..0f2dc931c 100644 --- a/third-party-licenses.windows.md +++ b/third-party-licenses.windows.md @@ -50,5 +50,6 @@ The following packages are included for the 386, amd64, arm64 architectures. - [golang.org/x/sys/windows](https://pkg.go.dev/golang.org/x/sys/windows) ([BSD-3-Clause](https://cs.opensource.google/go/x/sys/+/v0.31.0:LICENSE)) - [golang.org/x/text](https://pkg.go.dev/golang.org/x/text) ([BSD-3-Clause](https://cs.opensource.google/go/x/text/+/v0.28.0:LICENSE)) - [gopkg.in/yaml.v2](https://pkg.go.dev/gopkg.in/yaml.v2) ([Apache-2.0](https://github.com/go-yaml/yaml/blob/v2.4.0/LICENSE)) + - [gopkg.in/yaml.v3](https://pkg.go.dev/gopkg.in/yaml.v3) ([MIT](https://github.com/go-yaml/yaml/blob/v3.0.1/LICENSE)) [github/github-mcp-server]: https://github.com/github/github-mcp-server diff --git a/third-party/gopkg.in/yaml.v3/LICENSE b/third-party/gopkg.in/yaml.v3/LICENSE new file mode 100644 index 000000000..2683e4bb1 --- /dev/null +++ b/third-party/gopkg.in/yaml.v3/LICENSE @@ -0,0 +1,50 @@ + +This project is covered by two different licenses: MIT and Apache. + +#### MIT License #### + +The following files were ported to Go from C files of libyaml, and thus +are still covered by their original MIT license, with the additional +copyright staring in 2011 when the project was ported over: + + apic.go emitterc.go parserc.go readerc.go scannerc.go + writerc.go yamlh.go yamlprivateh.go + +Copyright (c) 2006-2010 Kirill Simonov +Copyright (c) 2006-2011 Kirill Simonov + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +### Apache License ### + +All the remaining project files are covered by the Apache license: + +Copyright (c) 2011-2019 Canonical Ltd + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/third-party/gopkg.in/yaml.v3/NOTICE b/third-party/gopkg.in/yaml.v3/NOTICE new file mode 100644 index 000000000..866d74a7a --- /dev/null +++ b/third-party/gopkg.in/yaml.v3/NOTICE @@ -0,0 +1,13 @@ +Copyright 2011-2016 Canonical Ltd. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License.