Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,5 @@ scratch

# binary
gh-migration-validator
.exports/
.exports/
migration-archives/
89 changes: 87 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -111,16 +119,21 @@ 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

**JSON Format:**

```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": {
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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:
Expand All @@ -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.
Expand Down
111 changes: 107 additions & 4 deletions cmd/export.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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()
Expand All @@ -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 != "" {
Expand Down Expand Up @@ -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)
Comment thread
pmartindev marked this conversation as resolved.
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)
Expand All @@ -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
Expand All @@ -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
}
Loading