diff --git a/.gitignore b/.gitignore index f721008..dcb0840 100644 --- a/.gitignore +++ b/.gitignore @@ -36,4 +36,5 @@ scratch # binary gh-migration-validator -.exports/ \ No newline at end of file +.exports/ +migration-archives/ \ No newline at end of file diff --git a/README.md b/README.md index a07c85d..9d5ff90 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,10 @@ A GitHub CLI extension for validating GitHub organization and repository migrati The GitHub Migration Validator helps ensure that your migration from one GitHub organization/repository to another has been completed successfully. It compares various repository metrics (issues, pull requests, tags, releases, commits) between source and target repositories and provides a detailed validation report. +## Documentation + +- **[Migration Archive Support](docs/migration-archive.md)** - Comprehensive guide for enhanced validation using GitHub migration archives + ## Install ```bash @@ -103,6 +107,10 @@ gh migration-validator export \ --output ".exports/my-export.json" ``` +### Export with Migration Archive + +The tool can also download and analyze migration archives to include additional validation metrics. See the [Migration Archive Documentation](docs/migration-archive.md) for detailed information. + ### Export Options - `--source-organization` (required): Source organization name @@ -111,6 +119,11 @@ gh migration-validator export \ - `--source-hostname` (optional): GitHub Enterprise Server URL - `--format` (optional): Export format - `json` or `csv` (default: `json`) - `--output` (optional): Output file path (auto-generated if not specified) +- `--download` (optional): Download and analyze migration archive automatically +- `--download-path` (optional): Directory to download migration archives to (default: ./migration-archives) +- `--archive-path` (optional): Path to an existing extracted migration archive directory + +**Note**: `--download` and `--archive-path` are mutually exclusive. For detailed migration archive usage, see [Migration Archive Documentation](docs/migration-archive.md). ### Export Output Formats @@ -118,9 +131,9 @@ gh migration-validator export \ ```json { - "export_timestamp": "2025-10-02T14:49:08Z", + "export_timestamp": "2025-10-13T14:49:08Z", "repository_data": { - "owner": "mona-actions", + "owner": "source-org", "name": "my-repo", "issues": 42, "pull_requests": { @@ -135,10 +148,18 @@ gh migration-validator export \ "latest_commit_sha": "abc123def456", "branch_protection_rules": 4, "webhooks": 2 + }, + "migration_archive": { + "issues": 42, + "pull_requests": 30, + "protected_branches" : 1, + "releases": 3 } } ``` +When migration archive data is included, the export will contain additional `migration_archive` metrics. See [Migration Archive Documentation](docs/migration-archive.md) for details. + **CSV Format:** Contains the same data in CSV format with headers for easy analysis in spreadsheet applications. @@ -165,6 +186,18 @@ gh migration-validator validate-from-export \ --target-token "ghp_yyy" ``` +### Using Existing Archive Directory + +If you already have an extracted migration archive directory: + +```bash +gh migration-validator export \ + --source-organization "source-org" \ + --source-repo "my-repo" \ + --source-token "ghp_xxx" \ + --archive-path "path/to/extracted/migration-archive" +``` + ### Validate-from-Export Options - `--export-file` (required): Path to the exported JSON file containing source data @@ -210,6 +243,12 @@ gh migration-validator validate-from-export --export-file "path/to/export.json" This ensures you're validating against the exact state of the source repository when the migration occurred, regardless of any subsequent changes. +## Migration Archive Support + +The tool supports working with GitHub migration archives for enhanced validation capabilities. Migration archives provide three-way validation comparing Source API ↔ Archive ↔ Target API data. + +For comprehensive documentation on migration archive features, workflow, and usage examples, see [Migration Archive Documentation](docs/migration-archive.md). + ## What Gets Validated The tool compares the following metrics between source and target repositories: @@ -235,6 +274,52 @@ The tool compares the following metrics between source and target repositories: The tool provides a formatted table with colored status indicators and a summary. +Example: + +```markdown +# πŸ”„ Source vs Target Validation + +Metric | Status | Source Value | Target Value | Difference +Issues (expected +1 for migration log) | ⚠️ WARN | 2 (expected target: 3) | 7 | Extra: 4 +Pull Requests (Total) | βœ… PASS | 29 | 29 | Perfect match +Pull Requests (Open) | βœ… PASS | 0 | 0 | Perfect match +Pull Requests (Merged) | βœ… PASS | 27 | 27 | Perfect match +Tags | βœ… PASS | 25 | 25 | Perfect match +Releases | βœ… PASS | 25 | 25 | Perfect match +Commits | βœ… PASS | 64 | 64 | Perfect match +Branch Protection Rules | βœ… PASS | 1 | 1 | Perfect match +Webhooks | βœ… PASS | 0 | 0 | Perfect match +Latest Commit SHA | βœ… PASS | d11552345ad4ffea894b59d9a4145a5119d77dba | d11552345ad4ffea894b59d9a4145a5119d77dba | N/A + + + +# πŸ“¦ Migration Archive vs Source Validation + +Metric | Status | Source API Value | Archive Value | Difference +Archive vs Source Issues | ❌ FAIL | 2 | 6 | Missing: 4 +Archive vs Source Pull Requests | βœ… PASS | 29 | 29 | Perfect match +Archive vs Source Protected Branches | βœ… PASS | 1 | 1 | Perfect match +Archive vs Source Releases | βœ… PASS | 25 | 25 | Perfect match + + + +# 🎯 Migration Archive vs Target Validation + +Metric | Status | Archive Value | Target Value | Difference +Archive vs Target Issues (expected +1 for migration log) | βœ… PASS | 6 (expected target: 7) | 7 | Perfect match +Archive vs Target Pull Requests | βœ… PASS | 29 | 29 | Perfect match +Archive vs Target Protected Branches | βœ… PASS | 1 | 1 | Perfect match +Archive vs Target Releases | βœ… PASS | 25 | 25 | Perfect match + + +πŸ“Š Passed: 16 +πŸ“Š Failed: 1 +πŸ“Š Warnings: 1 + + +ERROR ❌ Migration validation FAILED - Some data is missing in target +``` + ### Markdown Output Use the `--markdown-table` flag to generate copy-paste ready markdown for documentation. diff --git a/cmd/export.go b/cmd/export.go index 4785c94..9937f0f 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -6,8 +6,10 @@ package cmd import ( "fmt" "mona-actions/gh-migration-validator/internal/export" + "mona-actions/gh-migration-validator/internal/migrationarchive" "mona-actions/gh-migration-validator/internal/validator" "os" + "strings" "time" "github.com/spf13/cobra" @@ -28,7 +30,17 @@ This command fetches and exports repository metadata including: - Commits count - Latest commit hash -The data can be exported in JSON or CSV format with a timestamp.`, +The data can be exported in JSON or CSV format with a timestamp. + +Optionally, you can include migration archive data in the export by either: +- Using --download to automatically download and extract a migration archive +- Using --archive-path to specify an existing extracted migration archive directory + +When using --download, you can optionally specify --download-path to choose where +the archive files are saved (defaults to ./migration-archives). + +The tool will automatically search for migrations containing the specified repository +and allow you to select from multiple matches if available when downloading.`, Run: func(cmd *cobra.Command, args []string) { // Get parameters from flags sourceOrganization := cmd.Flag("source-organization").Value.String() @@ -37,6 +49,9 @@ The data can be exported in JSON or CSV format with a timestamp.`, sourceRepo := cmd.Flag("source-repo").Value.String() outputFormat := cmd.Flag("format").Value.String() outputFile := cmd.Flag("output").Value.String() + download, _ := cmd.Flags().GetBool("download") + downloadPath := cmd.Flag("download-path").Value.String() + archivePath := cmd.Flag("archive-path").Value.String() // Only set ENV variables if flag values are provided (not empty) if sourceOrganization != "" { @@ -67,14 +82,40 @@ The data can be exported in JSON or CSV format with a timestamp.`, os.Exit(1) } + // Validate that --download and --archive-path are mutually exclusive + if download && archivePath != "" { + fmt.Printf("Error: --download and --archive-path flags are mutually exclusive. Please use only one.\n") + os.Exit(1) + } + initializeAPI() - // Create validator and export source data + // Create validator migrationValidator := validator.New(ghAPI) - // Export the source repository data + // Handle migration archive (either download or use existing path) + var archiveDir string + if download { + fmt.Println("Searching for migration archives...") + extractedPath, err := migrationarchive.DownloadAndExtractArchive(ghAPI, sourceOrganization, sourceRepo, downloadPath) + if err != nil { + fmt.Printf("Migration archive download failed: %v\n", err) + os.Exit(1) + } + archiveDir = extractedPath + } else if archivePath != "" { + // Validate that the specified archive path exists and is a directory + if err := validateArchivePath(archivePath); err != nil { + fmt.Printf("Archive path validation failed: %v\n", err) + os.Exit(1) + } + archiveDir = archivePath + fmt.Printf("Using existing migration archive at: %s\n", archivePath) + } + + // Export the source repository data (with optional migration archive analysis) timestamp := time.Now() - err := export.ExportSourceData(migrationValidator, sourceOrganization, sourceRepo, outputFormat, outputFile, timestamp) + err := export.ExportSourceData(migrationValidator, sourceOrganization, sourceRepo, outputFormat, outputFile, timestamp, archiveDir) if err != nil { fmt.Printf("Export failed: %v\n", err) os.Exit(1) @@ -100,6 +141,12 @@ func init() { exportCmd.Flags().StringP("format", "f", "json", "Output format: json or csv") exportCmd.Flags().StringP("output", "o", "", "Output file path (if not provided, will use default naming)") + + exportCmd.Flags().BoolP("download", "d", false, "Download and extract migration archive for the specified repository") + + exportCmd.Flags().StringP("download-path", "", "", "Directory to download migration archives to (default: ./migration-archives)") + + exportCmd.Flags().StringP("archive-path", "p", "", "Path to an existing extracted migration archive directory (alternative to --download)") } // checkExportVars validates the configuration for export command @@ -118,3 +165,59 @@ func checkExportVars() error { return nil } + +// validateArchivePath validates that the provided archive path exists and contains expected migration archive files +func validateArchivePath(archivePath string) error { + // Check if the path exists + info, err := os.Stat(archivePath) + if err != nil { + if os.IsNotExist(err) { + return fmt.Errorf("archive path does not exist: %s", archivePath) + } + return fmt.Errorf("error accessing archive path: %v", err) + } + + // Check if it's a directory + if !info.IsDir() { + return fmt.Errorf("archive path must be a directory: %s", archivePath) + } + + // Check if directory contains expected migration archive files + entries, err := os.ReadDir(archivePath) + if err != nil { + return fmt.Errorf("error reading archive directory: %v", err) + } + + // Look for at least one expected migration archive file pattern + // Using a map for O(1) pattern lookups instead of nested O(n*m) loops + expectedPatterns := map[string]bool{ + "issues_": true, + "pull_requests_": true, + "protected_branches_": true, + "releases_": true, + "repositories_": true, + } + foundExpectedFile := false + + for _, entry := range entries { + if entry.IsDir() || foundExpectedFile { + continue + } + fileName := entry.Name() + if strings.HasSuffix(fileName, ".json") { + // Find the underscore position to extract the potential prefix + if underscoreIdx := strings.Index(fileName, "_"); underscoreIdx > 0 { + prefix := fileName[:underscoreIdx+1] // Include the underscore + if expectedPatterns[prefix] { + foundExpectedFile = true + } + } + } + } + + if !foundExpectedFile { + return fmt.Errorf("directory does not appear to contain migration archive files (expected files like issues_*.json, pull_requests_*.json, etc.): %s", archivePath) + } + + return nil +} diff --git a/cmd/export_test.go b/cmd/export_test.go new file mode 100644 index 0000000..df150ad --- /dev/null +++ b/cmd/export_test.go @@ -0,0 +1,271 @@ +package cmd + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/spf13/cobra" +) + +func TestExportFlagValidation(t *testing.T) { + tests := []struct { + name string + args []string + expectedError bool + expectedErrMsg string + }{ + { + name: "both download and archive-path flags should fail", + args: []string{"--source-organization", "test-org", "--source-repo", "test-repo", "--download", "--archive-path", "/some/path"}, + expectedError: true, + expectedErrMsg: "--download and --archive-path flags are mutually exclusive. Please use only one.", + }, + { + name: "download flag alone should be valid", + args: []string{"--source-organization", "test-org", "--source-repo", "test-repo", "--download"}, + expectedError: false, + }, + { + name: "archive-path flag alone should be valid", + args: []string{"--source-organization", "test-org", "--source-repo", "test-repo", "--archive-path", "/some/path"}, + expectedError: false, + }, + { + name: "download-path with download should be valid", + args: []string{"--source-organization", "test-org", "--source-repo", "test-repo", "--download", "--download-path", "/custom/path"}, + expectedError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Set required environment variable to prevent validation errors + os.Setenv("GHMV_SOURCE_TOKEN", "fake-token") + defer os.Unsetenv("GHMV_SOURCE_TOKEN") + + // Create a new command instance for each test + cmd := &cobra.Command{ + Use: "export", + RunE: func(cmd *cobra.Command, args []string) error { + // Get flag values + download, _ := cmd.Flags().GetBool("download") + downloadPath := cmd.Flag("download-path").Value.String() + archivePath := cmd.Flag("archive-path").Value.String() + + // Test the validation logic directly + if download && archivePath != "" { + return &ValidationError{Message: "--download and --archive-path flags are mutually exclusive. Please use only one."} + } + + // For testing purposes, we'll just validate the flags + // In a real test, we'd mock the API and test the full flow + _ = downloadPath // Use the variable to avoid compiler warning + + return nil + }, + } + + // Add all the flags + cmd.Flags().StringP("source-organization", "s", "", "Source Organization") + cmd.Flags().StringP("source-repo", "", "", "Source repository") + cmd.Flags().BoolP("download", "d", false, "Download and extract migration archive") + cmd.Flags().StringP("download-path", "", "", "Directory to download migration archives") + cmd.Flags().StringP("archive-path", "p", "", "Path to existing extracted archive") + + // Mark required flags + cmd.MarkFlagRequired("source-organization") + cmd.MarkFlagRequired("source-repo") + + // Capture output + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) + + // Set arguments and execute + cmd.SetArgs(tt.args) + err := cmd.Execute() + + if tt.expectedError { + if err == nil { + t.Errorf("Expected error but got none") + } else if validationErr, ok := err.(*ValidationError); ok { + if validationErr.Message != tt.expectedErrMsg { + t.Errorf("Expected error message '%s', got '%s'", tt.expectedErrMsg, validationErr.Message) + } + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + }) + } +} + +func TestExportFlagValues(t *testing.T) { + tests := []struct { + name string + args []string + expectedDownload bool + expectedDownloadPath string + expectedArchivePath string + }{ + { + name: "download flag sets boolean correctly", + args: []string{"--source-organization", "test-org", "--source-repo", "test-repo", "--download"}, + expectedDownload: true, + expectedDownloadPath: "", + expectedArchivePath: "", + }, + { + name: "download-path flag sets string correctly", + args: []string{"--source-organization", "test-org", "--source-repo", "test-repo", "--download", "--download-path", "/custom/path"}, + expectedDownload: true, + expectedDownloadPath: "/custom/path", + expectedArchivePath: "", + }, + { + name: "archive-path flag sets string correctly", + args: []string{"--source-organization", "test-org", "--source-repo", "test-repo", "--archive-path", "/existing/path"}, + expectedDownload: false, + expectedDownloadPath: "", + expectedArchivePath: "/existing/path", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &cobra.Command{ + Use: "export", + RunE: func(cmd *cobra.Command, args []string) error { + // Test flag values + download, _ := cmd.Flags().GetBool("download") + downloadPath := cmd.Flag("download-path").Value.String() + archivePath := cmd.Flag("archive-path").Value.String() + + if download != tt.expectedDownload { + t.Errorf("Expected download %v, got %v", tt.expectedDownload, download) + } + if downloadPath != tt.expectedDownloadPath { + t.Errorf("Expected downloadPath %s, got %s", tt.expectedDownloadPath, downloadPath) + } + if archivePath != tt.expectedArchivePath { + t.Errorf("Expected archivePath %s, got %s", tt.expectedArchivePath, archivePath) + } + + return nil + }, + } + + // Add flags + cmd.Flags().StringP("source-organization", "s", "", "Source Organization") + cmd.Flags().StringP("source-repo", "", "", "Source repository") + cmd.Flags().BoolP("download", "d", false, "Download and extract migration archive") + cmd.Flags().StringP("download-path", "", "", "Directory to download migration archives") + cmd.Flags().StringP("archive-path", "p", "", "Path to existing extracted archive") + + // Set arguments and execute + cmd.SetArgs(tt.args) + err := cmd.Execute() + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + }) + } +} + +// ValidationError represents a validation error for testing +type ValidationError struct { + Message string +} + +func (e *ValidationError) Error() string { + return e.Message +} + +func TestValidateArchivePathOptimization(t *testing.T) { + // Create a temporary directory with various test files + tempDir := t.TempDir() + + testFiles := []struct { + filename string + shouldMatch bool + }{ + {"issues_000001.json", true}, + {"pull_requests_000001.json", true}, + {"protected_branches_000001.json", true}, + {"releases_000001.json", true}, + {"repositories_000001.json", true}, + {"random_file.json", false}, + {"issues.json", false}, // No underscore + {"issues_", false}, // No .json extension + {"not_expected_pattern_000001.json", false}, + {"schema.json", false}, + {"users_000001.json", false}, // Not in expected patterns + } + + // Create the test files + for _, tf := range testFiles { + filePath := filepath.Join(tempDir, tf.filename) + err := os.WriteFile(filePath, []byte("{}"), 0644) + if err != nil { + t.Fatalf("Failed to create test file %s: %v", tf.filename, err) + } + } + + // Test with directory containing expected files + err := validateArchivePath(tempDir) + if err != nil { + t.Errorf("validateArchivePath should succeed with expected files, got error: %v", err) + } + + // Test with directory containing only non-matching files + tempDir2 := t.TempDir() + nonMatchingFiles := []string{"random.json", "schema.json", "unexpected_pattern.json"} + for _, filename := range nonMatchingFiles { + filePath := filepath.Join(tempDir2, filename) + err := os.WriteFile(filePath, []byte("{}"), 0644) + if err != nil { + t.Fatalf("Failed to create test file %s: %v", filename, err) + } + } + + err = validateArchivePath(tempDir2) + if err == nil { + t.Error("validateArchivePath should fail when no expected files are found") + } +} + +func BenchmarkValidateArchivePathOptimization(b *testing.B) { + // Create a temporary directory with many files for benchmarking + tempDir := b.TempDir() + + // Create many files to test performance + patterns := []string{"issues_", "pull_requests_", "protected_branches_", "releases_", "repositories_"} + for i := 0; i < 1000; i++ { + for j, pattern := range patterns { + filename := filepath.Join(tempDir, fmt.Sprintf("%s%06d_%d.json", pattern, i, j)) + err := os.WriteFile(filename, []byte("{}"), 0644) + if err != nil { + b.Fatalf("Failed to create test file: %v", err) + } + } + // Add some non-matching files + filename := filepath.Join(tempDir, fmt.Sprintf("random_%06d.json", i)) + err := os.WriteFile(filename, []byte("{}"), 0644) + if err != nil { + b.Fatalf("Failed to create test file: %v", err) + } + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + err := validateArchivePath(tempDir) + if err != nil { + b.Fatalf("validateArchivePath failed: %v", err) + } + } +} diff --git a/cmd/validate_from_export.go b/cmd/validate_from_export.go index 53d1025..0848824 100644 --- a/cmd/validate_from_export.go +++ b/cmd/validate_from_export.go @@ -87,7 +87,10 @@ The validation compares the same metrics as the standard validate command: migrationValidator := validator.New(ghAPI) // Set source data from export instead of fetching from API - migrationValidator.SetSourceDataFromExport(&exportData.Repository) + // Copy migration archive data to repository data if it exists + repositoryData := exportData.Repository + repositoryData.MigrationArchive = exportData.MigrationArchive + migrationValidator.SetSourceDataFromExport(&repositoryData) // Perform validation against target (now returns results directly) results, err := migrationValidator.ValidateFromExport(targetOrganization, targetRepo) diff --git a/docs/migration-archive.md b/docs/migration-archive.md new file mode 100644 index 0000000..f377297 --- /dev/null +++ b/docs/migration-archive.md @@ -0,0 +1,285 @@ +# Migration Archive Support + +The GitHub Migration Validator supports working with GitHub migration archives to provide additional validation capabilities. Migration archives contain the actual data that was migrated and serve as an authoritative source for validation. + +## Overview + +Migration archives provide a comprehensive way to validate migrations by comparing three data sources: + +- **Source API**: Live data from the source repository +- **Migration Archive**: The actual data that was migrated +- **Target API**: Live data from the target repository + +This three-way comparison ensures complete validation coverage and helps identify where any discrepancies occurred during the migration process. + +## Features + +- **Automatic Download**: Automatically find and download migration archives for a repository +- **Archive Analysis**: Extract and count key entities from migration archive JSON files +- **Three-way Validation**: Compare Source API ↔ Archive ↔ Target API for comprehensive validation +- **Interactive Selection**: Choose from multiple available migration archives for a repository + +## Export with Migration Archive + +### Automatic Archive Download + +Download and analyze migration archives automatically during export: + +```bash +gh migration-validator export \ + --source-organization "source-org" \ + --source-repo "my-repo" \ + --source-token "ghp_xxx" \ + --download +``` + +### Custom Download Location + +Specify where to download migration archives: + +```bash +gh migration-validator export \ + --source-organization "source-org" \ + --source-repo "my-repo" \ + --source-token "ghp_xxx" \ + --download \ + --download-path "/path/to/custom/directory" +``` + +### Using Existing Archive Directory + +If you already have an extracted migration archive directory: + +```bash +gh migration-validator export \ + --source-organization "source-org" \ + --source-repo "my-repo" \ + --source-token "ghp_xxx" \ + --archive-path "path/to/extracted/migration-archive" +``` + +### Options + +- `--download` (optional): Download and analyze migration archive automatically +- `--download-path` (optional): Directory to download migration archives to (default: ./migration-archives) +- `--archive-path` (optional): Path to an existing extracted migration archive directory + +**Note**: `--download` and `--archive-path` are mutually exclusive. When using `--download`, you must also provide `--source-organization`. You can optionally specify `--download-path` to choose where archives are saved. + +## Migration Archive Workflow + +### 1. Find Available Migrations + +The tool queries GitHub's API to find all migrations for your organization: + +```text +βœ“ Found 3 migrations for organization: source-org +``` + +### 2. Select Repository Migration + +Choose the specific migration for your repository (filters to only show "exported" migrations): + +```text +? Select a migration for repository 'my-repo': + β–Έ Migration ID: abc123 (State: exported, Created: 2024-01-15) + Migration ID: def456 (State: exported, Created: 2024-01-10) +``` + +### 3. Download and Extract + +The tool downloads the migration archive with a descriptive filename and extracts it: + +```text +β Έ Downloading migration archive... +βœ“ Downloaded: migration-my-repo-abc123.tar.gz +β Έ Extracting migration archive... +βœ“ Archive extracted to: /tmp/migration-abc123 +``` + +### 4. Analyze Content + +Parse JSON files in the archive to count entities: + +```text +β Έ Analyzing migration archive... +βœ“ Migration archive analysis complete + β€’ Issues: 42 + β€’ Pull Requests: 30 + β€’ Releases: 3 + β€’ Organizations: 1 + β€’ Repositories: 1 + β€’ Users: 25 +``` + +### 5. Enhanced Export + +The export file includes both API data and migration archive metrics: + +```json +{ + "export_timestamp": "2025-10-13T14:49:08Z", + "repository_data": { + "owner": "source-org", + "name": "my-repo", + "issues": 42, + "pull_requests": { + "open": 5, + "closed": 10, + "merged": 15, + "total": 30 + }, + "tags": 8, + "releases": 3, + "commits": 150, + "latest_commit_sha": "abc123def456", + "branch_protection_rules": 4, + "webhooks": 2 + }, + "migration_archive": { + "issues": 42, + "pull_requests": 30, + "protected_branches" : 1, + "releases": 3 + } +} +``` + +## Validation with Migration Archives + +When validating from an export that contains migration archive data, the tool performs comprehensive three-way validation. + +### Three-way Validation Process + +1. **Source vs Archive**: Ensures the migration archive contains all expected data from the source +2. **Archive vs Target**: Validates that the target repository matches the migrated data + +### Validation Example + +```bash +gh migration-validator validate-from-export \ + --export-file ".exports/source-org_my-repo_export_20251013_144908.json" \ + --target-organization "target-org" \ + --target-repo "my-repo" \ + --target-token "ghp_yyy" +``` + +### Enhanced Validation Output + +The validation tables clearly indicate which comparison is being made: + +```text +Source vs Archive Validation: +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Metric β”‚ Source Value β”‚ Archive Valueβ”‚ Status β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Issues β”‚ 42 β”‚ 42 β”‚ βœ… PASSβ”‚ +β”‚ Pull Requests (Total) β”‚ 30 β”‚ 30 β”‚ βœ… PASSβ”‚ +β”‚ Releases β”‚ 3 β”‚ 3 β”‚ βœ… PASSβ”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜ + +Archive vs Target Validation: +β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β” +β”‚ Metric β”‚ Archive Valueβ”‚ Target Value β”‚ Status β”‚ +β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€ +β”‚ Issues β”‚ 42 β”‚ 43 β”‚ ⚠️ WARNβ”‚ +β”‚ Pull Requests (Total) β”‚ 30 β”‚ 30 β”‚ βœ… PASSβ”‚ +β”‚ Releases β”‚ 3 β”‚ 3 β”‚ βœ… PASSβ”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +``` + +## Archive Storage + +### File Naming + +Downloaded migration archives are stored with descriptive names: + +- Format: `migration-{repo-name}-{migration-id}.tar.gz` +- Default Location: `./migration-archives/` directory +- Custom Location: Configurable via `--download-path` flag +- Extraction: Automatically extracted to temporary directories for analysis + +### Examples + +**Default location (./migration-archives/):** + +```text +./migration-archives/migration-my-repo-abc123def456.tar.gz +./migration-archives/migration-webapp-789xyz123456.tar.gz +./migration-archives/migration-api-service-456def789abc.tar.gz +``` + +**Custom location (--download-path /custom/path):** + +```text +/custom/path/migration-my-repo-abc123def456.tar.gz +/custom/path/migration-webapp-789xyz123456.tar.gz +/custom/path/migration-api-service-456def789abc.tar.gz +``` + +## Archive Data Analysis + +### Supported Files + +The tool analyzes the following files in migration archives: + +- `issues_*.json` - Issue data +- `pull_requests_*.json` - Pull request data +- `releases_*.json` - Release data +- `protected_branches_*.json` - Protected branches data + +### Multi-file Support + +Migration archives may contain data across multiple numbered JSON files: + +- `issues_000001.json` +- `issues_000002.json` +- `pull_requests_000001.json` +- `pull_requests_000002.json` + +The tool automatically processes all numbered files for each entity type and aggregates the counts. + +### Analysis Process + +1. **Scan Directory**: Find all relevant JSON files in the archive +2. **Parse Files**: Read and parse each JSON file +3. **Count Entities**: Count array elements in each file +4. **Aggregate**: Sum counts across all files of the same type +5. **Report**: Display final counts for each entity type + +## Benefits + +### Comprehensive Validation + +Three-way validation provides confidence that: + +- The migration archive captured all source data correctly +- The target repository contains all migrated data +- Any discrepancies can be traced to their source + +### Point-in-time Accuracy + +Migration archives represent the exact data state at migration time, ensuring validation accuracy even if source repositories continue to change. + +### Audit Trail + +The combination of export files and migration archives provides a complete audit trail of the migration process and validation results. + +## Troubleshooting + +### Common Issues + +1. **No migrations found**: Ensure your token has the necessary permissions and the organization has completed migrations +2. **Archive download fails**: Check network connectivity and ensure the migration is in "exported" state +3. **Archive analysis errors**: Verify the archive extracted correctly and contains expected JSON files + +### Migration States + +The tool only shows migrations in "exported" state, which indicates they are ready for download. Other states include: + +### Permissions Required + +To use migration archive features, your GitHub token needs: + +- `read:org` - To list organization migrations +- `repo` - To access repository data for comparison diff --git a/internal/api/api.go b/internal/api/api.go index 5cbbde2..6d74e20 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -3,8 +3,10 @@ package api import ( "context" "fmt" + "io" "log" "net/http" + "os" "strconv" "strings" "time" @@ -517,3 +519,163 @@ func (api *GitHubAPI) GetWebhookCount(clientType ClientType, owner, name string) return activeWebhookCount, nil } + +// ListOrganizationMigrations retrieves the list of organization migrations using REST API +// Limited to the last 100 migrations +func (api *GitHubAPI) ListOrganizationMigrations(clientType ClientType, org string) ([]*github.Migration, error) { + ctx := context.Background() + + client, clientName, err := api.getRESTClient(clientType) + if err != nil { + return nil, err + } + + opts := &github.ListOptions{PerPage: 100} + var allMigrations []*github.Migration + migrationCount := 0 + maxMigrations := 100 + + for { + migrations, resp, err := client.Migrations.ListMigrations(ctx, org, opts) + if err != nil { + return nil, fmt.Errorf("failed to list %s organization migrations: %v", clientName, err) + } + + for _, migration := range migrations { + if migrationCount >= maxMigrations { + break + } + allMigrations = append(allMigrations, migration) + migrationCount++ + } + + if resp.NextPage == 0 || migrationCount >= maxMigrations { + break + } + opts.Page = resp.NextPage + } + + return allMigrations, nil +} + +// MigrationInfo holds information about a migration for display to user +type MigrationInfo struct { + ID int64 + CreatedAt string + UpdatedAt string + State string + Repositories []string +} + +// FindMigrationsByRepository finds migrations that contain the specified repository +func (api *GitHubAPI) FindMigrationsByRepository(clientType ClientType, org, repoName string) ([]*MigrationInfo, error) { + migrations, err := api.ListOrganizationMigrations(clientType, org) + if err != nil { + return nil, err + } + + var matchingMigrations []*MigrationInfo + + for _, migration := range migrations { + // Only consider migrations that are in "exported" state + if migration.GetState() != "exported" { + continue // Skip this entire migration - not in exported state + } + + // Pre-allocate repository names slice and check for target repo in single pass + repositories := make([]string, 0, len(migration.Repositories)) + foundTarget := false + + for _, repo := range migration.Repositories { + currentRepoName := repo.GetName() + repositories = append(repositories, currentRepoName) + + if repoName == currentRepoName { + foundTarget = true + } + } + + // Only create MigrationInfo if target repository was found + if foundTarget { + migrationInfo := &MigrationInfo{ + ID: migration.GetID(), + CreatedAt: migration.GetCreatedAt(), + UpdatedAt: migration.GetUpdatedAt(), + State: migration.GetState(), + Repositories: repositories, // Assign pre-built slice + } + + matchingMigrations = append(matchingMigrations, migrationInfo) + } + } + + return matchingMigrations, nil +} + +// DownloadMigrationArchive downloads a migration archive and returns the file path +func (api *GitHubAPI) DownloadMigrationArchive(clientType ClientType, org string, migrationID int64, outputPath string) (string, error) { + ctx := context.Background() + + client, clientName, err := api.getRESTClient(clientType) + if err != nil { + return "", err + } + + // Step 1: Get the signed S3 URL from GitHub API + signedURL, err := client.Migrations.MigrationArchiveURL(ctx, org, migrationID) + if err != nil { + return "", fmt.Errorf("failed to get %s migration archive URL: %v", clientName, err) + } + + // Step 2: Use a plain HTTP client to download from the signed S3 URL + // Note: We don't need authentication for the signed URL - it's already authorized + httpClient := &http.Client{} + downloadResp, err := httpClient.Get(signedURL) + if err != nil { + return "", fmt.Errorf("failed to download from signed URL: %v", err) + } + defer downloadResp.Body.Close() + + if downloadResp.StatusCode != http.StatusOK { + return "", fmt.Errorf("failed to download migration archive from S3: status %d", downloadResp.StatusCode) + } + + // Create the output file + file, err := os.Create(outputPath) + if err != nil { + return "", fmt.Errorf("failed to create output file: %v", err) + } + defer file.Close() + + // Copy the response body to the file + _, err = io.Copy(file, downloadResp.Body) + if err != nil { + return "", fmt.Errorf("failed to save migration archive: %v", err) + } + + return outputPath, nil +} + +// Helper function to get client config for a given client type +func getClientConfigForType(clientType ClientType) ClientConfig { + switch clientType { + case SourceClient: + return ClientConfig{ + Token: viper.GetString("SOURCE_TOKEN"), + Hostname: viper.GetString("SOURCE_HOSTNAME"), + AppID: viper.GetString("SOURCE_APP_ID"), + PrivateKey: []byte(viper.GetString("SOURCE_PRIVATE_KEY")), + InstallationID: viper.GetInt64("SOURCE_INSTALLATION_ID"), + } + case TargetClient: + return ClientConfig{ + Token: viper.GetString("TARGET_TOKEN"), + Hostname: viper.GetString("TARGET_HOSTNAME"), + AppID: viper.GetString("TARGET_APP_ID"), + PrivateKey: []byte(viper.GetString("TARGET_PRIVATE_KEY")), + InstallationID: viper.GetInt64("TARGET_INSTALLATION_ID"), + } + default: + return ClientConfig{} + } +} diff --git a/internal/archive/archive.go b/internal/archive/archive.go new file mode 100644 index 0000000..f6f2bc7 --- /dev/null +++ b/internal/archive/archive.go @@ -0,0 +1,135 @@ +package archive + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "log" + "os" + "path/filepath" + "strings" +) + +// ExtractTarGz extracts a .tar.gz file to the specified destination directory +func ExtractTarGz(srcPath, destPath string) error { + // Open the source file + file, err := os.Open(srcPath) + if err != nil { + return fmt.Errorf("failed to open archive file: %v", err) + } + defer file.Close() + + // Create gzip reader + gzipReader, err := gzip.NewReader(file) + if err != nil { + return fmt.Errorf("failed to create gzip reader: %v", err) + } + defer gzipReader.Close() + + // Create tar reader + tarReader := tar.NewReader(gzipReader) + + // Create destination directory if it doesn't exist + if err := os.MkdirAll(destPath, 0755); err != nil { + return fmt.Errorf("failed to create destination directory: %v", err) + } + + // Extract files + for { + header, err := tarReader.Next() + if err == io.EOF { + break // End of archive + } + if err != nil { + return fmt.Errorf("failed to read tar header: %v", err) + } + + // Skip problematic paths + if header.Name == "" || header.Name == "." || header.Name == "./" { + continue + } + + // Construct the full path for the file + fullPath := filepath.Join(destPath, header.Name) + + // Security check: ensure the file path is within the destination directory + cleanDestPath := filepath.Clean(destPath) + cleanFullPath := filepath.Clean(fullPath) + + // Check if the clean full path is within the destination directory + // Also prevent files from overwriting the destination directory itself + if !strings.HasPrefix(cleanFullPath, cleanDestPath+string(os.PathSeparator)) { + return fmt.Errorf("invalid file path: %s (resolved to %s, outside %s)", header.Name, cleanFullPath, cleanDestPath) + } + + // Handle different file types + switch header.Typeflag { + case tar.TypeDir: + // Create directory + if err := os.MkdirAll(fullPath, os.FileMode(header.Mode)); err != nil { + return fmt.Errorf("failed to create directory %s: %v", fullPath, err) + } + + case tar.TypeReg: + // Create file + if err := extractFile(tarReader, fullPath, header.Mode); err != nil { + return fmt.Errorf("failed to extract file %s: %v", fullPath, err) + } + + case tar.TypeSymlink: + // Create symbolic link + if err := os.Symlink(header.Linkname, fullPath); err != nil { + return fmt.Errorf("failed to create symlink %s: %v", fullPath, err) + } + + default: + // Skip other file types (block devices, character devices, etc.) + log.Printf("Skipping unsupported file type for %s (type: %d)\n", header.Name, header.Typeflag) + } + } + + return nil +} + +// extractFile extracts a single regular file from the tar archive +func extractFile(tarReader *tar.Reader, destPath string, mode int64) error { + // Ensure the parent directory exists + parentDir := filepath.Dir(destPath) + if err := os.MkdirAll(parentDir, 0755); err != nil { + return fmt.Errorf("failed to create parent directory: %v", err) + } + + // Create the file + file, err := os.Create(destPath) + if err != nil { + return fmt.Errorf("failed to create file: %v", err) + } + defer file.Close() + + // Copy the file contents + _, err = io.Copy(file, tarReader) + if err != nil { + return fmt.Errorf("failed to copy file contents: %v", err) + } + + // Set file permissions + if err := os.Chmod(destPath, os.FileMode(mode)); err != nil { + return fmt.Errorf("failed to set file permissions: %v", err) + } + + return nil +} + +// GetArchiveDestination generates a destination directory name based on the archive filename +func GetArchiveDestination(archivePath string) string { + baseName := filepath.Base(archivePath) + // Remove .tar.gz extension + baseName = strings.TrimSuffix(baseName, ".tar.gz") + // Remove .tgz extension + baseName = strings.TrimSuffix(baseName, ".tgz") + + // Return the directory path in the same location as the archive + archiveDir := filepath.Dir(archivePath) + return filepath.Join(archiveDir, baseName) +} diff --git a/internal/export/export.go b/internal/export/export.go index 2aac8cc..8900e99 100644 --- a/internal/export/export.go +++ b/internal/export/export.go @@ -4,6 +4,7 @@ import ( "encoding/csv" "encoding/json" "fmt" + "mona-actions/gh-migration-validator/internal/migrationarchive" "mona-actions/gh-migration-validator/internal/validator" "os" "path/filepath" @@ -15,13 +16,15 @@ import ( // ExportData represents the exported repository data with metadata type ExportData struct { - ExportTimestamp time.Time `json:"export_timestamp"` - Repository validator.RepositoryData `json:"repository_data"` + ExportTimestamp time.Time `json:"export_timestamp"` + Repository validator.RepositoryData `json:"repository_data"` + MigrationArchive *migrationarchive.MigrationArchiveMetrics `json:"migration_archive,omitempty"` } // ExportSourceData exports source repository data at a point in time // Takes a validator instance to leverage existing data retrieval functionality -func ExportSourceData(mv *validator.MigrationValidator, owner, repoName, format, outputFile string, timestamp time.Time) error { +// If migrationArchiveDir is provided, it will analyze and include migration archive metrics +func ExportSourceData(mv *validator.MigrationValidator, owner, repoName, format, outputFile string, timestamp time.Time, migrationArchiveDir string) error { fmt.Println("Starting source repository data export...") fmt.Printf("Repository: %s/%s\n", owner, repoName) @@ -40,6 +43,21 @@ func ExportSourceData(mv *validator.MigrationValidator, owner, repoName, format, Repository: *mv.SourceData, } + // Analyze migration archive if provided + if migrationArchiveDir != "" { + archiveSpinner, _ := pterm.DefaultSpinner.Start("Analyzing migration archive metrics...") + + archiveMetrics, err := migrationarchive.AnalyzeMigrationArchive(migrationArchiveDir) + if err != nil { + archiveSpinner.Fail("Failed to analyze migration archive") + return fmt.Errorf("failed to analyze migration archive: %w", err) + } + + exportData.MigrationArchive = archiveMetrics + archiveSpinner.Success(fmt.Sprintf("Migration archive analyzed - Issues: %d, PRs: %d, Protected Branches: %d, Releases: %d", + archiveMetrics.Issues, archiveMetrics.PullRequests, archiveMetrics.ProtectedBranches, archiveMetrics.Releases)) + } + // Generate output filename if not provided if outputFile == "" { outputFile = generateExportFileName(owner, repoName, format, timestamp) diff --git a/internal/export/export_test.go b/internal/export/export_test.go index 4328b2a..439aafe 100644 --- a/internal/export/export_test.go +++ b/internal/export/export_test.go @@ -4,6 +4,7 @@ import ( "encoding/csv" "encoding/json" "mona-actions/gh-migration-validator/internal/api" + "mona-actions/gh-migration-validator/internal/migrationarchive" "mona-actions/gh-migration-validator/internal/validator" "os" "path/filepath" @@ -28,6 +29,31 @@ func createTestExportData() ExportData { } } +// createTestExportDataWithMigrationArchive creates sample export data with migration archive for testing +func createTestExportDataWithMigrationArchive() ExportData { + return ExportData{ + ExportTimestamp: time.Date(2025, 10, 13, 12, 0, 0, 0, time.UTC), + Repository: validator.RepositoryData{ + Owner: "test-owner", + Name: "test-repo", + Issues: 5, + PRs: &api.PRCounts{Open: 1, Closed: 2, Merged: 3, Total: 6}, + Tags: 10, + Releases: 8, + CommitCount: 100, + LatestCommitSHA: "sha123", + BranchProtectionRules: 1, + Webhooks: 0, + }, + MigrationArchive: &migrationarchive.MigrationArchiveMetrics{ + Issues: 6, + PullRequests: 29, + ProtectedBranches: 1, + Releases: 25, + }, + } +} + // Note: Full integration tests for ExportSourceData would require API mocking // The following tests focus on the individual components that can be tested in isolation diff --git a/internal/migrationarchive/migrationarchive.go b/internal/migrationarchive/migrationarchive.go new file mode 100644 index 0000000..ef7ec17 --- /dev/null +++ b/internal/migrationarchive/migrationarchive.go @@ -0,0 +1,194 @@ +package migrationarchive + +import ( + "bufio" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "mona-actions/gh-migration-validator/internal/api" + "mona-actions/gh-migration-validator/internal/archive" + + "github.com/pterm/pterm" +) + +// MigrationArchiveMetrics holds the counts of different entities in a migration archive +type MigrationArchiveMetrics struct { + Issues int `json:"issues"` + PullRequests int `json:"pull_requests"` + ProtectedBranches int `json:"protected_branches"` + Releases int `json:"releases"` +} + +// SelectMigrationForRepository finds and selects a migration containing the specified repository +func SelectMigrationForRepository(githubAPI *api.GitHubAPI, org, repoName string) (int64, error) { + // Find migrations containing the target repository + fmt.Printf("Searching for migrations containing repository '%s'...\n", repoName) + matchingMigrations, err := githubAPI.FindMigrationsByRepository(api.SourceClient, org, repoName) + if err != nil { + return 0, fmt.Errorf("failed to search for migrations: %v", err) + } + + if len(matchingMigrations) == 0 { + return 0, fmt.Errorf("no exported migrations found containing repository '%s' in organization %s", repoName, org) + } + + if len(matchingMigrations) == 1 { + // Only one migration found, use it automatically + migrationID := matchingMigrations[0].ID + fmt.Printf("Found one migration containing '%s':\n", repoName) + fmt.Printf(" Migration ID: %d (will use for download)\n", migrationID) + fmt.Printf(" Created: %s\n", matchingMigrations[0].CreatedAt) + return migrationID, nil + } + + // Multiple migrations found, let user choose + fmt.Printf("Found %d migrations containing repository '%s':\n\n", len(matchingMigrations), repoName) + + for i, migration := range matchingMigrations { + fmt.Printf("%d. Migration ID: %d\n", i+1, migration.ID) + fmt.Printf(" Created: %s\n", migration.CreatedAt) + fmt.Printf(" Updated: %s\n", migration.UpdatedAt) + fmt.Printf(" State: %s\n", migration.State) + fmt.Printf(" Repositories (%d): %s\n\n", + len(migration.Repositories), strings.Join(migration.Repositories, ", ")) + } + + // Get user selection + fmt.Printf("Please select a migration (1-%d): ", len(matchingMigrations)) + reader := bufio.NewReader(os.Stdin) + input, err := reader.ReadString('\n') + if err != nil { + return 0, fmt.Errorf("failed to read user input: %v", err) + } + + selection, err := strconv.Atoi(strings.TrimSpace(input)) + if err != nil || selection < 1 || selection > len(matchingMigrations) { + return 0, fmt.Errorf("invalid selection. Please enter a number between 1 and %d", len(matchingMigrations)) + } + + selectedMigration := matchingMigrations[selection-1] + fmt.Printf("Selected migration ID: %d (will use for download)\n", selectedMigration.ID) + return selectedMigration.ID, nil +} + +// DownloadAndExtractArchive downloads and extracts a migration archive for the specified repository +// Returns the path to the extracted archive directory +func DownloadAndExtractArchive(githubAPI *api.GitHubAPI, org, repoName, downloadPath string) (string, error) { + // Select the appropriate migration ID for this repository + migrationID, err := SelectMigrationForRepository(githubAPI, org, repoName) + if err != nil { + return "", err + } + + // Use provided download path or default + outputDir := "migration-archives" + if downloadPath != "" { + outputDir = downloadPath + } + + if err := os.MkdirAll(outputDir, 0755); err != nil { + return "", fmt.Errorf("failed to create output directory: %v", err) + } + + archivePath := filepath.Join(outputDir, fmt.Sprintf("migration-%s-%d.tar.gz", repoName, migrationID)) + + // Download the archive with spinner + downloadSpinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Downloading migration archive %d...", migrationID)) + downloadedPath, err := githubAPI.DownloadMigrationArchive(api.SourceClient, org, migrationID, archivePath) + if err != nil { + downloadSpinner.Fail("Failed to download migration archive") + return "", fmt.Errorf("failed to download migration archive: %v", err) + } + downloadSpinner.Success(fmt.Sprintf("Archive downloaded successfully: %s", downloadedPath)) + + // Extract the archive with spinner + extractPath := archive.GetArchiveDestination(downloadedPath) + extractSpinner, _ := pterm.DefaultSpinner.Start("Extracting migration archive...") + + err = archive.ExtractTarGz(downloadedPath, extractPath) + if err != nil { + extractSpinner.Fail("Failed to extract archive") + return "", fmt.Errorf("failed to extract archive: %v", err) + } + extractSpinner.Success(fmt.Sprintf("Archive extracted successfully: %s", extractPath)) + + return extractPath, nil +} + +// AnalyzeMigrationArchive analyzes a migration archive directory and returns metrics +func AnalyzeMigrationArchive(archiveDir string) (*MigrationArchiveMetrics, error) { + metrics := &MigrationArchiveMetrics{} + + // Count issues from issues_*.json files + issuesCount, err := countJSONArrayEntries(archiveDir, "issues_") + if err != nil { + return nil, fmt.Errorf("failed to count issues: %v", err) + } + metrics.Issues = issuesCount + + // Count pull requests from pull_requests_*.json files + pullRequestsCount, err := countJSONArrayEntries(archiveDir, "pull_requests_") + if err != nil { + return nil, fmt.Errorf("failed to count pull requests: %v", err) + } + metrics.PullRequests = pullRequestsCount + + // Count protected branches from protected_branches_*.json files + protectedBranchesCount, err := countJSONArrayEntries(archiveDir, "protected_branches_") + if err != nil { + return nil, fmt.Errorf("failed to count protected branches: %v", err) + } + metrics.ProtectedBranches = protectedBranchesCount + + // Count releases from releases_*.json files + releasesCount, err := countJSONArrayEntries(archiveDir, "releases_") + if err != nil { + return nil, fmt.Errorf("failed to count releases: %v", err) + } + metrics.Releases = releasesCount + + return metrics, nil +} + +// countJSONArrayEntries counts all entries in JSON files matching the given prefix +func countJSONArrayEntries(archiveDir, filePrefix string) (int, error) { + totalCount := 0 + + // Read directory contents + entries, err := os.ReadDir(archiveDir) + if err != nil { + return 0, fmt.Errorf("failed to read archive directory: %v", err) + } + + // Find all files matching the prefix pattern (e.g., "issues_000001.json", "issues_000002.json") + for _, entry := range entries { + if entry.IsDir() { + continue + } + + fileName := entry.Name() + if strings.HasPrefix(fileName, filePrefix) && strings.HasSuffix(fileName, ".json") { + filePath := filepath.Join(archiveDir, fileName) + + // Read and parse the JSON file + fileContent, err := os.ReadFile(filePath) + if err != nil { + return 0, fmt.Errorf("failed to read file %s: %v", fileName, err) + } + + // Parse as JSON array to count entries + var jsonArray []interface{} + if err := json.Unmarshal(fileContent, &jsonArray); err != nil { + return 0, fmt.Errorf("failed to parse JSON in file %s: %v", fileName, err) + } + + totalCount += len(jsonArray) + } + } + + return totalCount, nil +} diff --git a/internal/migrationarchive/migrationarchive_test.go b/internal/migrationarchive/migrationarchive_test.go new file mode 100644 index 0000000..3fbbb45 --- /dev/null +++ b/internal/migrationarchive/migrationarchive_test.go @@ -0,0 +1,226 @@ +package migrationarchive + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestAnalyzeMigrationArchive(t *testing.T) { + // Create a temporary directory for test files + tempDir := t.TempDir() + + // Create test JSON files with sample data + createTestJSONFile(t, tempDir, "issues_000001.json", []map[string]interface{}{ + {"type": "issue", "id": 1}, + {"type": "issue", "id": 2}, + {"type": "issue", "id": 3}, + }) + + createTestJSONFile(t, tempDir, "issues_000002.json", []map[string]interface{}{ + {"type": "issue", "id": 4}, + }) + + createTestJSONFile(t, tempDir, "pull_requests_000001.json", []map[string]interface{}{ + {"type": "pull_request", "id": 1}, + {"type": "pull_request", "id": 2}, + }) + + createTestJSONFile(t, tempDir, "protected_branches_000001.json", []map[string]interface{}{ + {"type": "protected_branch", "name": "main"}, + }) + + createTestJSONFile(t, tempDir, "releases_000001.json", []map[string]interface{}{ + {"type": "release", "id": 1}, + {"type": "release", "id": 2}, + {"type": "release", "id": 3}, + {"type": "release", "id": 4}, + {"type": "release", "id": 5}, + }) + + // Test AnalyzeMigrationArchive + metrics, err := AnalyzeMigrationArchive(tempDir) + if err != nil { + t.Fatalf("AnalyzeMigrationArchive failed: %v", err) + } + + // Verify the counts + expectedIssues := 4 // 3 from issues_000001.json + 1 from issues_000002.json + expectedPRs := 2 // 2 from pull_requests_000001.json + expectedBranches := 1 // 1 from protected_branches_000001.json + expectedReleases := 5 // 5 from releases_000001.json + + if metrics.Issues != expectedIssues { + t.Errorf("Expected %d issues, got %d", expectedIssues, metrics.Issues) + } + + if metrics.PullRequests != expectedPRs { + t.Errorf("Expected %d pull requests, got %d", expectedPRs, metrics.PullRequests) + } + + if metrics.ProtectedBranches != expectedBranches { + t.Errorf("Expected %d protected branches, got %d", expectedBranches, metrics.ProtectedBranches) + } + + if metrics.Releases != expectedReleases { + t.Errorf("Expected %d releases, got %d", expectedReleases, metrics.Releases) + } +} + +func TestAnalyzeMigrationArchive_EmptyDirectory(t *testing.T) { + tempDir := t.TempDir() + + metrics, err := AnalyzeMigrationArchive(tempDir) + if err != nil { + t.Fatalf("AnalyzeMigrationArchive failed: %v", err) + } + + // All counts should be zero for empty directory + if metrics.Issues != 0 || metrics.PullRequests != 0 || metrics.ProtectedBranches != 0 || metrics.Releases != 0 { + t.Errorf("Expected all counts to be zero, got Issues: %d, PRs: %d, Branches: %d, Releases: %d", + metrics.Issues, metrics.PullRequests, metrics.ProtectedBranches, metrics.Releases) + } +} + +func TestAnalyzeMigrationArchive_NonExistentDirectory(t *testing.T) { + _, err := AnalyzeMigrationArchive("/nonexistent/directory") + if err == nil { + t.Error("Expected error for non-existent directory, got nil") + } +} + +func TestAnalyzeMigrationArchive_InvalidJSON(t *testing.T) { + tempDir := t.TempDir() + + // Create a file with invalid JSON + invalidJSONPath := filepath.Join(tempDir, "issues_000001.json") + err := os.WriteFile(invalidJSONPath, []byte("invalid json content"), 0644) + if err != nil { + t.Fatalf("Failed to create test file: %v", err) + } + + _, err = AnalyzeMigrationArchive(tempDir) + if err == nil { + t.Error("Expected error for invalid JSON, got nil") + } +} + +func TestCountJSONArrayEntries(t *testing.T) { + tempDir := t.TempDir() + + // Create test files with different patterns + createTestJSONFile(t, tempDir, "issues_000001.json", []map[string]interface{}{ + {"id": 1}, {"id": 2}, {"id": 3}, + }) + + createTestJSONFile(t, tempDir, "issues_000002.json", []map[string]interface{}{ + {"id": 4}, {"id": 5}, + }) + + // Create a non-matching file that should be ignored + createTestJSONFile(t, tempDir, "other_000001.json", []map[string]interface{}{ + {"id": 6}, + }) + + // Test counting issues + count, err := countJSONArrayEntries(tempDir, "issues_") + if err != nil { + t.Fatalf("countJSONArrayEntries failed: %v", err) + } + + expectedCount := 5 // 3 + 2 from issues files + if count != expectedCount { + t.Errorf("Expected count %d, got %d", expectedCount, count) + } + + // Test counting non-existent prefix + count, err = countJSONArrayEntries(tempDir, "nonexistent_") + if err != nil { + t.Fatalf("countJSONArrayEntries failed: %v", err) + } + + if count != 0 { + t.Errorf("Expected count 0 for non-existent prefix, got %d", count) + } +} + +func TestSelectMigrationForRepository(t *testing.T) { + // Create a mock GitHubAPI - this would require setting up the API mock + // For now, this is a placeholder test structure + t.Skip("This test requires API mocking infrastructure") + + // TODO: Implement when we have API mocking set up + // This would test: + // - Finding migrations for a repository + // - User selection when multiple migrations exist + // - Handling of no migrations found + // - Handling of single migration found +} + +func TestDownloadAndExtractArchive(t *testing.T) { + // This test also requires API mocking + t.Skip("This test requires API mocking infrastructure") + + // TODO: Implement when we have API mocking set up + // This would test: + // - End-to-end download and extraction flow + // - Error handling for API failures + // - Error handling for extraction failures +} + +// TestDownloadPathHandling tests the downloadPath parameter logic without requiring API calls +func TestDownloadPathHandling(t *testing.T) { + tests := []struct { + name string + downloadPath string + expected string + }{ + { + name: "empty download path uses default", + downloadPath: "", + expected: "migration-archives", + }, + { + name: "custom download path is used", + downloadPath: "/custom/path", + expected: "/custom/path", + }, + { + name: "relative download path is used", + downloadPath: "custom-archives", + expected: "custom-archives", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // This is testing the logic inside DownloadAndExtractArchive + // We can't easily unit test it without refactoring, but we can document the expected behavior + + // The function should use "migration-archives" as default when downloadPath is empty + outputDir := "migration-archives" + if tt.downloadPath != "" { + outputDir = tt.downloadPath + } + + if outputDir != tt.expected { + t.Errorf("Expected outputDir %s, got %s", tt.expected, outputDir) + } + }) + } +} + +// Helper function to create test JSON files +func createTestJSONFile(t *testing.T, dir, filename string, data []map[string]interface{}) { + filePath := filepath.Join(dir, filename) + jsonData, err := json.Marshal(data) + if err != nil { + t.Fatalf("Failed to marshal test data: %v", err) + } + + err = os.WriteFile(filePath, jsonData, 0644) + if err != nil { + t.Fatalf("Failed to create test file %s: %v", filename, err) + } +} diff --git a/internal/validator/validator.go b/internal/validator/validator.go index 5990540..cddb98b 100644 --- a/internal/validator/validator.go +++ b/internal/validator/validator.go @@ -3,6 +3,8 @@ package validator import ( "fmt" "mona-actions/gh-migration-validator/internal/api" + "mona-actions/gh-migration-validator/internal/migrationarchive" + "strings" "sync" "time" @@ -25,6 +27,9 @@ const ( ValidationStatusWarn ) +// MigrationLogIssueOffset represents the additional issue created during migration +const MigrationLogIssueOffset = 1 + // getValidationStatus returns both display string and enum value based on difference // diff > 0: target has fewer items than source (FAIL) // diff < 0: target has more items than source (WARN) @@ -52,6 +57,7 @@ type RepositoryData struct { LatestCommitSHA string BranchProtectionRules int Webhooks int + MigrationArchive *migrationarchive.MigrationArchiveMetrics `json:"migration_archive,omitempty"` } // ValidationResult represents the comparison between source and target @@ -448,14 +454,14 @@ func (mv *MigrationValidator) validateRepositoryData() []ValidationResult { var results []ValidationResult - // Compare Issues (target should have source issues + 1 for migration logging issue) - expectedTargetIssues := mv.SourceData.Issues + 1 + // Compare Issues (target should have source issues + migration log issue) + expectedTargetIssues := mv.SourceData.Issues + MigrationLogIssueOffset issueDiff := expectedTargetIssues - mv.TargetData.Issues issueStatus, issueStatusType := getValidationStatus(issueDiff) results = append(results, ValidationResult{ Metric: "Issues (expected +1 for migration log)", - SourceVal: fmt.Sprintf("%d (expected target: %d)", mv.SourceData.Issues, expectedTargetIssues), + SourceVal: mv.SourceData.Issues, TargetVal: mv.TargetData.Issues, Status: issueStatus, StatusType: issueStatusType, @@ -584,6 +590,108 @@ func (mv *MigrationValidator) validateRepositoryData() []ValidationResult { Difference: 0, // Not applicable for SHA comparison }) + // Add migration archive validation if available + if mv.SourceData.MigrationArchive != nil { + // First, compare migration archive with source API data to check migration completeness + archiveVsSourceIssuesDiff := mv.SourceData.MigrationArchive.Issues - mv.SourceData.Issues + archiveVsSourceIssuesStatus, archiveVsSourceIssuesStatusType := getValidationStatus(archiveVsSourceIssuesDiff) + + results = append(results, ValidationResult{ + Metric: "Archive vs Source Issues", + SourceVal: mv.SourceData.Issues, + TargetVal: mv.SourceData.MigrationArchive.Issues, + Status: archiveVsSourceIssuesStatus, + StatusType: archiveVsSourceIssuesStatusType, + Difference: archiveVsSourceIssuesDiff, + }) + + archiveVsSourcePRsDiff := mv.SourceData.MigrationArchive.PullRequests - mv.SourceData.PRs.Total + archiveVsSourcePRsStatus, archiveVsSourcePRsStatusType := getValidationStatus(archiveVsSourcePRsDiff) + + results = append(results, ValidationResult{ + Metric: "Archive vs Source Pull Requests", + SourceVal: mv.SourceData.PRs.Total, + TargetVal: mv.SourceData.MigrationArchive.PullRequests, + Status: archiveVsSourcePRsStatus, + StatusType: archiveVsSourcePRsStatusType, + Difference: archiveVsSourcePRsDiff, + }) + + archiveVsSourceBranchesDiff := mv.SourceData.MigrationArchive.ProtectedBranches - mv.SourceData.BranchProtectionRules + archiveVsSourceBranchesStatus, archiveVsSourceBranchesStatusType := getValidationStatus(archiveVsSourceBranchesDiff) + + results = append(results, ValidationResult{ + Metric: "Archive vs Source Protected Branches", + SourceVal: mv.SourceData.BranchProtectionRules, + TargetVal: mv.SourceData.MigrationArchive.ProtectedBranches, + Status: archiveVsSourceBranchesStatus, + StatusType: archiveVsSourceBranchesStatusType, + Difference: archiveVsSourceBranchesDiff, + }) + + archiveVsSourceReleasesDiff := mv.SourceData.MigrationArchive.Releases - mv.SourceData.Releases + archiveVsSourceReleasesStatus, archiveVsSourceReleasesStatusType := getValidationStatus(archiveVsSourceReleasesDiff) + + results = append(results, ValidationResult{ + Metric: "Archive vs Source Releases", + SourceVal: mv.SourceData.Releases, + TargetVal: mv.SourceData.MigrationArchive.Releases, + Status: archiveVsSourceReleasesStatus, + StatusType: archiveVsSourceReleasesStatusType, + Difference: archiveVsSourceReleasesDiff, + }) + + // Then, compare migration archive with target data to check migration success + expectedTargetFromArchive := mv.SourceData.MigrationArchive.Issues + MigrationLogIssueOffset + archiveToTargetIssuesDiff := expectedTargetFromArchive - mv.TargetData.Issues + archiveToTargetIssuesStatus, archiveToTargetIssuesStatusType := getValidationStatus(archiveToTargetIssuesDiff) + + results = append(results, ValidationResult{ + Metric: "Archive vs Target Issues (expected +1 for migration log)", + SourceVal: mv.SourceData.MigrationArchive.Issues, + TargetVal: mv.TargetData.Issues, + Status: archiveToTargetIssuesStatus, + StatusType: archiveToTargetIssuesStatusType, + Difference: archiveToTargetIssuesDiff, + }) + + archiveToTargetPRsDiff := mv.SourceData.MigrationArchive.PullRequests - mv.TargetData.PRs.Total + archiveToTargetPRsStatus, archiveToTargetPRsStatusType := getValidationStatus(archiveToTargetPRsDiff) + + results = append(results, ValidationResult{ + Metric: "Archive vs Target Pull Requests", + SourceVal: mv.SourceData.MigrationArchive.PullRequests, + TargetVal: mv.TargetData.PRs.Total, + Status: archiveToTargetPRsStatus, + StatusType: archiveToTargetPRsStatusType, + Difference: archiveToTargetPRsDiff, + }) + + archiveToTargetBranchesDiff := mv.SourceData.MigrationArchive.ProtectedBranches - mv.TargetData.BranchProtectionRules + archiveToTargetBranchesStatus, archiveToTargetBranchesStatusType := getValidationStatus(archiveToTargetBranchesDiff) + + results = append(results, ValidationResult{ + Metric: "Archive vs Target Protected Branches", + SourceVal: mv.SourceData.MigrationArchive.ProtectedBranches, + TargetVal: mv.TargetData.BranchProtectionRules, + Status: archiveToTargetBranchesStatus, + StatusType: archiveToTargetBranchesStatusType, + Difference: archiveToTargetBranchesDiff, + }) + + archiveToTargetReleasesDiff := mv.SourceData.MigrationArchive.Releases - mv.TargetData.Releases + archiveToTargetReleasesStatus, archiveToTargetReleasesStatusType := getValidationStatus(archiveToTargetReleasesDiff) + + results = append(results, ValidationResult{ + Metric: "Archive vs Target Releases", + SourceVal: mv.SourceData.MigrationArchive.Releases, + TargetVal: mv.TargetData.Releases, + Status: archiveToTargetReleasesStatus, + StatusType: archiveToTargetReleasesStatusType, + Difference: archiveToTargetReleasesDiff, + }) + } + return results } @@ -602,11 +710,63 @@ func (mv *MigrationValidator) PrintValidationResults(results []ValidationResult) fmt.Println() // Add spacing - // Create table data - tableData := [][]string{ - {"Metric", "Status", "Source Value", "Target Value", "Difference"}, + // Separate results into different categories + var standardResults []ValidationResult + var archiveVsSourceResults []ValidationResult + var archiveVsTargetResults []ValidationResult + + for _, result := range results { + if strings.HasPrefix(result.Metric, "Archive vs Source") { + archiveVsSourceResults = append(archiveVsSourceResults, result) + } else if strings.HasPrefix(result.Metric, "Archive vs Target") { + archiveVsTargetResults = append(archiveVsTargetResults, result) + } else { + standardResults = append(standardResults, result) + } + } + + // Display standard validation table + mv.displayValidationTable("πŸ”„ Source vs Target Validation", standardResults) + + // Display migration archive validation tables if available + if len(archiveVsSourceResults) > 0 { + fmt.Println() + mv.displayValidationTable("πŸ“¦ Migration Archive vs Source Validation", archiveVsSourceResults) + } + + if len(archiveVsTargetResults) > 0 { + fmt.Println() + mv.displayValidationTable("🎯 Migration Archive vs Target Validation", archiveVsTargetResults) + } + + fmt.Println() // Add spacing + + // Calculate and display summary for all results + mv.displayValidationSummary(results) +} + +// displayValidationTable displays a validation table with the given title and results +func (mv *MigrationValidator) displayValidationTable(title string, results []ValidationResult) { + if len(results) == 0 { + return + } + + // Print section title + pterm.DefaultSection.Println(title) + + // Determine appropriate headers based on the validation type + var headers []string + if strings.Contains(title, "Archive vs Source") { + headers = []string{"Metric", "Status", "Source API Value", "Archive Value", "Difference"} + } else if strings.Contains(title, "Archive vs Target") { + headers = []string{"Metric", "Status", "Archive Value", "Target Value", "Difference"} + } else { + headers = []string{"Metric", "Status", "Source Value", "Target Value", "Difference"} } + // Create table data + tableData := [][]string{headers} + for _, result := range results { diffStr := "" if result.Difference > 0 { @@ -631,9 +791,10 @@ func (mv *MigrationValidator) PrintValidationResults(results []ValidationResult) // Create and display the table table := pterm.DefaultTable.WithHasHeader().WithData(tableData) table.Render() +} - fmt.Println() // Add spacing - +// displayValidationSummary calculates and displays the overall validation summary +func (mv *MigrationValidator) displayValidationSummary(results []ValidationResult) { // Calculate summary passCount := 0 failCount := 0 diff --git a/internal/validator/validator_migration_archive_test.go b/internal/validator/validator_migration_archive_test.go new file mode 100644 index 0000000..2f3c1a6 --- /dev/null +++ b/internal/validator/validator_migration_archive_test.go @@ -0,0 +1,173 @@ +package validator + +import ( + "testing" + + "mona-actions/gh-migration-validator/internal/api" + "mona-actions/gh-migration-validator/internal/migrationarchive" + + "github.com/stretchr/testify/assert" +) + +// Test migration archive functionality +func TestSetSourceDataFromExport_WithMigrationArchive(t *testing.T) { + validator := New(nil) + + // Test data with migration archive + repositoryData := &RepositoryData{ + Owner: "test-owner", + Name: "test-repo", + Issues: 5, + PRs: &api.PRCounts{Open: 1, Closed: 2, Merged: 3, Total: 6}, + Tags: 10, + Releases: 8, + CommitCount: 100, + LatestCommitSHA: "sha123", + MigrationArchive: &migrationarchive.MigrationArchiveMetrics{ + Issues: 6, + PullRequests: 29, + ProtectedBranches: 1, + Releases: 25, + }, + } + + validator.SetSourceDataFromExport(repositoryData) + + // Verify migration archive data was set + assert.NotNil(t, validator.SourceData.MigrationArchive, "Migration archive should not be nil") + assert.Equal(t, 6, validator.SourceData.MigrationArchive.Issues) + assert.Equal(t, 29, validator.SourceData.MigrationArchive.PullRequests) + assert.Equal(t, 1, validator.SourceData.MigrationArchive.ProtectedBranches) + assert.Equal(t, 25, validator.SourceData.MigrationArchive.Releases) +} + +func TestValidateRepositoryData_WithMigrationArchive(t *testing.T) { + sourceData := &RepositoryData{ + Owner: "source-org", + Name: "source-repo", + Issues: 2, + PRs: &api.PRCounts{Total: 29, Open: 0, Merged: 27, Closed: 2}, + Tags: 25, + Releases: 25, + CommitCount: 64, + LatestCommitSHA: "source123", + BranchProtectionRules: 1, + Webhooks: 0, + MigrationArchive: &migrationarchive.MigrationArchiveMetrics{ + Issues: 6, + PullRequests: 29, + ProtectedBranches: 1, + Releases: 25, + }, + } + + targetData := &RepositoryData{ + Owner: "target-org", + Name: "target-repo", + Issues: 7, // 6 from archive + 1 migration log + PRs: &api.PRCounts{Total: 29, Open: 0, Merged: 27, Closed: 2}, + Tags: 25, + Releases: 25, + CommitCount: 64, + LatestCommitSHA: "target123", + BranchProtectionRules: 1, + Webhooks: 0, + } + + validator := setupTestValidator(sourceData, targetData) + results := validator.validateRepositoryData() + + // Check that we have migration archive validation results + hasArchiveVsSource := false + hasArchiveVsTarget := false + + for _, result := range results { + if result.Metric == "Archive vs Source Issues" { + hasArchiveVsSource = true + // Archive has 6, source API has 2, difference = 4 + assert.Equal(t, 4, result.Difference) + } + if result.Metric == "Archive vs Target Issues (expected +1 for migration log)" { + hasArchiveVsTarget = true + // Expected target: 6 + 1 = 7, actual target: 7, difference = 0 + assert.Equal(t, 0, result.Difference) + } + } + + assert.True(t, hasArchiveVsSource, "Should have archive vs source validation") + assert.True(t, hasArchiveVsTarget, "Should have archive vs target validation") +} + +func TestValidateRepositoryData_WithoutMigrationArchive(t *testing.T) { + sourceData := &RepositoryData{ + Owner: "source-org", + Name: "source-repo", + Issues: 2, + PRs: &api.PRCounts{Total: 29, Open: 0, Merged: 27, Closed: 2}, + Tags: 25, + Releases: 25, + CommitCount: 64, + LatestCommitSHA: "source123", + BranchProtectionRules: 1, + Webhooks: 0, + MigrationArchive: nil, // No migration archive + } + + targetData := &RepositoryData{ + Owner: "target-org", + Name: "target-repo", + Issues: 3, + PRs: &api.PRCounts{Total: 29, Open: 0, Merged: 27, Closed: 2}, + Tags: 25, + Releases: 25, + CommitCount: 64, + LatestCommitSHA: "target123", + BranchProtectionRules: 1, + Webhooks: 0, + } + + validator := setupTestValidator(sourceData, targetData) + results := validator.validateRepositoryData() + + // Check that we don't have migration archive validation results + for _, result := range results { + assert.NotContains(t, result.Metric, "Archive vs", "Should not have archive validation without migration archive data") + } + + // Should only have standard validation metrics + assert.Equal(t, len(expectedValidationMetrics), len(results), "Should have standard validation metrics count") +} + +func TestDisplayValidationTable_Headers(t *testing.T) { + validator := New(nil) + + // Test different table types have correct headers + testCases := []struct { + title string + expectedHeaders []string + }{ + { + title: "πŸ”„ Source vs Target Validation", + expectedHeaders: []string{"Metric", "Status", "Source Value", "Target Value", "Difference"}, + }, + { + title: "πŸ“¦ Migration Archive vs Source Validation", + expectedHeaders: []string{"Metric", "Status", "Source API Value", "Archive Value", "Difference"}, + }, + { + title: "🎯 Migration Archive vs Target Validation", + expectedHeaders: []string{"Metric", "Status", "Archive Value", "Target Value", "Difference"}, + }, + } + + for _, tc := range testCases { + t.Run(tc.title, func(t *testing.T) { + // Capture the table output (this would require more sophisticated mocking) + // For now, we can at least verify the function doesn't panic + validator.displayValidationTable(tc.title, []ValidationResult{}) + + // TODO: Implement proper output capture and header verification + // This would require mocking pterm.DefaultTable or capturing its output + }) + } +}