Skip to content

Commit

Permalink
feat: rewriting the CLI using bubbletea and bubbles (#41)
Browse files Browse the repository at this point in the history
* wip

Signed-off-by: Carlos Alexandro Becker <[email protected]>

* wip

Signed-off-by: Carlos Alexandro Becker <[email protected]>

* wip

Signed-off-by: Carlos Alexandro Becker <[email protected]>

* wip

Signed-off-by: Carlos Alexandro Becker <[email protected]>

* wip

Signed-off-by: Carlos Alexandro Becker <[email protected]>

* wip

Signed-off-by: Carlos Alexandro Becker <[email protected]>

* wip

Signed-off-by: Carlos Alexandro Becker <[email protected]>

* wip

Signed-off-by: Carlos Alexandro Becker <[email protected]>

* wip

Signed-off-by: Carlos Alexandro Becker <[email protected]>

* wip

Signed-off-by: Carlos Alexandro Becker <[email protected]>

* remove unused deps

Signed-off-by: Carlos Alexandro Becker <[email protected]>

* docs

Signed-off-by: Carlos Alexandro Becker <[email protected]>

* fix: logger

Signed-off-by: Carlos Alexandro Becker <[email protected]>

* fix: log

Signed-off-by: Carlos Alexandro Becker <[email protected]>

* fix: lint

Signed-off-by: Carlos Alexandro Becker <[email protected]>
  • Loading branch information
caarlos0 authored Jan 14, 2021
1 parent 2271b71 commit 81db547
Show file tree
Hide file tree
Showing 11 changed files with 632 additions and 211 deletions.
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
vendor
.env
fork-cleaner
./fork-cleaner
dist
bin
coverage.out
snap.login
fork-cleaner.log
101 changes: 15 additions & 86 deletions cmd/fork-cleaner/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,11 @@ package main

import (
"context"
"fmt"
"log"
"os"
"time"

"github.com/Songmu/prompter"
forkcleaner "github.com/caarlos0/fork-cleaner"
"github.com/caarlos0/spin"
"github.com/caarlos0/fork-cleaner/internal/ui"
tea "github.com/charmbracelet/bubbletea"
"github.com/google/go-github/v33/github"
"github.com/urfave/cli"
"golang.org/x/oauth2"
Expand Down Expand Up @@ -39,41 +36,19 @@ func main() {
Name: "force, f",
Usage: "Don't ask to remove the forks",
},
cli.BoolFlag{
Name: "include-private, p",
Usage: "Include private repositories",
},
cli.BoolFlag{
Name: "include-forked",
Usage: "Include forked repositories",
},
cli.BoolFlag{
Name: "include-starred",
Usage: "Include starred repositories",
},
cli.BoolFlag{
Name: "exclude-commits-ahead, a",
Usage: "Exclude repositories with commits ahead of parent",
},
cli.BoolFlag{
Name: "show-exclusion-reason",
Usage: "Show the reason a fork is excluded",
},
cli.StringSliceFlag{
Name: "blacklist, exclude, b",
Usage: "Blacklist of repos that shouldn't be removed (names only)",
},
cli.DurationFlag{
Name: "no-activity-since, since",
Usage: "Time to check for activity",
Value: 30 * 24 * time.Hour,
},
}

app.Action = func(c *cli.Context) error {
log.SetFlags(0)
f, err := tea.LogToFile("fork-cleaner.log", "")
if err != nil {
return cli.NewExitError(err.Error(), 1)
}
defer func() { _ = f.Close() }()

token := c.String("token")
ghurl := c.String("github-url")
blacklist := c.StringSlice("blacklist")

ctx := context.Background()
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
tc := oauth2.NewClient(ctx, ts)
Expand All @@ -86,59 +61,13 @@ func main() {
return cli.NewExitError("missing github token", 1)
}

sg := spin.New("\033[36m %s Gathering data...\033[m")
sg.Start()
filter := forkcleaner.Filter{
Blacklist: blacklist,
IncludePrivate: c.Bool("include-private"),
IncludeForked: c.Bool("include-forked"),
IncludeStarred: c.Bool("include-starred"),
Since: c.Duration("since"),
ExcludeCommitsAhead: c.Bool("exclude-commits-ahead"),
}
forks, excludedForks, err := forkcleaner.Find(ctx, client, filter)
sg.Stop()
if err != nil {
var p = tea.NewProgram(ui.NewInitialModel(client))
p.EnterAltScreen()
defer p.ExitAltScreen()
if err = p.Start(); err != nil {
return cli.NewExitError(err.Error(), 1)
}
if c.Bool("show-exclusion-reason") && len(excludedForks) > 0 {
log.Println(len(excludedForks), "forks excluded from deletion:")
for _, f := range excludedForks {
log.Print(f)
}
log.Println()
}
if len(forks) == 0 {
log.Println("0 forks to delete!")
return nil
}
log.Println(len(forks), "forks to delete:")
log.SetPrefix(" --> ")
for _, repo := range forks {
log.Println(*repo.HTMLURL)
}
log.SetPrefix("")

remove := true
if !c.Bool("force") {
remove = prompter.YN("Remove the above listed forks?", false)
}
if !remove {
log.Println("OK, exiting")
return nil
}
fmt.Printf("\n\n")
sd := spin.New(fmt.Sprintf(
"\033[36m %s Deleting %d forks...\033[m", "%s", len(forks),
))
sd.Start()
err = forkcleaner.Delete(ctx, client, forks)
sd.Stop()
if err == nil {
log.Println("Forks removed!")
return nil
}
return cli.NewExitError(err.Error(), 1)
return nil
}

if err := app.Run(os.Args); err != nil {
Expand Down
216 changes: 109 additions & 107 deletions fork-cleaner.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,143 +4,145 @@ package forkcleaner
import (
"context"
"fmt"
"log"
"net/http"
"strings"
"time"

"github.com/google/go-github/v33/github"
)

const pageSize = 100

// Filter applied to the repositories list.
type Filter struct {
Blacklist []string
Since time.Duration
IncludePrivate bool
IncludeStarred bool
IncludeForked bool
ExcludeCommitsAhead bool
type RepositoryWithDetails struct {
Name string
RepoURL string
Private bool
ParentDeleted bool
ParentDMCATakeDown bool
Forks int
Stars int
OpenPRs int
CommitsAhead int
LastUpdate time.Time
}

// Delete delete the given list of forks.
func Delete(
// FindAllForks lists all the forks for the current user.
func FindAllForks(
ctx context.Context,
client *github.Client,
deletions []*github.Repository,
) error {
for _, repo := range deletions {
_, err := client.Repositories.Delete(ctx, *repo.Owner.Login, *repo.Name)
) ([]*RepositoryWithDetails, error) {
var forks []*RepositoryWithDetails
repos, err := getAllRepos(ctx, client)
if err != nil {
return forks, nil
}
for _, r := range repos {
if !r.GetFork() {
continue
}

var login = r.GetOwner().GetLogin()
var name = r.GetName()

// Get repository as List omits parent information.
repo, _, err := client.Repositories.Get(ctx, login, name)
if err != nil {
return forks, fmt.Errorf("failed to get repository: %s: %w", repo.GetFullName(), err)
}

var parent = repo.GetParent()

// get fork's Issues
issues, _, err := client.Issues.ListByRepo(
ctx,
parent.GetOwner().GetLogin(),
parent.GetName(),
&github.IssueListByRepoOptions{
ListOptions: github.ListOptions{
PerPage: pageSize,
},
Creator: login,
},
)
if err != nil {
return err
return forks, fmt.Errorf("failed to get repository's issues: %s: %w", repo.GetFullName(), err)
}

// compare Commits with upstream
commits, resp, err := client.Repositories.CompareCommits(
ctx,
parent.GetOwner().GetLogin(),
parent.GetName(),
parent.GetDefaultBranch(),
fmt.Sprintf("%s:%s", login, repo.GetDefaultBranch()),
)
if err != nil {
return forks, fmt.Errorf("failed to compare repository with upstream: %s: %w", repo.GetFullName(), err)
}

forks = append(forks, buildDetails(repo, issues, commits, resp.StatusCode))
}
return forks, nil
}

func buildDetails(repo *github.Repository, issues []*github.Issue, commits *github.CommitsComparison, code int) *RepositoryWithDetails {
var openPrs int
for _, issue := range issues {
if issue.IsPullRequest() {
openPrs++
}
}
return &RepositoryWithDetails{
Name: repo.GetFullName(),
RepoURL: repo.GetURL(),
Private: repo.GetPrivate(),
ParentDeleted: code == http.StatusNotFound,
ParentDMCATakeDown: code == http.StatusUnavailableForLegalReasons,
Forks: repo.GetForksCount(),
Stars: repo.GetStargazersCount(),
OpenPRs: openPrs,
CommitsAhead: commits.GetAheadBy(),
LastUpdate: repo.GetUpdatedAt().Time,
}
return nil
}

// Find list the forks from a given owner that could be deleted.
func Find(
func getAllRepos(
ctx context.Context,
client *github.Client,
filter Filter,
) ([]*github.Repository, []string, error) {
lopt := github.ListOptions{PerPage: pageSize}
ropt := &github.RepositoryListOptions{
ListOptions: lopt,
) ([]*github.Repository, error) {
var allRepos []*github.Repository
var opts = &github.RepositoryListOptions{
ListOptions: github.ListOptions{PerPage: pageSize},
Affiliation: "owner",
}
iopt := &github.IssueListByRepoOptions{
ListOptions: lopt,
}
var deletions []*github.Repository
var login string
exclusionReasons := make([]string, 0)
for {
repos, resp, err := client.Repositories.List(ctx, "", ropt)
repos, resp, err := client.Repositories.List(ctx, "", opts)
if err != nil {
return deletions, exclusionReasons, err
}
for _, repo := range repos {
if login == "" {
login = repo.GetOwner().GetLogin()
iopt.Creator = login
}
if !repo.GetFork() {
continue
}
rn := repo.GetName()
// Get repository as List omits parent information.
repo, _, err = client.Repositories.Get(ctx, login, rn)
if err != nil {
return deletions, exclusionReasons, err
}
parent := repo.GetParent()
po := parent.GetOwner().GetLogin()
pn := parent.GetName()
issues, _, err := client.Issues.ListByRepo(ctx, po, pn, iopt)
if err != nil {
return deletions, exclusionReasons, err
}
commits, resp, compareErr := client.Repositories.CompareCommits(ctx, po, pn, *parent.DefaultBranch, fmt.Sprintf("%s:%s", login, *repo.DefaultBranch))
if resp.StatusCode == http.StatusNotFound {
exclusionReasons = append(exclusionReasons, fmt.Sprintf("%s excluded because: parent repo doesn't exist anymore\n", *repo.HTMLURL))
continue
}
if resp.StatusCode == http.StatusUnavailableForLegalReasons {
exclusionReasons = append(exclusionReasons, fmt.Sprintf("%s excluded because: DMCA take down\n", *repo.HTMLURL))
continue
}
if compareErr != nil {
return deletions, exclusionReasons, compareErr
}

ok, reason := shouldDelete(repo, filter, issues, commits)
if ok {
deletions = append(deletions, repo)
} else {
exclusionReasons = append(exclusionReasons, reason)
}
return allRepos, err
}
allRepos = append(allRepos, repos...)
if resp.NextPage == 0 {
break
}
ropt.ListOptions.Page = resp.NextPage
opts.ListOptions.Page = resp.NextPage
}
return deletions, exclusionReasons, nil
return allRepos, nil
}

func shouldDelete(
repo *github.Repository,
filter Filter,
issues []*github.Issue,
commitComparison *github.CommitsComparison,
) (bool, string) {
for _, r := range filter.Blacklist {
if r == repo.GetName() {
return false, fmt.Sprintf("%s excluded because: repo is blacklisted\n", *repo.HTMLURL)
}
}
if !filter.IncludePrivate && repo.GetPrivate() {
return false, fmt.Sprintf("%s excluded because: repo is private\n", *repo.HTMLURL)
}
if !filter.IncludeForked && repo.GetForksCount() > 0 {
return false, fmt.Sprintf("%s excluded because: repo has %d forks\n", *repo.HTMLURL, *repo.ForksCount)
}
if !filter.IncludeStarred && repo.GetStargazersCount() > 0 {
return false, fmt.Sprintf("%s excluded because: repo has %d stars\n", *repo.HTMLURL, *repo.StargazersCount)
}
if !time.Now().Add(-filter.Since).After((repo.GetUpdatedAt()).Time) {
return false, fmt.Sprintf("%s excluded because: repo has recent activity (last update on %s)\n", *repo.HTMLURL, repo.GetUpdatedAt().Format("1/2/2006"))
}
for _, issue := range issues {
if issue.IsPullRequest() {
return false, fmt.Sprintf("%s excluded because: repo has a pull request\n", *repo.HTMLURL)
// Delete delete the given list of forks.
func Delete(
ctx context.Context,
client *github.Client,
deletions []*RepositoryWithDetails,
) error {
for _, repo := range deletions {
var parts = strings.Split(repo.Name, "/")
log.Println("deleting repository:", repo.Name)
_, err := client.Repositories.Delete(ctx, parts[0], parts[1])
if err != nil {
return fmt.Errorf("couldn't delete repository: %s: %w", repo.Name, err)
}
}

// check if the fork has commits ahead of the parent repo
if filter.ExcludeCommitsAhead && *commitComparison.AheadBy > 0 {
return false, fmt.Sprintf("%s excluded because: repo is %d commits ahead of parent\n", *repo.HTMLURL, *commitComparison.AheadBy)
}

return true, ""
return nil
}
Loading

0 comments on commit 81db547

Please sign in to comment.