Skip to content

Commit 63af9d3

Browse files
authored
Feat: Validate from Export (#4)
1 parent 5fdff20 commit 63af9d3

6 files changed

Lines changed: 849 additions & 165 deletions

File tree

README.md

Lines changed: 71 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -79,9 +79,18 @@ For GitHub Enterprise Server:
7979
export GHMV_SOURCE_HOSTNAME="https://github.example.com"
8080
```
8181

82-
## Export Functionality
82+
## Export and Validation Workflow
8383

84-
The tool also provides an export command to capture repository data at a specific point in time, which can be useful for creating snapshots before and after migrations.
84+
The tool provides both export and validation capabilities that work together to enable point-in-time migration validation:
85+
86+
1. **Export**: Capture repository data at a specific point in time
87+
2. **Validate-from-Export**: Validate target repositories against exported snapshots
88+
89+
This workflow is particularly useful when:
90+
91+
- The source repository continues to receive changes during migration
92+
- You need to validate against the exact state when migration occurred
93+
- You want to create audit trails of migration validation
8594

8695
### Export Usage
8796

@@ -140,6 +149,65 @@ When no output file is specified, exports are automatically saved to `.exports/`
140149

141150
Example: `.exports/mona-actions_my-repo_export_20251002_144908.json`
142151

152+
## Validate-from-Export
153+
154+
The `validate-from-export` command allows you to validate a target repository against a previously exported snapshot of source repository data. This is essential for validating migrations when the source repository may have changed since the migration occurred.
155+
156+
### Validate-from-Export Usage
157+
158+
```bash
159+
gh migration-validator validate-from-export \
160+
--export-file ".exports/mona-actions_my-repo_export_20251002_144908.json" \
161+
--target-organization "target-org" \
162+
--target-repo "my-repo" \
163+
--target-token "ghp_yyy"
164+
```
165+
166+
### Validate-from-Export Options
167+
168+
- `--export-file` (required): Path to the exported JSON file containing source data
169+
- `--target-organization` (required): Target organization name
170+
- `--target-repo` (required): Target repository name
171+
- `--target-token` (required): GitHub token with read permissions for target
172+
- `--target-hostname` (optional): GitHub Enterprise Server URL for target
173+
- `--markdown-table` (optional): Output results in markdown format
174+
175+
### Environment Variables for Validate-from-Export
176+
177+
```bash
178+
export GHMV_TARGET_ORGANIZATION="target-org"
179+
export GHMV_TARGET_TOKEN="ghp_yyy"
180+
export GHMV_TARGET_REPO="my-repo"
181+
export GHMV_MARKDOWN_TABLE="true"
182+
183+
gh migration-validator validate-from-export --export-file "path/to/export.json"
184+
```
185+
186+
### Complete Export and Validation Workflow
187+
188+
1. **Export source data before migration:**
189+
190+
```bash
191+
gh migration-validator export \
192+
--source-organization "source-org" \
193+
--source-repo "my-repo" \
194+
--source-token "ghp_xxx"
195+
```
196+
197+
2. **Perform your migration** (using GitHub's migration tools)
198+
199+
3. **Validate against the export:**
200+
201+
```bash
202+
gh migration-validator validate-from-export \
203+
--export-file ".exports/source-org_my-repo_export_20251002_144908.json" \
204+
--target-organization "target-org" \
205+
--target-repo "my-repo" \
206+
--target-token "ghp_yyy"
207+
```
208+
209+
This ensures you're validating against the exact state of the source repository when the migration occurred, regardless of any subsequent changes.
210+
143211
## What Gets Validated
144212

145213
The tool compares the following metrics between source and target repositories:
@@ -185,4 +253,4 @@ Contributions are welcome! Please see [CONTRIBUTING.md](.github/contributing.md)
185253

186254
## License
187255

188-
[MIT](./LICENSE) © [Mona-Actions](https://github.com/mona-actions)
256+
[MIT](./LICENSE) © [Mona-Actions](https://github.com/mona-actions)

cmd/validate_from_export.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/*
2+
Copyright © 2025 NAME HERE <EMAIL ADDRESS>
3+
*/
4+
package cmd
5+
6+
import (
7+
"fmt"
8+
"mona-actions/gh-migration-validator/internal/export"
9+
"mona-actions/gh-migration-validator/internal/validator"
10+
"os"
11+
12+
"github.com/spf13/cobra"
13+
"github.com/spf13/viper"
14+
)
15+
16+
// validateFromExportCmd represents the validate-from-export command
17+
var validateFromExportCmd = &cobra.Command{
18+
Use: "validate-from-export",
19+
Short: "Validate target repository against exported source data",
20+
Long: `Validate a target repository against previously exported source repository data.
21+
22+
This command allows you to validate a migration by comparing the target repository
23+
against a point-in-time snapshot of the source repository that was previously
24+
exported using the 'export' command.
25+
26+
This is useful for:
27+
- Validating migrations against an active repository that may have changed since migration
28+
- Comparing target repositories to source state at migration time
29+
- Ensuring migration integrity when source data may have changed
30+
31+
The validation compares the same metrics as the standard validate command:
32+
- Issues count
33+
- Pull requests count (open, closed, merged)
34+
- Tags count
35+
- Releases count
36+
- Commits count
37+
- Latest commit hash`,
38+
Run: func(cmd *cobra.Command, args []string) {
39+
// Get parameters from flags
40+
exportFile := cmd.Flag("export-file").Value.String()
41+
targetOrganization := cmd.Flag("target-organization").Value.String()
42+
targetToken := cmd.Flag("target-token").Value.String()
43+
targetHostname := cmd.Flag("target-hostname").Value.String()
44+
targetRepo := cmd.Flag("target-repo").Value.String()
45+
markdownTable, err := cmd.Flags().GetBool("markdown-table")
46+
if err != nil {
47+
fmt.Printf("Failed to parse 'markdown-table' flag: %v\n", err)
48+
os.Exit(1)
49+
}
50+
51+
// Only set ENV variables if flag values are provided (not empty)
52+
if targetToken != "" {
53+
os.Setenv("GHMV_TARGET_TOKEN", targetToken)
54+
}
55+
if targetHostname != "" {
56+
os.Setenv("GHMV_TARGET_HOSTNAME", targetHostname)
57+
}
58+
if markdownTable {
59+
os.Setenv("GHMV_MARKDOWN_TABLE", "true")
60+
}
61+
62+
// Bind ENV variables in Viper (for optional parameters that can use env vars)
63+
viper.BindEnv("TARGET_TOKEN")
64+
viper.BindEnv("TARGET_HOSTNAME")
65+
viper.BindEnv("TARGET_PRIVATE_KEY")
66+
viper.BindEnv("TARGET_APP_ID")
67+
viper.BindEnv("TARGET_INSTALLATION_ID")
68+
viper.BindEnv("MARKDOWN_TABLE")
69+
70+
// Validate required parameters (using flag values directly for required flags)
71+
if err := checkExportValidationVars(exportFile); err != nil {
72+
fmt.Printf("Export validation configuration failed: %v\n", err)
73+
os.Exit(1)
74+
}
75+
76+
// Load export data from file
77+
exportData, err := export.LoadExportData(exportFile)
78+
if err != nil {
79+
fmt.Printf("Failed to load export file: %v\n", err)
80+
os.Exit(1)
81+
}
82+
83+
// Initialize API for target
84+
initializeAPI()
85+
86+
// Create validator and perform validation
87+
migrationValidator := validator.New(ghAPI)
88+
89+
// Set source data from export instead of fetching from API
90+
migrationValidator.SetSourceDataFromExport(&exportData.Repository)
91+
92+
// Perform validation against target (now returns results directly)
93+
results, err := migrationValidator.ValidateFromExport(targetOrganization, targetRepo)
94+
if err != nil {
95+
fmt.Printf("Validation failed: %v\n", err)
96+
os.Exit(1)
97+
}
98+
99+
// Display results using existing method
100+
migrationValidator.PrintValidationResults(results)
101+
},
102+
}
103+
104+
func init() {
105+
// Add validate-from-export command to root
106+
rootCmd.AddCommand(validateFromExportCmd)
107+
108+
// Define flags specific to validate-from-export command
109+
validateFromExportCmd.Flags().StringP("export-file", "e", "", "Path to the exported JSON file to use as source data")
110+
validateFromExportCmd.MarkFlagRequired("export-file")
111+
112+
validateFromExportCmd.Flags().StringP("target-organization", "t", "", "Target Organization to validate against")
113+
validateFromExportCmd.MarkFlagRequired("target-organization")
114+
115+
validateFromExportCmd.Flags().StringP("target-token", "b", "", "Target Organization GitHub token. Scopes: read:org, read:user, user:email")
116+
117+
validateFromExportCmd.Flags().StringP("target-hostname", "v", "", "GitHub Enterprise target hostname url (optional) Ex. https://github.example.com")
118+
119+
validateFromExportCmd.Flags().String("target-repo", "", "Target repository name to validate (just the repo name, not owner/repo)")
120+
validateFromExportCmd.MarkFlagRequired("target-repo")
121+
122+
validateFromExportCmd.Flags().BoolP("markdown-table", "m", false, "Output results in markdown table format")
123+
}
124+
125+
// checkExportValidationVars validates the configuration for validate-from-export command
126+
func checkExportValidationVars(exportFile string) error {
127+
128+
// Check if export file exists
129+
if _, err := os.Stat(exportFile); os.IsNotExist(err) {
130+
return fmt.Errorf("export file does not exist: %s", exportFile)
131+
}
132+
133+
// Check for target token (can come from flag or environment variable)
134+
targetToken := viper.GetString("TARGET_TOKEN")
135+
if targetToken == "" {
136+
return fmt.Errorf("target token is required. Set it via --target-token flag or GHMV_TARGET_TOKEN environment variable")
137+
}
138+
139+
return nil
140+
}

internal/export/export.go

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,3 +162,50 @@ func exportToCSV(data ExportData, filename string) error {
162162

163163
return nil
164164
}
165+
166+
// LoadExportData loads and validates export data from a JSON file
167+
func LoadExportData(filename string) (*ExportData, error) {
168+
// Check if file exists
169+
if _, err := os.Stat(filename); os.IsNotExist(err) {
170+
return nil, fmt.Errorf("export file does not exist: %s", filename)
171+
}
172+
173+
// Read file content
174+
content, err := os.ReadFile(filename)
175+
if err != nil {
176+
return nil, fmt.Errorf("failed to read export file: %w", err)
177+
}
178+
179+
// Parse JSON
180+
var exportData ExportData
181+
if err := json.Unmarshal(content, &exportData); err != nil {
182+
return nil, fmt.Errorf("failed to parse export JSON: %w", err)
183+
}
184+
185+
// Validate required fields
186+
if err := validateExportData(&exportData); err != nil {
187+
return nil, fmt.Errorf("invalid export data: %w", err)
188+
}
189+
190+
return &exportData, nil
191+
}
192+
193+
// validateExportData ensures the export data has all required fields
194+
func validateExportData(data *ExportData) error {
195+
if data.ExportTimestamp.IsZero() {
196+
return fmt.Errorf("export timestamp is missing or invalid")
197+
}
198+
199+
if data.Repository.Owner == "" {
200+
return fmt.Errorf("repository owner is missing")
201+
}
202+
203+
if data.Repository.Name == "" {
204+
return fmt.Errorf("repository name is missing")
205+
}
206+
207+
// Note: We don't validate PRs for nil here since we handle that gracefully in the export functions
208+
// This allows for flexibility in case the export format evolves
209+
210+
return nil
211+
}

internal/export/export_test.go

Lines changed: 10 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -71,19 +71,7 @@ func TestExportToJSON(t *testing.T) {
7171
filename := filepath.Join(tmpDir, "test.json")
7272

7373
// Create test data
74-
exportData := ExportData{
75-
ExportTimestamp: time.Date(2025, 10, 2, 14, 30, 0, 0, time.UTC),
76-
Repository: validator.RepositoryData{
77-
Owner: "test-owner",
78-
Name: "test-repo",
79-
Issues: 10,
80-
PRs: &api.PRCounts{Open: 1, Closed: 2, Merged: 3, Total: 6},
81-
Tags: 5,
82-
Releases: 2,
83-
CommitCount: 100,
84-
LatestCommitSHA: "abcd1234",
85-
},
86-
}
74+
exportData := createTestExportData()
8775

8876
// Test the function
8977
err := exportToJSON(exportData, filename)
@@ -106,6 +94,12 @@ func TestExportToJSON(t *testing.T) {
10694
if parsed.Repository.Owner != exportData.Repository.Owner {
10795
t.Errorf("Expected owner %s, got %s", exportData.Repository.Owner, parsed.Repository.Owner)
10896
}
97+
if parsed.Repository.Name != exportData.Repository.Name {
98+
t.Errorf("Expected name %s, got %s", exportData.Repository.Name, parsed.Repository.Name)
99+
}
100+
if parsed.Repository.Issues != exportData.Repository.Issues {
101+
t.Errorf("Expected %d issues, got %d", exportData.Repository.Issues, parsed.Repository.Issues)
102+
}
109103
}
110104

111105
func TestExportToCSV(t *testing.T) {
@@ -114,19 +108,7 @@ func TestExportToCSV(t *testing.T) {
114108
filename := filepath.Join(tmpDir, "test.csv")
115109

116110
// Create test data
117-
exportData := ExportData{
118-
ExportTimestamp: time.Date(2025, 10, 2, 14, 30, 0, 0, time.UTC),
119-
Repository: validator.RepositoryData{
120-
Owner: "test-owner",
121-
Name: "test-repo",
122-
Issues: 10,
123-
PRs: &api.PRCounts{Open: 1, Closed: 2, Merged: 3, Total: 6},
124-
Tags: 5,
125-
Releases: 2,
126-
CommitCount: 100,
127-
LatestCommitSHA: "abcd1234",
128-
},
129-
}
111+
exportData := createTestExportData()
130112

131113
// Test the function
132114
err := exportToCSV(exportData, filename)
@@ -160,8 +142,8 @@ func TestExportToCSV(t *testing.T) {
160142
if dataRow[2] != "test-repo" {
161143
t.Errorf("Expected repo test-repo, got %s", dataRow[2])
162144
}
163-
if dataRow[3] != "10" {
164-
t.Errorf("Expected 10 issues, got %s", dataRow[3])
145+
if dataRow[3] != "42" {
146+
t.Errorf("Expected 42 issues, got %s", dataRow[3])
165147
}
166148
}
167149

0 commit comments

Comments
 (0)