Skip to content

Commit 5fdff20

Browse files
authored
Merge pull request #3 from mona-actions/amenocal/export-functionality
add export command and data export functionality for source
2 parents 9cefb2b + 7a9d81c commit 5fdff20

6 files changed

Lines changed: 586 additions & 1 deletion

File tree

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,5 @@ scratch
3535
.env
3636

3737
# binary
38-
gh-migration-validator
38+
gh-migration-validator
39+
.exports/

README.md

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

82+
## Export Functionality
83+
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.
85+
86+
### Export Usage
87+
88+
```bash
89+
gh migration-validator export \
90+
--source-organization "source-org" \
91+
--source-repo "my-repo" \
92+
--source-token "ghp_xxx" \
93+
--format json \
94+
--output ".exports/my-export.json"
95+
```
96+
97+
### Export Options
98+
99+
- `--source-organization` (required): Source organization name
100+
- `--source-repo` (required): Source repository name
101+
- `--source-token` (required): GitHub token with read permissions
102+
- `--source-hostname` (optional): GitHub Enterprise Server URL
103+
- `--format` (optional): Export format - `json` or `csv` (default: `json`)
104+
- `--output` (optional): Output file path (auto-generated if not specified)
105+
106+
### Export Output Formats
107+
108+
**JSON Format:**
109+
110+
```json
111+
{
112+
"export_timestamp": "2025-10-02T14:49:08Z",
113+
"repository_data": {
114+
"owner": "mona-actions",
115+
"name": "my-repo",
116+
"issues": 42,
117+
"pull_requests": {
118+
"open": 5,
119+
"closed": 10,
120+
"merged": 15,
121+
"total": 30
122+
},
123+
"tags": 8,
124+
"releases": 3,
125+
"commits": 150,
126+
"latest_commit_sha": "abc123def456"
127+
}
128+
}
129+
```
130+
131+
**CSV Format:**
132+
133+
Contains the same data in CSV format with headers for easy analysis in spreadsheet applications.
134+
135+
### Default Export Location
136+
137+
When no output file is specified, exports are automatically saved to `.exports/` directory with timestamped filenames:
138+
139+
- `.exports/{owner}_{repo}_export_{timestamp}.{format}`
140+
141+
Example: `.exports/mona-actions_my-repo_export_20251002_144908.json`
142+
82143
## What Gets Validated
83144

84145
The tool compares the following metrics between source and target repositories:
@@ -99,9 +160,11 @@ The tool compares the following metrics between source and target repositories:
99160
## Output Formats
100161

101162
### Console Output
163+
102164
The tool provides a formatted table with colored status indicators and a summary.
103165

104166
### Markdown Output
167+
105168
Use the `--markdown-table` flag to generate copy-paste ready markdown for documentation.
106169

107170
## Dependencies

cmd/export.go

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
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+
"time"
12+
13+
"github.com/spf13/cobra"
14+
"github.com/spf13/viper"
15+
)
16+
17+
// exportCmd represents the export command
18+
var exportCmd = &cobra.Command{
19+
Use: "export",
20+
Short: "Export source repository data at a point in time",
21+
Long: `Export repository data from the source organization at the current point in time.
22+
23+
This command fetches and exports repository metadata including:
24+
- Issues count
25+
- Pull requests count (open, closed, merged)
26+
- Tags count
27+
- Releases count
28+
- Commits count
29+
- Latest commit hash
30+
31+
The data can be exported in JSON or CSV format with a timestamp.`,
32+
Run: func(cmd *cobra.Command, args []string) {
33+
// Get parameters from flags
34+
sourceOrganization := cmd.Flag("source-organization").Value.String()
35+
sourceToken := cmd.Flag("source-token").Value.String()
36+
ghHostname := cmd.Flag("source-hostname").Value.String()
37+
sourceRepo := cmd.Flag("source-repo").Value.String()
38+
outputFormat := cmd.Flag("format").Value.String()
39+
outputFile := cmd.Flag("output").Value.String()
40+
41+
// Only set ENV variables if flag values are provided (not empty)
42+
if sourceOrganization != "" {
43+
os.Setenv("GHMV_SOURCE_ORGANIZATION", sourceOrganization)
44+
}
45+
if sourceToken != "" {
46+
os.Setenv("GHMV_SOURCE_TOKEN", sourceToken)
47+
}
48+
if ghHostname != "" {
49+
os.Setenv("GHMV_SOURCE_HOSTNAME", ghHostname)
50+
}
51+
if sourceRepo != "" {
52+
os.Setenv("GHMV_SOURCE_REPO", sourceRepo)
53+
}
54+
55+
// Bind ENV variables in Viper
56+
viper.BindEnv("SOURCE_ORGANIZATION")
57+
viper.BindEnv("SOURCE_TOKEN")
58+
viper.BindEnv("SOURCE_HOSTNAME")
59+
viper.BindEnv("SOURCE_PRIVATE_KEY")
60+
viper.BindEnv("SOURCE_APP_ID")
61+
viper.BindEnv("SOURCE_INSTALLATION_ID")
62+
viper.BindEnv("SOURCE_REPO")
63+
64+
// Validate required variables for export
65+
if err := checkExportVars(); err != nil {
66+
fmt.Printf("Export configuration validation failed: %v\n", err)
67+
os.Exit(1)
68+
}
69+
70+
initializeAPI()
71+
72+
// Create validator and export source data
73+
migrationValidator := validator.New(ghAPI)
74+
75+
// Export the source repository data
76+
timestamp := time.Now()
77+
err := export.ExportSourceData(migrationValidator, sourceOrganization, sourceRepo, outputFormat, outputFile, timestamp)
78+
if err != nil {
79+
fmt.Printf("Export failed: %v\n", err)
80+
os.Exit(1)
81+
}
82+
},
83+
}
84+
85+
func init() {
86+
// Add export command to root
87+
rootCmd.AddCommand(exportCmd)
88+
89+
// Define flags specific to export command
90+
exportCmd.Flags().StringP("source-organization", "s", "", "Source Organization to export data from")
91+
exportCmd.MarkFlagRequired("source-organization")
92+
93+
exportCmd.Flags().StringP("source-token", "a", "", "Source Organization GitHub token. Scopes: read:org, read:user, user:email")
94+
95+
exportCmd.Flags().StringP("source-hostname", "u", "", "GitHub Enterprise source hostname url (optional) Ex. https://github.example.com")
96+
97+
exportCmd.Flags().StringP("source-repo", "", "", "Source repository name to export (just the repo name, not owner/repo)")
98+
exportCmd.MarkFlagRequired("source-repo")
99+
100+
exportCmd.Flags().StringP("format", "f", "json", "Output format: json or csv")
101+
102+
exportCmd.Flags().StringP("output", "o", "", "Output file path (if not provided, will use default naming)")
103+
}
104+
105+
// checkExportVars validates the configuration for export command
106+
func checkExportVars() error {
107+
// Check for source token
108+
sourceToken := viper.GetString("SOURCE_TOKEN")
109+
if sourceToken == "" {
110+
return fmt.Errorf("source token is required. Set it via --source-token flag or GHMV_SOURCE_TOKEN environment variable")
111+
}
112+
113+
// Check source repository
114+
sourceRepo := viper.GetString("SOURCE_REPO")
115+
if sourceRepo == "" {
116+
return fmt.Errorf("source repository is required. Set it via --source-repo flag")
117+
}
118+
119+
return nil
120+
}

internal/export/export.go

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
package export
2+
3+
import (
4+
"encoding/csv"
5+
"encoding/json"
6+
"fmt"
7+
"mona-actions/gh-migration-validator/internal/validator"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"time"
12+
13+
"github.com/pterm/pterm"
14+
)
15+
16+
// ExportData represents the exported repository data with metadata
17+
type ExportData struct {
18+
ExportTimestamp time.Time `json:"export_timestamp"`
19+
Repository validator.RepositoryData `json:"repository_data"`
20+
}
21+
22+
// ExportSourceData exports source repository data at a point in time
23+
// Takes a validator instance to leverage existing data retrieval functionality
24+
func ExportSourceData(mv *validator.MigrationValidator, owner, repoName, format, outputFile string, timestamp time.Time) error {
25+
fmt.Println("Starting source repository data export...")
26+
fmt.Printf("Repository: %s/%s\n", owner, repoName)
27+
28+
// Create a spinner for the export process
29+
spinner, _ := pterm.DefaultSpinner.Start(fmt.Sprintf("Preparing to export data from %s/%s...", owner, repoName))
30+
31+
// Use the validator to retrieve source repository data
32+
err := mv.RetrieveSourceData(owner, repoName, spinner)
33+
if err != nil {
34+
return fmt.Errorf("failed to retrieve source data for export: %w", err)
35+
}
36+
37+
// Prepare export data
38+
exportData := ExportData{
39+
ExportTimestamp: timestamp,
40+
Repository: *mv.SourceData,
41+
}
42+
43+
// Generate output filename if not provided
44+
if outputFile == "" {
45+
outputFile = generateExportFileName(owner, repoName, format, timestamp)
46+
}
47+
48+
// Export based on format
49+
switch strings.ToLower(format) {
50+
case "json":
51+
err = exportToJSON(exportData, outputFile)
52+
case "csv":
53+
err = exportToCSV(exportData, outputFile)
54+
default:
55+
return fmt.Errorf("unsupported format: %s. Supported formats: json, csv", format)
56+
}
57+
58+
if err != nil {
59+
return fmt.Errorf("failed to export data: %w", err)
60+
}
61+
62+
spinner.Success(fmt.Sprintf("Export completed successfully: %s", outputFile))
63+
fmt.Println()
64+
return nil
65+
}
66+
67+
// generateExportFileName creates a default filename for the export in a .exports directory
68+
func generateExportFileName(owner, repo, format string, timestamp time.Time) string {
69+
timestampStr := timestamp.Format("20060102_150405")
70+
filename := fmt.Sprintf("%s_%s_export_%s.%s", owner, repo, timestampStr, format)
71+
return filepath.Join(".exports", filename)
72+
}
73+
74+
// exportToJSON exports data to JSON format
75+
func exportToJSON(data ExportData, filename string) error {
76+
// Create directory if it doesn't exist
77+
if dir := filepath.Dir(filename); dir != "." {
78+
if err := os.MkdirAll(dir, 0755); err != nil {
79+
return fmt.Errorf("failed to create directory %s: %w", dir, err)
80+
}
81+
}
82+
83+
file, err := os.Create(filename)
84+
if err != nil {
85+
return fmt.Errorf("failed to create JSON file: %w", err)
86+
}
87+
defer file.Close()
88+
89+
encoder := json.NewEncoder(file)
90+
encoder.SetIndent("", " ")
91+
92+
if err := encoder.Encode(data); err != nil {
93+
return fmt.Errorf("failed to encode JSON: %w", err)
94+
}
95+
96+
return nil
97+
}
98+
99+
// exportToCSV exports data to CSV format
100+
func exportToCSV(data ExportData, filename string) error {
101+
// Create directory if it doesn't exist
102+
if dir := filepath.Dir(filename); dir != "." {
103+
if err := os.MkdirAll(dir, 0755); err != nil {
104+
return fmt.Errorf("failed to create directory %s: %w", dir, err)
105+
}
106+
}
107+
108+
file, err := os.Create(filename)
109+
if err != nil {
110+
return fmt.Errorf("failed to create CSV file: %w", err)
111+
}
112+
defer file.Close()
113+
114+
writer := csv.NewWriter(file)
115+
defer writer.Flush()
116+
117+
// Write CSV header
118+
header := []string{
119+
"export_timestamp",
120+
"owner",
121+
"repository_name",
122+
"issues_count",
123+
"pull_requests_open",
124+
"pull_requests_closed",
125+
"pull_requests_merged",
126+
"pull_requests_total",
127+
"tags_count",
128+
"releases_count",
129+
"commits_count",
130+
"latest_commit_sha",
131+
}
132+
if err := writer.Write(header); err != nil {
133+
return fmt.Errorf("failed to write CSV header: %w", err)
134+
}
135+
136+
// Write data row
137+
prOpen, prClosed, prMerged, prTotal := "0", "0", "0", "0"
138+
if data.Repository.PRs != nil {
139+
prOpen = fmt.Sprintf("%d", data.Repository.PRs.Open)
140+
prClosed = fmt.Sprintf("%d", data.Repository.PRs.Closed)
141+
prMerged = fmt.Sprintf("%d", data.Repository.PRs.Merged)
142+
prTotal = fmt.Sprintf("%d", data.Repository.PRs.Total)
143+
}
144+
145+
record := []string{
146+
data.ExportTimestamp.Format(time.RFC3339),
147+
data.Repository.Owner,
148+
data.Repository.Name,
149+
fmt.Sprintf("%d", data.Repository.Issues),
150+
prOpen,
151+
prClosed,
152+
prMerged,
153+
prTotal,
154+
fmt.Sprintf("%d", data.Repository.Tags),
155+
fmt.Sprintf("%d", data.Repository.Releases),
156+
fmt.Sprintf("%d", data.Repository.CommitCount),
157+
data.Repository.LatestCommitSHA,
158+
}
159+
if err := writer.Write(record); err != nil {
160+
return fmt.Errorf("failed to write CSV record: %w", err)
161+
}
162+
163+
return nil
164+
}

0 commit comments

Comments
 (0)