diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml index d2591ea..67318a1 100644 --- a/.github/workflows/release-please.yml +++ b/.github/workflows/release-please.yml @@ -22,3 +22,31 @@ jobs: uses: googleapis/release-please-action@v4 with: release-type: go + + goreleaser: + name: GoReleaser + needs: release-please + if: needs.release-please.outputs.release_created == 'true' + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ needs.release-please.outputs.tag_name }} + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.26" + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v6 + with: + distribution: goreleaser + version: "~> v2" + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.goreleaser.yml b/.goreleaser.yml index f58f0d3..f515e0c 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -48,4 +48,5 @@ release: name: github-pokemon draft: false prerelease: auto + mode: append name_template: "v{{ .Version }}" diff --git a/CLAUDE.md b/CLAUDE.md index 8c85cfe..1b8e2b1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -24,10 +24,14 @@ There are no tests currently in this project. ## Architecture -Single-command Cobra CLI app. All logic lives in two files: +Single-command Cobra CLI app with a `prune-archived` subcommand: - `main.go` — entrypoint, calls `cmd.Execute()` - `cmd/root.go` — CLI flags, GitHub API pagination, worker pool for parallel clone/fetch +- `cmd/config.go` — YAML config file loading (`~/.config/github-pokemon/config.yaml`) +- `cmd/prune_archived.go` — subcommand to remove locally-cloned archived repos +- `cmd/output.go` — progress bar and grouped result display +- `cmd/update.go` — background self-update check The worker pool pattern: `runRootCommand()` creates a buffered channel of repos, spawns `--parallel` (default 5) goroutines via `worker()`, each calling `processRepository()` which either `git clone` or `git fetch --all`. diff --git a/README.md b/README.md index 5262b0b..5f0f21b 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,8 @@ A Go tool for efficiently managing multiple GitHub repositories from an organiza - Quickly clone all non-archived repositories from a GitHub organization - Update existing repositories by fetching remote tracking branches - Process repositories in parallel for better performance +- **Config file support** — define multiple org/path pairs and run with no arguments +- **Prune archived repos** — remove local directories for repositories archived on GitHub - SSH key support for authentication - Safe operations - never modifies local working directories @@ -43,10 +45,10 @@ git clone https://github.com/utahcon/github-pokemon.git cd github-pokemon # Build the binary -go build -o github-repo-manager +go build -o github-pokemon # Optionally move to your path -mv github-repo-manager /usr/local/bin/ +mv github-pokemon /usr/local/bin/ ``` ## Usage @@ -56,47 +58,106 @@ mv github-repo-manager /usr/local/bin/ export GITHUB_TOKEN="your-github-personal-access-token" # Basic usage with required parameters -github-repo-manager --org "organization-name" --path "/path/to/store/repos" +github-pokemon --org "organization-name" --path "/path/to/store/repos" -# Check version -github-repo-manager --version +# Run using a config file (no flags needed) +github-pokemon -# The tool will automatically check if git is installed and if GITHUB_TOKEN is set +# Check version +github-pokemon --version ``` ### Command Line Options ``` Flags: - -h, --help Display help information - -o, --org string GitHub organization to fetch repositories from (required) - -j, --parallel int Number of repositories to process in parallel (default 5) - -p, --path string Local path to clone/update repositories to (required) - -s, --skip-update Skip updating existing repositories - -v, --verbose Enable verbose output - -V, --version Show version information and exit + --config string Path to config file (default: ~/.config/github-pokemon/config.yaml) + -h, --help Display help information + --include-archived Include archived repositories + --no-color Disable colored output + -o, --org string GitHub organization to fetch repositories from + -j, --parallel int Number of repositories to process in parallel (default 5) + -p, --path string Local path to clone/update repositories to + -s, --skip-update Skip updating existing repositories + -v, --verbose Enable verbose output + --version Show version information and exit +``` + +When `--org` and `--path` are both provided, the tool runs in single-org mode. When omitted, it reads from the config file. + +### Config File + +Instead of passing `--org` and `--path` every time, you can create a config file at `~/.config/github-pokemon/config.yaml` (or set `XDG_CONFIG_HOME`): + +```yaml +orgs: + - org: "my-organization" + path: "/home/user/repos/my-org" + - org: "another-org" + path: "/home/user/repos/another-org" + +# Optional defaults (can be overridden by CLI flags) +parallel: 10 +skip_update: false +verbose: true +include_archived: false +no_color: false +``` + +Then simply run: + +```bash +github-pokemon +``` + +All orgs will be processed sequentially. You can also point to a custom config file: + +```bash +github-pokemon --config /path/to/my-config.yaml +``` + +### Subcommands + +#### `prune-archived` + +Remove local directories for repositories that have been archived on GitHub: + +```bash +# Dry-run (default) — shows what would be removed +github-pokemon prune-archived --org "my-org" --path "./repos" + +# Actually remove archived repo directories +github-pokemon prune-archived --org "my-org" --path "./repos" --confirm + +# Or use config file to prune across all orgs +github-pokemon prune-archived +github-pokemon prune-archived --confirm ``` ### Examples ```bash # Clone/fetch with 10 parallel workers -github-repo-manager --org "my-organization" --path "./repos" --parallel 10 +github-pokemon --org "my-organization" --path "./repos" --parallel 10 # Skip updating existing repositories -github-repo-manager --org "my-organization" --path "./repos" --skip-update +github-pokemon --org "my-organization" --path "./repos" --skip-update + +# Include archived repositories +github-pokemon --org "my-organization" --path "./repos" --include-archived # Verbose output with status information -github-repo-manager --org "my-organization" --path "./repos" --verbose +github-pokemon --org "my-organization" --path "./repos" --verbose ``` ## How It Works -1. The tool queries the GitHub API to list all repositories in the specified organization -2. For each non-archived repository: - - If it doesn't exist locally, it clones the repository +1. The tool reads org/path pairs from CLI flags or the config file +2. For each organization, it queries the GitHub API to list all repositories +3. For each non-archived repository (or all repos if `--include-archived`): + - If it doesn't exist locally, it clones the repository via SSH - If it exists locally, it only fetches updates to remote tracking branches -3. Local working directories are never modified - the tool only updates remote tracking information +4. Local working directories are never modified - the tool only updates remote tracking information ## Authentication @@ -136,10 +197,10 @@ export GITHUB_TOKEN="your-github-personal-access-token" mkdir -p ~/github-repos # Clone all repositories from your organization -github-repo-manager --org "your-organization" --path ~/github-repos +github-pokemon --org "your-organization" --path ~/github-repos # Update repositories daily to stay current (could be in a cron job) -github-repo-manager --org "your-organization" --path ~/github-repos +github-pokemon --org "your-organization" --path ~/github-repos # After updating, if you want to update local branches in a specific repository: cd ~/github-repos/specific-repo @@ -159,7 +220,7 @@ You can set up a cron job to automatically update your repositories: crontab -e # Add a line to run the tool daily at 9 AM -0 9 * * * export GITHUB_TOKEN="your-token"; /path/to/github-repo-manager --org "your-organization" --path ~/github-repos +0 9 * * * export GITHUB_TOKEN="your-token"; /path/to/github-pokemon --org "your-organization" --path ~/github-repos ``` ### Troubleshooting @@ -171,7 +232,7 @@ If you encounter errors like "permission denied" or "authentication failed": 1. Verify your GitHub token has the correct permissions 2. Check that your SSH key is properly set up with GitHub 3. Ensure your SSH agent is running: `eval "$(ssh-agent -s)"` -4. Try the verbose flag for more detailed output: `github-repo-manager --org "your-org" --path "./repos" --verbose` +4. Try the verbose flag for more detailed output: `github-pokemon --org "your-org" --path "./repos" --verbose` The tool will automatically detect authentication issues and provide helpful guidance. diff --git a/cmd/config.go b/cmd/config.go new file mode 100644 index 0000000..8f1fc46 --- /dev/null +++ b/cmd/config.go @@ -0,0 +1,161 @@ +package cmd + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" + + "gopkg.in/yaml.v3" +) + +// OrgConfig represents a single organization entry in the config file. +type OrgConfig struct { + Org string `yaml:"org"` + Path string `yaml:"path"` +} + +// Config represents the top-level configuration file structure. +type Config struct { + Orgs []OrgConfig `yaml:"orgs"` + Parallel int `yaml:"parallel,omitempty"` + SkipUpdate bool `yaml:"skip_update,omitempty"` + Verbose bool `yaml:"verbose,omitempty"` + IncludeArchived bool `yaml:"include_archived,omitempty"` + NoColor bool `yaml:"no_color,omitempty"` +} + +// defaultConfigPath returns ~/.config/github-pokemon/config.yaml. +func defaultConfigPath() (string, error) { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + return filepath.Join(xdg, "github-pokemon", "config.yaml"), nil + } + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("determining home directory: %w", err) + } + return filepath.Join(home, ".config", "github-pokemon", "config.yaml"), nil +} + +// loadConfig reads and parses a YAML config file. +func loadConfig(path string) (Config, error) { + data, err := os.ReadFile(path) + if err != nil { + return Config{}, fmt.Errorf("reading config file %s: %w", path, err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return Config{}, fmt.Errorf("parsing config file %s: %w", path, err) + } + + if len(cfg.Orgs) == 0 { + return Config{}, fmt.Errorf("config file %s has no orgs defined", path) + } + + for i, entry := range cfg.Orgs { + if entry.Org == "" { + return Config{}, fmt.Errorf("config entry %d: \"org\" is required", i+1) + } + if entry.Path == "" { + return Config{}, fmt.Errorf("config entry %d: \"path\" is required", i+1) + } + } + + return cfg, nil +} + +// loadOrCreateConfig loads an existing config or returns an empty one if the file doesn't exist. +func loadOrCreateConfig(path string) (Config, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return Config{}, nil + } + return Config{}, fmt.Errorf("reading config file %s: %w", path, err) + } + + var cfg Config + if err := yaml.Unmarshal(data, &cfg); err != nil { + return Config{}, fmt.Errorf("parsing config file %s: %w", path, err) + } + + return cfg, nil +} + +// saveConfig writes the config to disk, creating parent directories as needed. +func saveConfig(path string, cfg Config) error { + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("creating config directory: %w", err) + } + + data, err := yaml.Marshal(&cfg) + if err != nil { + return fmt.Errorf("marshaling config: %w", err) + } + + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("writing config file %s: %w", path, err) + } + + return nil +} + +// configHasOrg returns true if the config already contains the given org/path pair. +func configHasOrg(cfg Config, org, path string) bool { + for _, entry := range cfg.Orgs { + if entry.Org == org && entry.Path == path { + return true + } + } + return false +} + +// configLookupOrg returns the first matching OrgConfig for the given org name, or false if not found. +func configLookupOrg(cfg Config, org string) (OrgConfig, bool) { + for _, entry := range cfg.Orgs { + if entry.Org == org { + return entry, true + } + } + return OrgConfig{}, false +} + +// promptToSaveConfig asks the user if they want to save the org/path to the config file. +// Returns true if the entry was saved. +func promptToSaveConfig(org, path, cfgPath string) bool { + fmt.Printf("\nWould you like to save org %q with path %q to your config file? [y/N] ", org, path) + + reader := bufio.NewReader(os.Stdin) + answer, err := reader.ReadString('\n') + if err != nil { + return false + } + + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer != "y" && answer != "yes" { + return false + } + + cfg, err := loadOrCreateConfig(cfgPath) + if err != nil { + fmt.Printf("Error loading config: %v\n", err) + return false + } + + if configHasOrg(cfg, org, path) { + fmt.Println("Already in config, skipping.") + return false + } + + cfg.Orgs = append(cfg.Orgs, OrgConfig{Org: org, Path: path}) + + if err := saveConfig(cfgPath, cfg); err != nil { + fmt.Printf("Error saving config: %v\n", err) + return false + } + + fmt.Printf("Saved to %s\n", cfgPath) + return true +} diff --git a/cmd/output.go b/cmd/output.go index 01d8cef..ac4d1d9 100644 --- a/cmd/output.go +++ b/cmd/output.go @@ -49,7 +49,7 @@ func actionColor(a repoAction) *color.Color { } } -func collectAndDisplay(resultsCh <-chan repoResult, total int, verboseMode bool, startTime time.Time) (int, bool) { +func collectAndDisplay(resultsCh <-chan repoResult, total int, verboseMode bool, startTime time.Time, org string) (int, bool) { bar := progressbar.NewOptions(total, progressbar.OptionSetWriter(os.Stderr), progressbar.OptionSetDescription("Processing repos"), @@ -79,7 +79,7 @@ func collectAndDisplay(resultsCh <-chan repoResult, total int, verboseMode bool, } printGroupedResults(results, verboseMode) - printSummary(results, total, time.Since(startTime), organization, hadAuth) + printSummary(results, total, time.Since(startTime), org, hadAuth) return errorCount, hadAuth } diff --git a/cmd/prune_archived.go b/cmd/prune_archived.go index f57e6a4..8072c79 100644 --- a/cmd/prune_archived.go +++ b/cmd/prune_archived.go @@ -26,7 +26,7 @@ will be removed. By default runs in dry-run mode so you can preview what would be deleted. Pass --confirm to actually remove directories.`, RunE: func(cmd *cobra.Command, args []string) error { - return runPruneArchived(cmd.Context()) + return runPruneRoot(cmd) }, SilenceUsage: true, } @@ -36,19 +36,87 @@ func init() { pruneArchivedCmd.Flags().StringVarP(&prunePath, "path", "p", "", "Local path containing cloned repositories (required)") pruneArchivedCmd.Flags().BoolVar(&pruneConfirm, "confirm", false, "Actually remove directories (without this flag, runs in dry-run mode)") - _ = pruneArchivedCmd.MarkFlagRequired("org") - _ = pruneArchivedCmd.MarkFlagRequired("path") - rootCmd.AddCommand(pruneArchivedCmd) } -func runPruneArchived(ctx context.Context) error { +// runPruneRoot decides whether to use CLI flags (single org) or the config file (multi-org). +func runPruneRoot(cmd *cobra.Command) error { + ctx := cmd.Context() + + if pruneOrg != "" && prunePath != "" { + return runPruneArchived(ctx, pruneOrg, prunePath) + } + + // If --org is set without --path, look it up in the config. + if pruneOrg != "" && prunePath == "" { + cfgFile := configPath + if cfgFile == "" { + var err error + cfgFile, err = defaultConfigPath() + if err != nil { + return fmt.Errorf("--path is required when no config file is available: %w", err) + } + } + cfg, err := loadOrCreateConfig(cfgFile) + if err != nil { + return fmt.Errorf("--path is required: could not load config: %w", err) + } + entry, found := configLookupOrg(cfg, pruneOrg) + if !found { + return fmt.Errorf("org %q not found in config file %s; provide --path explicitly", pruneOrg, cfgFile) + } + return runPruneArchived(ctx, entry.Org, entry.Path) + } + + // --path without --org doesn't make sense. + if prunePath != "" { + return fmt.Errorf("--org is required when --path is provided") + } + + // No flags — load config file and run all orgs. + cfgFile := configPath + if cfgFile == "" { + var err error + cfgFile, err = defaultConfigPath() + if err != nil { + return fmt.Errorf("no --org/--path flags provided and %w", err) + } + } + + cfg, err := loadConfig(cfgFile) + if err != nil { + return fmt.Errorf("no --org/--path flags provided and config file unavailable: %w", err) + } + + fmt.Printf("Loaded config with %d org(s) from %s\n\n", len(cfg.Orgs), cfgFile) + + var firstErr error + for i, entry := range cfg.Orgs { + if i > 0 { + fmt.Println("---") + fmt.Println() + } + + fmt.Printf("Pruning archived repos for org: %s in %s\n\n", entry.Org, entry.Path) + + if err := runPruneArchived(ctx, entry.Org, entry.Path); err != nil { + fmt.Printf("Error pruning org %s: %v\n", entry.Org, err) + if firstErr == nil { + firstErr = err + } + } + } + + return firstErr +} + +func runPruneArchived(ctx context.Context, org string, path string) error { token := os.Getenv("GITHUB_TOKEN") if token == "" { return fmt.Errorf("GITHUB_TOKEN not set: set it with: export GITHUB_TOKEN=\"your-personal-access-token\"") } - absPath, err := filepath.Abs(prunePath) + absPath, err := filepath.Abs(path) if err != nil { return fmt.Errorf("resolving target path: %w", err) } @@ -60,9 +128,9 @@ func runPruneArchived(ctx context.Context) error { client := github.NewClient(nil).WithAuthToken(token) - allRepos, err := fetchOrgRepos(ctx, client, pruneOrg) + allRepos, err := fetchOrgRepos(ctx, client, org) if err != nil { - return err + return fmt.Errorf("fetching repos for org %s: %w", org, err) } archivedSet := make(map[string]bool) @@ -115,5 +183,9 @@ func runPruneArchived(ctx context.Context) error { fmt.Println() } + if skippedCount > 0 { + return fmt.Errorf("failed to remove %d archived repositories", skippedCount) + } + return nil } \ No newline at end of file diff --git a/cmd/root.go b/cmd/root.go index f8f3ebb..69c9a18 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -34,7 +34,8 @@ var ( verbose bool parallelLimit int includeArchived bool - noColor bool + noColor bool + configPath string ) const maxParallelLimit = 50 @@ -67,10 +68,21 @@ to a specified local path. Use --include-archived to also process archived repos The tool will never modify local branches - it only fetches remote tracking information for existing repositories. +You can specify --org and --path flags for a single organization, or omit them to use a +config file (~/.config/github-pokemon/config.yaml) that supports multiple organizations: + + orgs: + - org: "my-org" + path: "/home/user/repos/my-org" + - org: "other-org" + path: "/home/user/repos/other-org" + parallel: 10 + verbose: true + GitHub API credentials are expected to be in environment variables: - GITHUB_TOKEN: Personal access token for GitHub API`, RunE: func(cmd *cobra.Command, args []string) error { - return runRootCommand(cmd.Context()) + return runRoot(cmd) }, SilenceUsage: true, } @@ -98,9 +110,7 @@ func init() { rootCmd.Flags().BoolVar(&includeArchived, "include-archived", false, "Include archived repositories") rootCmd.Flags().BoolVar(&noColor, "no-color", false, "Disable colored output") - - _ = rootCmd.MarkFlagRequired("org") - _ = rootCmd.MarkFlagRequired("path") + rootCmd.Flags().StringVar(&configPath, "config", "", "Path to config file (default: ~/.config/github-pokemon/config.yaml)") } // isAuthRelated returns true if the output contains authentication-related keywords. @@ -248,31 +258,144 @@ func filterNonArchived(repos []*github.Repository) []*github.Repository { return result } -func runRootCommand(ctx context.Context) error { +// runRoot decides whether to use CLI flags (single org) or the config file (multi-org). +func runRoot(cmd *cobra.Command) error { + ctx := cmd.Context() + + if noColor { + color.NoColor = true + } + + // If --org and --path are provided, run in single-org mode (backward compatible). + if organization != "" && targetPath != "" { + if err := runRootCommand(ctx, organization, targetPath); err != nil { + return err + } + + // Offer to save this org/path to config for future use. + cfgFile := configPath + if cfgFile == "" { + if p, err := defaultConfigPath(); err == nil { + cfgFile = p + } + } + if cfgFile != "" { + cfg, _ := loadOrCreateConfig(cfgFile) + absPath, _ := filepath.Abs(targetPath) + if absPath == "" { + absPath = targetPath + } + if !configHasOrg(cfg, organization, absPath) { + promptToSaveConfig(organization, absPath, cfgFile) + } + } + + return nil + } + + // If --org is set without --path, look it up in the config. + if organization != "" && targetPath == "" { + cfgFile := configPath + if cfgFile == "" { + var err error + cfgFile, err = defaultConfigPath() + if err != nil { + return fmt.Errorf("--path is required when no config file is available: %w", err) + } + } + cfg, err := loadOrCreateConfig(cfgFile) + if err != nil { + return fmt.Errorf("--path is required: could not load config: %w", err) + } + entry, found := configLookupOrg(cfg, organization) + if !found { + return fmt.Errorf("org %q not found in config file %s; provide --path explicitly", organization, cfgFile) + } + return runRootCommand(ctx, entry.Org, entry.Path) + } + + // --path without --org doesn't make sense. + if targetPath != "" { + return fmt.Errorf("--org is required when --path is provided") + } + + // No flags — load config file and run all orgs. + cfgFile := configPath + if cfgFile == "" { + var err error + cfgFile, err = defaultConfigPath() + if err != nil { + return fmt.Errorf("no --org/--path flags provided and %w", err) + } + } + + cfg, err := loadConfig(cfgFile) + if err != nil { + return fmt.Errorf("no --org/--path flags provided and config file unavailable: %w", err) + } + + // Apply config defaults for flags that weren't explicitly set. + if !cmd.Flags().Changed("parallel") && cfg.Parallel > 0 { + parallelLimit = cfg.Parallel + } + if !cmd.Flags().Changed("skip-update") && cfg.SkipUpdate { + skipUpdate = true + } + if !cmd.Flags().Changed("verbose") && cfg.Verbose { + verbose = true + } + if !cmd.Flags().Changed("include-archived") && cfg.IncludeArchived { + includeArchived = true + } + if !cmd.Flags().Changed("no-color") && cfg.NoColor { + noColor = true + } + + fmt.Printf("Loaded config with %d org(s) from %s\n\n", len(cfg.Orgs), cfgFile) + + var firstErr error + for i, entry := range cfg.Orgs { + if i > 0 { + fmt.Println("---") + fmt.Println() + } + + fmt.Printf("Processing org: %s -> %s\n\n", entry.Org, entry.Path) + + if err := runRootCommand(ctx, entry.Org, entry.Path); err != nil { + fmt.Printf("Error processing org %s: %v\n", entry.Org, err) + if firstErr == nil { + firstErr = err + } + } + } + + return firstErr +} + +func runRootCommand(ctx context.Context, org string, path string) error { // Start update check in background (non-blocking, 5s timeout). token := os.Getenv("GITHUB_TOKEN") updateCh := checkForUpdate(ctx, token) defer printUpdateNotice(updateCh) - if noColor { - color.NoColor = true - } - if parallelLimit <= 0 { - parallelLimit = 5 + parallel := parallelLimit + if parallel <= 0 { + parallel = 5 } - if parallelLimit > maxParallelLimit { - return fmt.Errorf("parallel limit %d exceeds maximum of %d", parallelLimit, maxParallelLimit) + if parallel > maxParallelLimit { + return fmt.Errorf("parallel limit %d exceeds maximum of %d", parallel, maxParallelLimit) } if _, err := exec.LookPath("git"); err != nil { return fmt.Errorf("git is not installed or not in PATH: %w", err) } - if err := os.MkdirAll(targetPath, 0755); err != nil { + if err := os.MkdirAll(path, 0755); err != nil { return fmt.Errorf("failed to create target directory: %w", err) } - absTargetPath, err := filepath.Abs(targetPath) + absTargetPath, err := filepath.Abs(path) if err != nil { return fmt.Errorf("resolving target path: %w", err) } @@ -289,9 +412,9 @@ func runRootCommand(ctx context.Context) error { startTime := time.Now() - allRepos, err := fetchOrgRepos(ctx, client, organization) + allRepos, err := fetchOrgRepos(ctx, client, org) if err != nil { - return err + return fmt.Errorf("fetching repos for org %s: %w", org, err) } var repos []*github.Repository @@ -304,7 +427,7 @@ func runRootCommand(ctx context.Context) error { nonArchivedCount := len(filterNonArchived(allRepos)) fmt.Printf("Found %d repositories in organization %s (%d non-archived)\n", - len(allRepos), organization, nonArchivedCount) + len(allRepos), org, nonArchivedCount) if includeArchived { fmt.Printf("Including archived repositories (--include-archived)\n") @@ -315,13 +438,13 @@ func runRootCommand(ctx context.Context) error { return nil } - fmt.Printf("Processing repositories with %d parallel workers\n\n", parallelLimit) + fmt.Printf("Processing repositories with %d parallel workers\n\n", parallel) jobs := make(chan *github.Repository, repoCount) results := make(chan repoResult, repoCount) var wg sync.WaitGroup - for w := 0; w < parallelLimit; w++ { + for w := 0; w < parallel; w++ { wg.Add(1) go func() { defer wg.Done() @@ -339,7 +462,7 @@ func runRootCommand(ctx context.Context) error { close(results) }() - errorCount, _ := collectAndDisplay(results, repoCount, verbose, startTime) + errorCount, _ := collectAndDisplay(results, repoCount, verbose, startTime, org) if errorCount > 0 { return fmt.Errorf("failed to process %d repositories", errorCount) diff --git a/go.mod b/go.mod index 8558561..c0eaead 100644 --- a/go.mod +++ b/go.mod @@ -3,19 +3,20 @@ module github.com/utahcon/github-pokemon go 1.26.1 require ( + github.com/fatih/color v1.18.0 github.com/google/go-github/v84 v84.0.0 + github.com/schollz/progressbar/v3 v3.19.0 github.com/spf13/cobra v1.10.2 + gopkg.in/yaml.v3 v3.0.1 ) require ( - github.com/fatih/color v1.18.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/schollz/progressbar/v3 v3.19.0 // indirect github.com/spf13/pflag v1.0.10 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/term v0.28.0 // indirect diff --git a/go.sum b/go.sum index 2379e6b..0186949 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,8 @@ +github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= +github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= @@ -15,8 +19,12 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -27,6 +35,8 @@ github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiT github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= +github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -34,4 +44,7 @@ golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=