diff --git a/README.md b/README.md index abe5ae5..f635ce5 100644 --- a/README.md +++ b/README.md @@ -5,3 +5,18 @@ GitHub Tools - [gh-purge-artifacts](cmd/gh-purge-artifacts) Purge GitHub Actions artifacts across GitHub repositories - [gh-go-rdeps](cmd/gh-go-rdeps) Find reverse Go dependencies across GitHub repositories - [gh-find](cmd/gh-find) Walk file hierarchies across GitHub repositories + +## Authentication + +All tools require a GitHub access token in order to authenticate API requests and use following methods, in the order of precedence, to infer/set the token: + +- If `-token` flag is used a user will be asked to enter the token interactively +- `GHTOOLS_TOKEN` environment variable +- `GITHUB_TOKEN` environment variable +- `~/.config/gh-tools/auth.yml` file, containing the token + + ```yaml + oauth_token: + ``` + +- `gh cli`'s configuration file diff --git a/auth/token.go b/auth/token.go new file mode 100644 index 0000000..00cad7a --- /dev/null +++ b/auth/token.go @@ -0,0 +1,85 @@ +package auth + +import ( + "os" + + "gopkg.in/yaml.v2" +) + +// GetToken tries to infer the access token +// from environment variables and config files. +func GetToken() string { + var token string + + // gh-tools specific env variable. + if token = os.Getenv("GHTOOLS_TOKEN"); token != "" { + return token + } + // Generic env variable. + if token = os.Getenv("GITHUB_TOKEN"); token != "" { + return token + } + // Read the token from gh-tools auth file ~/.config/gh-tools/auth.yml + if token = fromAuthFile(); token != "" { + return token + } + // Try to read the token from gh cli's config file ~/.config/gh/hosts.yml + if token = fromGhCliConfig(); token != "" { + return token + } + + return "" +} + +func fromAuthFile() string { + path := "/.config/gh-tools/auth.yml" + + home, err := os.UserHomeDir() + if err != nil { + return "" + } + path = home + path + + file, err := os.Open(path) + if err != nil { + return "" + } + + auth := struct { + OauthToken string `yaml:"oauth_token"` + }{} + err = yaml.NewDecoder(file).Decode(auth) + if err != nil { + return "" + } + + return auth.OauthToken +} + +func fromGhCliConfig() string { + path := "/.config/gh/hosts.yml" + + home, err := os.UserHomeDir() + if err != nil { + return "" + } + path = home + path + + file, err := os.Open(path) + if err != nil { + return "" + } + + hosts := map[string]struct { + OauthToken string `yaml:"oauth_token"` + User string `yaml:"user"` + }{} + err = yaml.NewDecoder(file).Decode(hosts) + if err != nil { + return "" + } + + auth := hosts["github.com"] + + return auth.OauthToken +} diff --git a/cmd/gh-find/README.md b/cmd/gh-find/README.md index 79b8c44..2cbed7a 100644 --- a/cmd/gh-find/README.md +++ b/cmd/gh-find/README.md @@ -32,7 +32,7 @@ Flags: ## Environment variables -`GITHUB_TOKEN` shoud be set and contain GitHub personal access token +`GHTOOLS_TOKEN` and `GITHUB_TOKEN` in the order of precedence can be used to set a GitHub access token. ### Examples diff --git a/cmd/gh-find/main.go b/cmd/gh-find/main.go index 6bd0ac2..e6173f9 100644 --- a/cmd/gh-find/main.go +++ b/cmd/gh-find/main.go @@ -12,6 +12,9 @@ import ( "strings" "github.com/google/go-github/v32/github" + "github.com/pmatseykanets/gh-tools/auth" + gh "github.com/pmatseykanets/gh-tools/github" + "github.com/pmatseykanets/gh-tools/terminal" "github.com/pmatseykanets/gh-tools/version" "golang.org/x/oauth2" ) @@ -34,11 +37,9 @@ Flags: -path The pattern to match the pathname -no-path The pattern to reject the pathname -repo The pattern to match repository names + -token Prompt for an Access Token -type File type f - file, d - directory -version Print the version and exit - -Environment variables: - GITHUB_TOKEN an authentication token for github.com API requests ` fmt.Printf("gh-find version %s\n", version.Version) fmt.Println(usage) @@ -69,6 +70,7 @@ type config struct { pathRegexp []*regexp.Regexp // The pattern to match the pathname. noPathRegexp []*regexp.Regexp // The pattern to reject the pathname. grepRegexp *regexp.Regexp // The pattern to match the contents of matching files. + token bool // Propmt for an access token. } type finder struct { @@ -106,8 +108,8 @@ func readConfig() (config, error) { name, path, noName, noPath stringList err error ) - flag.BoolVar(&showHelp, "help", showHelp, "Print this information and exit") flag.StringVar(&config.branch, "branch", "", "Repository branch name if different from the default") + flag.BoolVar(&showHelp, "help", showHelp, "Print this information and exit") flag.StringVar(&grep, "grep", "", "The pattern to match the file contents") flag.IntVar(&config.maxDepth, "maxdepth", 0, "Descend at most n directory levels") flag.IntVar(&config.minDepth, "mindepth", 0, "Descend at least n directory levels") @@ -116,6 +118,7 @@ func readConfig() (config, error) { flag.Var(&path, "path", "The pattern to match the pathname") flag.Var(&noPath, "no-path", "The pattern to reject the pathname") flag.StringVar(&repo, "repo", "", "The pattern to match repository names") + flag.BoolVar(&config.token, "token", config.token, "Prompt for Access Token") flag.StringVar(&config.ftype, "type", "", "File type f - file, d - directory") flag.BoolVar(&showVersion, "version", showVersion, "Print version and exit") flag.Usage = usage @@ -217,22 +220,31 @@ func run(ctx context.Context) error { return err } - ghToken := os.Getenv("GITHUB_TOKEN") - if ghToken == "" { - return fmt.Errorf("GITHUB_TOKEN env variable should be set") + var token string + if finder.config.token { + token, _ = terminal.PasswordPrompt("Access Token: ") + } else { + token = auth.GetToken() + } + if token == "" { + return fmt.Errorf("access token is required") } - finder.gh = github.NewClient( - oauth2.NewClient(ctx, oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: ghToken}), - ), - ) + finder.gh = github.NewClient(oauth2.NewClient(ctx, oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + ))) return finder.find(ctx) } func (f *finder) find(ctx context.Context) error { - repos, err := f.getRepos(ctx) + repoFinder := gh.RepoFinder{ + Client: f.gh, + Owner: f.config.owner, + Repo: f.config.repo, + RepoRegexp: f.config.repoRegexp, + } + repos, err := repoFinder.Find(ctx) if err != nil { return err } @@ -241,15 +253,12 @@ func (f *finder) find(ctx context.Context) error { branch, entryPath, basename string level int ) - // REPOS: for _, repo := range repos { branch = f.config.branch if branch == "" { branch = repo.GetDefaultBranch() } - // fmt.Println(repo.GetFullName(), branch) - tree, resp, err := f.gh.Git.GetTree(ctx, f.config.owner, *repo.Name, branch, true) if err != nil { if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusConflict { @@ -306,12 +315,12 @@ func (f *finder) find(ctx context.Context) error { return err } for _, match := range results.matches { - fmt.Println(repo.GetFullName(), entry.GetPath(), match.lineno, match.line) + fmt.Fprintln(f.stdout, repo.GetFullName(), entry.GetPath(), match.lineno, match.line) } continue nextEntry } - fmt.Println(repo.GetFullName(), entry.GetPath()) + fmt.Fprintln(f.stdout, repo.GetFullName(), entry.GetPath()) } } @@ -333,112 +342,6 @@ func (f *finder) grepContents(ctx context.Context, repo *github.Repository, bran return grep(contents, f.config.grepRegexp) } -func (f *finder) getSingleRepo(ctx context.Context) (*github.Repository, error) { - repo, _, err := f.gh.Repositories.Get(ctx, f.config.owner, f.config.repo) - if err != nil { - return nil, fmt.Errorf("can't read repository: %s", err) - } - - return repo, nil -} - -func (f *finder) getUserRepos(ctx context.Context) ([]*github.Repository, error) { - opt := &github.RepositoryListOptions{ - ListOptions: github.ListOptions{PerPage: 30}, - Affiliation: "owner", - } - var ( - list, repos []*github.Repository - resp *github.Response - err error - ) - for { - repos, resp, err = f.gh.Repositories.List(ctx, f.config.owner, opt) - if err != nil { - return nil, fmt.Errorf("can't read repositories: %s", err) - } - - if f.config.repoRegexp == nil { - list = append(list, repos...) - } else { - for _, repo := range repos { - if f.config.repoRegexp.MatchString(repo.GetName()) { - list = append(list, repo) - } - } - } - - if resp.NextPage == 0 { - break - } - opt.Page = resp.NextPage - } - - return list, nil -} - -func (f *finder) getOrgRepos(ctx context.Context) ([]*github.Repository, error) { - opt := &github.RepositoryListByOrgOptions{ - ListOptions: github.ListOptions{PerPage: 30}, - } - var ( - list, repos []*github.Repository - resp *github.Response - err error - ) - for { - repos, resp, err = f.gh.Repositories.ListByOrg(ctx, f.config.owner, opt) - if err != nil { - return nil, fmt.Errorf("can't read repositories: %s", err) - } - - if f.config.repoRegexp == nil { - list = append(list, repos...) - } else { - for _, repo := range repos { - if f.config.repoRegexp.Match([]byte(repo.GetName())) { - list = append(list, repo) - } - } - } - - if resp.NextPage == 0 { - break - } - opt.Page = resp.NextPage - } - - return list, nil -} - -func (f *finder) getRepos(ctx context.Context) ([]*github.Repository, error) { - owner, _, err := f.gh.Users.Get(ctx, f.config.owner) - if err != nil { - return nil, fmt.Errorf("can't read owner information: %s", err) - } - - // A single repository. - if f.config.repo != "" { - repo, err := f.getSingleRepo(ctx) - if err != nil { - return nil, err - } - return []*github.Repository{repo}, nil - } - - var repos []*github.Repository - switch t := owner.GetType(); t { - case "User": - repos, err = f.getUserRepos(ctx) - case "Organization": - repos, err = f.getOrgRepos(ctx) - default: - err = fmt.Errorf("unknown owner type %s", t) - } - - return repos, err -} - func levels(path string) int { return len(path) - len(strings.ReplaceAll(path, "/", "")) + 1 } diff --git a/cmd/gh-go-rdeps/README.md b/cmd/gh-go-rdeps/README.md index 2269cf8..1a05833 100644 --- a/cmd/gh-go-rdeps/README.md +++ b/cmd/gh-go-rdeps/README.md @@ -17,14 +17,14 @@ Usage: gh-go-rdeps [flags] Flags: -help Print this information and exit - -progress Show the progress - -regexp= Regexp to match repository names + -repo The pattern to match repository names + -token Prompt for an Access Token -version Print the version and exit ``` ## Environment variables -`GITHUB_TOKEN` shoud be set and contain GitHub personal access token +`GHTOOLS_TOKEN` and `GITHUB_TOKEN` in the order of precedence can be used to set a GitHub access token. ### Examples @@ -37,5 +37,5 @@ gh-go-rdeps owner golang.org/x/sync Find all Go repositories that start with `api` and depend on `github.com/owner/library` ```sh -gh-go-rdeps -regexp '^api' owner github.com/owner/library +gh-go-rdeps -repo '^api' owner github.com/owner/library ``` diff --git a/cmd/gh-go-rdeps/main.go b/cmd/gh-go-rdeps/main.go index eb49b64..fe2e671 100644 --- a/cmd/gh-go-rdeps/main.go +++ b/cmd/gh-go-rdeps/main.go @@ -13,6 +13,9 @@ import ( "strings" "github.com/google/go-github/v32/github" + "github.com/pmatseykanets/gh-tools/auth" + gh "github.com/pmatseykanets/gh-tools/github" + "github.com/pmatseykanets/gh-tools/terminal" "github.com/pmatseykanets/gh-tools/version" "golang.org/x/mod/modfile" "golang.org/x/oauth2" @@ -27,28 +30,25 @@ Usage: gh-go-rdeps [flags] Flags: -help Print this information and exit - -progress Show the progress - -regexp= Regexp to match repository names + -repo The pattern to match repository names + -token Prompt for an Access Token -version Print the version and exit - -Environment variables: - GITHUB_TOKEN an authentication token for github.com API requests ` fmt.Println(usage) } func main() { if err := run(context.Background()); err != nil { - fmt.Fprintf(os.Stderr, "error: %s\n", err) + fmt.Printf("error: %s\n", err) os.Exit(1) } } type config struct { - owner string - modpath string - regexp *regexp.Regexp - progress bool + owner string + modpath string + repoRegexp *regexp.Regexp + token bool // Propmt for an access token. } type finder struct { @@ -68,13 +68,13 @@ func readConfig() (config, error) { var ( showVersion, showHelp bool - nameRegExp string + repo string err error ) flag.BoolVar(&showHelp, "help", showHelp, "Print this information and exit") - flag.BoolVar(&config.progress, "progress", config.progress, "Show progress") - flag.StringVar(&nameRegExp, "regexp", "", "Regexp to match repository names") + flag.StringVar(&repo, "repo", "", "The pattern to match repository names") + flag.BoolVar(&config.token, "token", config.token, "Prompt for Access Token") flag.BoolVar(&showVersion, "version", showVersion, "Print version and exit") flag.Usage = usage flag.Parse() @@ -105,10 +105,10 @@ func readConfig() (config, error) { return config, fmt.Errorf("mod path can't be empty") } - if nameRegExp != "" { - config.regexp, err = regexp.Compile(nameRegExp) + if repo != "" { + config.repoRegexp, err = regexp.Compile(repo) if err != nil { - return config, fmt.Errorf("invalid name pattern: %s", err) + return config, fmt.Errorf("invalid repo pattern: %s: %s", repo, err) } } @@ -127,22 +127,30 @@ func run(ctx context.Context) error { return err } - ghToken := os.Getenv("GITHUB_TOKEN") - if ghToken == "" { - return fmt.Errorf("GITHUB_TOKEN env variable should be set") + var token string + if finder.config.token { + token, _ = terminal.PasswordPrompt("Access Token: ") + } else { + token = auth.GetToken() + } + if token == "" { + return fmt.Errorf("access token is required") } - finder.gh = github.NewClient( - oauth2.NewClient(ctx, oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: ghToken}), - ), - ) + finder.gh = github.NewClient(oauth2.NewClient(ctx, oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + ))) return finder.find(ctx) } func (f *finder) find(ctx context.Context) error { - repos, err := f.getRepos(ctx) + repoFinder := gh.RepoFinder{ + Client: f.gh, + Owner: f.config.owner, + RepoRegexp: f.config.repoRegexp, + } + repos, err := repoFinder.Find(ctx) if err != nil { return err } @@ -158,12 +166,8 @@ func (f *finder) find(ctx context.Context) error { gopkgProject GopkgProject dependencies []string ) -repos: +nextRepo: for _, repo = range repos { - if f.config.progress { - fmt.Fprint(f.stderr, ".") - } - goRepo, err = f.goRepo(ctx, repo) if err != nil { return err @@ -188,17 +192,17 @@ repos: for _, require = range mod.Require { if strings.HasPrefix(require.Mod.Path, f.config.modpath) { dependencies = append(dependencies, mod.Module.Mod.Path) - continue repos + continue nextRepo } } for _, replace = range mod.Replace { if strings.HasPrefix(replace.Old.Path, f.config.modpath) || strings.HasPrefix(replace.New.Path, f.config.modpath) { dependencies = append(dependencies, mod.Module.Mod.Path) - continue repos + continue nextRepo } } - continue repos + continue nextRepo } // Gopkg.toml. @@ -208,7 +212,7 @@ repos: } if len(contents) == 0 { - continue repos + continue nextRepo } gopkg, err = parseGopkg(bytes.NewReader(contents)) @@ -220,23 +224,20 @@ repos: if strings.HasPrefix(gopkgProject.Name, f.config.modpath) || strings.HasPrefix(gopkgProject.Source, f.config.modpath) { dependencies = append(dependencies, fmt.Sprintf("github.com/%s/%s", f.config.owner, repo.GetName())) - continue repos + continue nextRepo } } for _, gopkgProject = range gopkg.Overrides { if strings.HasPrefix(gopkgProject.Name, f.config.modpath) || strings.HasPrefix(gopkgProject.Source, f.config.modpath) { dependencies = append(dependencies, fmt.Sprintf("github.com/%s/%s", f.config.owner, repo.GetName())) - continue repos + continue nextRepo } } } sort.Strings(dependencies) - if f.config.progress { - fmt.Fprintln(f.stderr) - } for _, dependency := range dependencies { fmt.Fprintln(f.stdout, dependency) } @@ -280,83 +281,3 @@ func (f *finder) goRepo(ctx context.Context, repo *github.Repository) (bool, err return false, nil } - -func (f *finder) getUserRepos(ctx context.Context) ([]*github.Repository, error) { - opt := &github.RepositoryListOptions{ - ListOptions: github.ListOptions{PerPage: 30}, - Affiliation: "owner", - } - var list []*github.Repository - for { - repos, resp, err := f.gh.Repositories.List(ctx, f.config.owner, opt) - if err != nil { - return nil, fmt.Errorf("can't read repositories: %s", err) - } - - if f.config.regexp == nil { - list = append(list, repos...) - } else { - for _, repo := range repos { - if f.config.regexp.Match([]byte(repo.GetName())) { - list = append(list, repo) - } - } - } - - if resp.NextPage == 0 { - break - } - opt.Page = resp.NextPage - } - - return list, nil -} - -func (f *finder) getOrgRepos(ctx context.Context) ([]*github.Repository, error) { - opt := &github.RepositoryListByOrgOptions{ - ListOptions: github.ListOptions{PerPage: 30}, - } - var list []*github.Repository - for { - repos, resp, err := f.gh.Repositories.ListByOrg(ctx, f.config.owner, opt) - if err != nil { - return nil, fmt.Errorf("can't read repositories: %s", err) - } - - if f.config.regexp == nil { - list = append(list, repos...) - } else { - for _, repo := range repos { - if f.config.regexp.Match([]byte(repo.GetName())) { - list = append(list, repo) - } - } - } - - if resp.NextPage == 0 { - break - } - opt.Page = resp.NextPage - } - - return list, nil -} - -func (f *finder) getRepos(ctx context.Context) ([]*github.Repository, error) { - owner, _, err := f.gh.Users.Get(ctx, f.config.owner) - if err != nil { - return nil, fmt.Errorf("can't read owner information: %s", err) - } - - var repos []*github.Repository - switch t := owner.GetType(); t { - case "User": - repos, err = f.getUserRepos(ctx) - case "Organization": - repos, err = f.getOrgRepos(ctx) - default: - err = fmt.Errorf("unknown owner type %s", t) - } - - return repos, err -} diff --git a/cmd/gh-purge-artifacts/README.md b/cmd/gh-purge-artifacts/README.md index d9e83e7..ad9abcb 100644 --- a/cmd/gh-purge-artifacts/README.md +++ b/cmd/gh-purge-artifacts/README.md @@ -18,13 +18,14 @@ Usage: gh-purge-artifacts [flags] [owner][/repo] Flags: -help Print this information and exit -dry-run Dry run - -regexp= Regexp to match repository names + -repo The pattern to match repository names + -token Prompt for an Access Token -version Print the version and exit ``` ## Environment variables -`GITHUB_TOKEN` shoud be set and contain GitHub personal access token +`GHTOOLS_TOKEN` and `GITHUB_TOKEN` in the order of precedence can be used to set a GitHub access token. ### Examples @@ -43,7 +44,7 @@ gh-purge-artifacts owner Purge artifacts in repositories starting with 'api' ```sh -gh-purge-artifacts -regexp '^api' owner +gh-purge-artifacts -repo '^api' owner ``` Dry-run mode. List found atifacts but don't purge. diff --git a/cmd/gh-purge-artifacts/main.go b/cmd/gh-purge-artifacts/main.go index db48ec0..b58acf1 100644 --- a/cmd/gh-purge-artifacts/main.go +++ b/cmd/gh-purge-artifacts/main.go @@ -4,11 +4,15 @@ import ( "context" "flag" "fmt" + "io" "os" "regexp" "strings" "github.com/google/go-github/v32/github" + "github.com/pmatseykanets/gh-tools/auth" + gh "github.com/pmatseykanets/gh-tools/github" + "github.com/pmatseykanets/gh-tools/terminal" "github.com/pmatseykanets/gh-tools/version" "golang.org/x/oauth2" ) @@ -23,11 +27,9 @@ Usage: gh-purge-artifacts [flags] [owner][/repo] Flags: -help Print this information and exit -dry-run Dry run - -regexp= Regexp to match repository names + -repo The pattern to match repository names + -token Prompt for an Access Token -version Print the version and exit - -Environment variables: - GITHUB_TOKEN an authentication token for github.com API requests ` fmt.Println(usage) } @@ -40,15 +42,18 @@ func main() { } type config struct { - owner string - repo string - regexp *regexp.Regexp - dryRun bool + owner string + repo string + repoRegexp *regexp.Regexp + dryRun bool + token bool // Propmt for an access token. } type purger struct { gh *github.Client config config + stdout io.WriteCloser + stderr io.WriteCloser } func readConfig() (config, error) { @@ -59,13 +64,15 @@ func readConfig() (config, error) { config := config{} - var showVersion, showHelp bool - var nameRegExp string - var err error - + var ( + showVersion, showHelp bool + repo string + err error + ) flag.BoolVar(&config.dryRun, "dry-run", config.dryRun, "Dry run") flag.BoolVar(&showHelp, "help", showHelp, "Print this information and exit") - flag.StringVar(&nameRegExp, "regexp", "", "Regexp to match repository names") + flag.StringVar(&repo, "repo", "", "The pattern to match repository names") + flag.BoolVar(&config.token, "token", config.token, "Prompt for Access Token") flag.BoolVar(&showVersion, "version", showVersion, "Print version and exit") flag.Usage = usage flag.Parse() @@ -96,10 +103,10 @@ func readConfig() (config, error) { return config, fmt.Errorf("owner is required") } - if nameRegExp != "" { - config.regexp, err = regexp.Compile(nameRegExp) + if repo != "" { + config.repoRegexp, err = regexp.Compile(repo) if err != nil { - return config, fmt.Errorf("invalid name pattern: %s", err) + return config, fmt.Errorf("invalid name pattern: %s: %s", repo, err) } } @@ -108,28 +115,41 @@ func readConfig() (config, error) { func run(ctx context.Context) error { var err error - purger := &purger{} + + purger := &purger{ + stdout: os.Stdout, + stderr: os.Stderr, + } purger.config, err = readConfig() if err != nil { return err } - ghToken := os.Getenv("GITHUB_TOKEN") - if ghToken == "" { - return fmt.Errorf("GITHUB_TOKEN env variable should be set") + var token string + if purger.config.token { + token, _ = terminal.PasswordPrompt("Access Token: ") + } else { + token = auth.GetToken() + } + if token == "" { + return fmt.Errorf("access token is required") } - purger.gh = github.NewClient( - oauth2.NewClient(ctx, oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: ghToken}), - ), - ) + purger.gh = github.NewClient(oauth2.NewClient(ctx, oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + ))) return purger.purge(ctx) } func (p *purger) purge(ctx context.Context) error { - repos, err := p.getRepos(ctx) + repoFinder := gh.RepoFinder{ + Client: p.gh, + Owner: p.config.owner, + Repo: p.config.repo, + RepoRegexp: p.config.repoRegexp, + } + repos, err := repoFinder.Find(ctx) if err != nil { return err } @@ -145,116 +165,18 @@ func (p *purger) purge(ctx context.Context) error { } if totalRepos := len(repos); totalRepos > 1 { - fmt.Printf("Total:") + fmt.Fprintf(p.stdout, "Total:") if p.config.dryRun { - fmt.Printf(" found") + fmt.Fprintf(p.stdout, " found") } else { - fmt.Printf(" purged") + fmt.Fprintf(p.stdout, " purged") } - fmt.Printf(" %d artifacts (%s) in %d repos\n", totalDeleted, formatSize(totalSize), totalRepos) + fmt.Fprintf(p.stdout, " %d artifacts (%s) in %d repos\n", totalDeleted, formatSize(totalSize), totalRepos) } return nil } -func (p *purger) getSingleRepo(ctx context.Context) (*github.Repository, error) { - repo, _, err := p.gh.Repositories.Get(ctx, p.config.owner, p.config.repo) - if err != nil { - return nil, fmt.Errorf("can't read repository: %s", err) - } - - return repo, nil -} - -func (p *purger) getUserRepos(ctx context.Context) ([]*github.Repository, error) { - opt := &github.RepositoryListOptions{ - ListOptions: github.ListOptions{PerPage: 30}, - Affiliation: "owner", - } - var list []*github.Repository - for { - repos, resp, err := p.gh.Repositories.List(ctx, p.config.owner, opt) - if err != nil { - return nil, fmt.Errorf("can't read repositories: %s", err) - } - - if p.config.regexp == nil { - list = append(list, repos...) - } else { - for _, repo := range repos { - if p.config.regexp.Match([]byte(repo.GetName())) { - list = append(list, repo) - } - } - } - - if resp.NextPage == 0 { - break - } - opt.Page = resp.NextPage - } - - return list, nil -} - -func (p *purger) getOrgRepos(ctx context.Context) ([]*github.Repository, error) { - opt := &github.RepositoryListByOrgOptions{ - ListOptions: github.ListOptions{PerPage: 30}, - } - var list []*github.Repository - for { - repos, resp, err := p.gh.Repositories.ListByOrg(ctx, p.config.owner, opt) - if err != nil { - return nil, fmt.Errorf("can't read repositories: %s", err) - } - - if p.config.regexp == nil { - list = append(list, repos...) - } else { - for _, repo := range repos { - if p.config.regexp.Match([]byte(repo.GetName())) { - list = append(list, repo) - } - } - } - - if resp.NextPage == 0 { - break - } - opt.Page = resp.NextPage - } - - return list, nil -} - -func (p *purger) getRepos(ctx context.Context) ([]*github.Repository, error) { - owner, _, err := p.gh.Users.Get(ctx, p.config.owner) - if err != nil { - return nil, fmt.Errorf("can't read owner information: %s", err) - } - - // A single repository. - if p.config.repo != "" { - repo, err := p.getSingleRepo(ctx) - if err != nil { - return nil, err - } - return []*github.Repository{repo}, nil - } - - var repos []*github.Repository - switch t := owner.GetType(); t { - case "User": - repos, err = p.getUserRepos(ctx) - case "Organization": - repos, err = p.getOrgRepos(ctx) - default: - err = fmt.Errorf("unknown owner type %s", t) - } - - return repos, err -} - func (p *purger) purgeRepoArtifacts(ctx context.Context, repo *github.Repository) (int64, int64, error) { owner := repo.GetOwner().GetLogin() name := repo.GetName() @@ -275,19 +197,19 @@ func (p *purger) purgeRepoArtifacts(ctx context.Context, repo *github.Repository opt.Page = resp.NextPage } - fmt.Printf("%s/%s", owner, name) + fmt.Fprintf(p.stdout, "%s/%s", owner, name) var deleted, size int64 defer func() { if deleted > 0 { if p.config.dryRun { - fmt.Printf(" found") + fmt.Fprintf(p.stdout, " found") } else { - fmt.Printf(" purged") + fmt.Fprintf(p.stdout, " purged") } - fmt.Printf(" %d out of %d artifacts (%s)", len(artifacts), deleted, formatSize(size)) + fmt.Fprintf(p.stdout, " %d out of %d artifacts (%s)", len(artifacts), deleted, formatSize(size)) } - fmt.Println() + fmt.Fprintln(p.stdout) }() for _, artifact := range artifacts { if !p.config.dryRun { diff --git a/github/repo.go b/github/repo.go new file mode 100644 index 0000000..4272601 --- /dev/null +++ b/github/repo.go @@ -0,0 +1,122 @@ +package github + +import ( + "context" + "fmt" + "regexp" + + "github.com/google/go-github/v32/github" +) + +type RepoFinder struct { + Client *github.Client + Owner string + Repo string + RepoRegexp *regexp.Regexp +} + +func (f *RepoFinder) Find(ctx context.Context) ([]*github.Repository, error) { + owner, _, err := f.Client.Users.Get(ctx, f.Owner) + if err != nil { + return nil, fmt.Errorf("can't read owner information: %s", err) + } + + // A single repository. + if f.Repo != "" { + repo, err := f.singleRepo(ctx) + if err != nil { + return nil, err + } + return []*github.Repository{repo}, nil + } + + var repos []*github.Repository + switch t := owner.GetType(); t { + case "User": + repos, err = f.userRepos(ctx) + case "Organization": + repos, err = f.orgRepos(ctx) + default: + err = fmt.Errorf("unknown owner type %s", t) + } + + return repos, err +} + +func (f *RepoFinder) singleRepo(ctx context.Context) (*github.Repository, error) { + repo, _, err := f.Client.Repositories.Get(ctx, f.Owner, f.Repo) + if err != nil { + return nil, fmt.Errorf("can't read repository: %s", err) + } + + return repo, nil +} + +func (f *RepoFinder) userRepos(ctx context.Context) ([]*github.Repository, error) { + opt := &github.RepositoryListOptions{ + ListOptions: github.ListOptions{PerPage: 30}, + Affiliation: "owner", + } + var ( + list, repos []*github.Repository + resp *github.Response + err error + ) + for { + repos, resp, err = f.Client.Repositories.List(ctx, f.Owner, opt) + if err != nil { + return nil, fmt.Errorf("can't read repositories: %s", err) + } + + if f.RepoRegexp == nil { + list = append(list, repos...) + } else { + for _, repo := range repos { + if f.RepoRegexp.MatchString(repo.GetName()) { + list = append(list, repo) + } + } + } + + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + + return list, nil +} + +func (f *RepoFinder) orgRepos(ctx context.Context) ([]*github.Repository, error) { + opt := &github.RepositoryListByOrgOptions{ + ListOptions: github.ListOptions{PerPage: 30}, + } + var ( + list, repos []*github.Repository + resp *github.Response + err error + ) + for { + repos, resp, err = f.Client.Repositories.ListByOrg(ctx, f.Owner, opt) + if err != nil { + return nil, fmt.Errorf("can't read repositories: %s", err) + } + + if f.RepoRegexp == nil { + list = append(list, repos...) + } else { + for _, repo := range repos { + if f.RepoRegexp.Match([]byte(repo.GetName())) { + list = append(list, repo) + } + } + } + + if resp.NextPage == 0 { + break + } + opt.Page = resp.NextPage + } + + return list, nil +} diff --git a/go.mod b/go.mod index faa73dd..c22716a 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,8 @@ go 1.14 require ( github.com/google/go-github/v32 v32.1.0 github.com/pelletier/go-toml v1.8.1 + golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 golang.org/x/mod v0.3.0 golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58 + gopkg.in/yaml.v2 v2.2.2 ) diff --git a/go.sum b/go.sum index 55ddb24..0eb6b63 100644 --- a/go.sum +++ b/go.sum @@ -196,8 +196,6 @@ golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4Iltr golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43 h1:ld7aEMNHoBnnDAX15v1T6z31v8HwR2A9FYOuAhWqkwc= -golang.org/x/oauth2 v0.0.0-20200902213428-5d25da1a8d43/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58 h1:Mj83v+wSRNEar42a/MQgxk9X42TdEmrOl9i+y8WbxLo= golang.org/x/oauth2 v0.0.0-20201109201403-9fd604954f58/go.mod h1:KelEdhl1UZF7XfJ4dDtk6s++YSgaE7mD/BuKKDLBl4A= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -232,6 +230,7 @@ golang.org/x/sys v0.0.0-20200501052902-10377860bb8e/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200511232937-7e40ca221e25/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200523222454-059865788121/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200803210538-64077c9b5642 h1:B6caxRw+hozq68X2MY7jEpZh/cr4/aHLv9xU8Kkadrw= golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= @@ -368,6 +367,7 @@ gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8 gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/terminal/terminal.go b/terminal/terminal.go new file mode 100644 index 0000000..240296b --- /dev/null +++ b/terminal/terminal.go @@ -0,0 +1,56 @@ +package terminal + +import ( + "fmt" + "os" + "os/signal" + "syscall" + + "golang.org/x/crypto/ssh/terminal" +) + +// PasswordPrompt reads the password from the terminal. +// It resets terminal echo after ^C interrupts. +// Source: https://gist.github.com/jlinoff/e8e26b4ffa38d379c7f1891fd174a6d0 +func PasswordPrompt(prompt ...string) (string, error) { + // Get the initial state of the terminal. + state, err := terminal.GetState(syscall.Stdin) + if err != nil { + return "", err + } + + // Restore the state in the event of an interrupt. + // See: https://groups.google.com/forum/#!topic/golang-nuts/kTVAbtee9UA + c := make(chan os.Signal) + q := make(chan struct{}) + signal.Notify(c, os.Interrupt, os.Interrupt, syscall.SIGTERM) + go func() { + select { + case <-c: + _ = terminal.Restore(syscall.Stdin, state) + fmt.Println() + os.Exit(1) + case <-q: + return + } + }() + + text := "Password: " + if len(prompt) > 0 { + text = prompt[0] + } + fmt.Print(text) + + password, err := terminal.ReadPassword(syscall.Stdin) + fmt.Println() + if err != nil { + return "", err + } + + // Stop looking for ^C on the channel. + signal.Stop(c) + close(q) + + // Return the password as a string. + return string(password), nil +} diff --git a/version/version.go b/version/version.go index 3494801..984dd89 100644 --- a/version/version.go +++ b/version/version.go @@ -1,3 +1,3 @@ package version -var Version = "0.3.0" +var Version = "0.4.0"