diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..96faaf1 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: gomod + directory: / + schedule: + interval: weekly + + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/workflows/automerge.yml b/.github/workflows/automerge.yml new file mode 100644 index 0000000..67a73d9 --- /dev/null +++ b/.github/workflows/automerge.yml @@ -0,0 +1,17 @@ +name: Dependabot auto-merge + +on: pull_request + +permissions: + contents: write + +jobs: + dependabot: + runs-on: ubuntu-latest + if: ${{ github.actor == 'dependabot[bot]' }} + steps: + - name: Enable auto-merge for Dependabot PRs + run: gh pr merge --auto --merge "$PR_URL" + env: + PR_URL: ${{github.event.pull_request.html_url}} + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..ae4c186 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,41 @@ +name: test +on: + push: + pull_request: + +permissions: + contents: read + # Optional: allow read access to pull request. Use with `only-new-issues` option. + # pull-requests: read + +jobs: + test: + strategy: + matrix: + os: [ubuntu-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-go@v5 + with: + go-version: "1.22" + cache: true + + - name: Install dependencies + run: make init + + - name: Lint + run: make lint-check + + - name: check-is-dirty + run: | + if [[ -n $(git status --porcelain) ]]; then + echo "Detected uncommitted changes." + git status + git diff + exit 1 + fi + + - name: Run tests + run: make test diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d2100fa --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,32 @@ +name: goreleaser + +on: + release: + types: [released, prereleased] + +permissions: + contents: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Set up Go + uses: actions/setup-go@v5 + with: + go-version: "1.22" + cache: true + + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v5 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f9bee2f --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +./fork-sweeper +.DS_Store diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..a09114e --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,53 @@ +# This is an example .goreleaser.yml file with some sensible defaults. +# Make sure to check the documentation at https://goreleaser.com +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - windows + - darwin + goarch: + - amd64 + - arm64 + - "386" + main: ./cmd/fork-sweeper + +archives: + - format: tar.gz + # this name template makes the OS and Arch compatible with the results of uname. + name_template: >- + {{ .ProjectName }}_ + {{- title .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}_{{ .Version }}{{ end }} + # use zip for windows archives + format_overrides: + - goos: windows + format: zip + +checksum: + name_template: "checksums.txt" + +snapshot: + name_template: "{{ incpatch .Version }}-next" + +changelog: + sort: asc + filters: + exclude: + - "^docs:" + - "^test:" + +brews: + - name: link-patrol + homepage: "https://github.com/rednafi/fork-sweeper" + description: Remove unused GitHub forks + repository: + owner: rednafi + name: fork-sweeper + commit_author: + name: rednafi + email: redowan.nafi@gmail.com diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..a7fbe38 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,15 @@ +public/ +static/ +layouts/ +themes/ + +# Ignore +**/*.html +**/Makefile +.editorconfig +.prettierignore +.git* +.hugo* +Brew* +pyproject* +requirements* diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..de2042c --- /dev/null +++ b/.prettierrc @@ -0,0 +1,24 @@ +{ + "proseWrap": "preserve", + "semi": false, + "singleQuote": false, + "useTabs": false, + "tabWidth": 2, + "trailingComma": "all", + "printWidth": 92, + "overrides": [ + { + "files": "**/*.md", + "options": { + "tabWidth": 4, + "useTabs": false, + "singleQuote": false, + "trailingComma": "all", + "arrowParens": "avoid", + "printWidth": 92, + "proseWrap": "always", + "embeddedLanguageFormatting": "off" + } + } + ] +} diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..df1cbcf --- /dev/null +++ b/Makefile @@ -0,0 +1,38 @@ +.PHONY: all build clean test lint ci + +# Binary name for the output binary +BINARY_NAME=fork-sweeper + +# Default command to run when no arguments are provided to make +all: build + +# Builds the binary +build: + @echo "Building..." + go build -C cmd/fork-sweeper -o ../../${BINARY_NAME} + +# Cleans our project: deletes binaries +clean: + @echo "Cleaning..." + go clean + rm -f ${BINARY_NAME} + +# Runs tests +test: + @echo "Running tests..." + go test ./... -cover + +# Lints the project +lint: + @echo "Linting..." + go fmt ./... + go vet ./... + go mod tidy + +# Command for Continuous Integration +ci: lint test + @echo "CI steps..." + # Add commands specific to your CI setup + # e.g., integration testing, deployment commands, etc. + +# Additional commands can be added below for database migrations, Docker operations, etc. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b3c3794 --- /dev/null +++ b/README.md @@ -0,0 +1,81 @@ +
+
+

+fork +sweeper +| +\|/ +-|- +// \\ +/// \\\ +//// \\\\ +

+

+Remove unused GitHub forks +

+
+
+ +## Installation + +- On macOS, brew install: + + ```sh + brew install fork-sweeper + ``` + +- Elsewhere, go install: + + ```sh + go install fork-sweeper + ``` + +## Prerequisites + +- Collect your GitHub API [access token]. The token will be sent as `Bearer ` in + the HTTP header while making API requests. +- The token must have write and delete access to the forked repos. +- Set the `GITHUB_TOKEN` to your current shell environment with + `export GITHUB_TOKEN=` command. + +## Usage + +- Run help: + + ```sh + fork-sweeper -h + ``` + + ```txt + Usage of fork-sweeper: + -delete + Delete forked repos + -max-page int + Maximum page number to fetch (default 100) + -older-than-days int + Delete forked repos older than this number of days (default 60) + -owner string + GitHub repo owner (required) + -per-page int + Number of forked repos fetched per page (default 100) + -token string + GitHub access token (required) + -version + ``` + +- List forked repos older than `n` days. By default it'll fetch all reposties that were + forked at least 60 days ago. + + ```sh + fork-sweeper --owner rednafi --token $GITHUB_TOKEN --older-than-days 60 + ``` + +- The CLI won't delete any repository unless you explicitly tell it to do so with the + `--delete` flag: + + ```sh + fork-sweeper --owner rednafi --token $GITHUB_TOKEN --delete + ``` + +[access token]: + https://docs.github.com/en/rest/authentication/authenticating-to-the-rest-api?apiVersion=2022-11-28 diff --git a/cmd/fork-pruner/main.go b/cmd/fork-pruner/main.go deleted file mode 100644 index fc72808..0000000 --- a/cmd/fork-pruner/main.go +++ /dev/null @@ -1,10 +0,0 @@ -package main - -import ( - - "github.com/rednafi/fork-pruner/src" -) - -func main() { - src.CLI() -} diff --git a/cmd/fork-sweeper/main.go b/cmd/fork-sweeper/main.go new file mode 100644 index 0000000..8ad6181 --- /dev/null +++ b/cmd/fork-sweeper/main.go @@ -0,0 +1,19 @@ +package main + +import ( + "github.com/rednafi/fork-sweeper/src" + "os" + "text/tabwriter" +) + +// Ldflags filled by goreleaser +var version = "dev" + +func main() { + w := tabwriter.NewWriter(os.Stdout, 0, 0, 4, ' ', 0) + defer w.Flush() + + cliConfig := src.NewCLIConfig(w, version, os.Exit) + + cliConfig.CLI(os.Args[1:]) +} diff --git a/go.mod b/go.mod index 1559e91..ec8d57b 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module github.com/rednafi/fork-pruner +module github.com/rednafi/fork-sweeper go 1.22.0 diff --git a/src/cli.go b/src/cli.go index c072e1d..0502b01 100644 --- a/src/cli.go +++ b/src/cli.go @@ -5,6 +5,7 @@ import ( "encoding/json" "flag" "fmt" + "io" "net/http" "os" "sync" @@ -29,7 +30,11 @@ type repo struct { CreatedAt time.Time `json:"created_at"` } -var httpClient = &http.Client{Timeout: 10 * time.Second} +var httpClientPool = sync.Pool{ + New: func() any { + return &http.Client{Timeout: 10 * time.Second} + }, +} func fetchForkedReposPage( ctx context.Context, @@ -37,7 +42,8 @@ func fetchForkedReposPage( owner, token string, pageNum, - perPage int) ([]repo, error) { + perPage, + olderThanDays int) ([]repo, error) { url := fmt.Sprintf( "%s/users/%s/repos?type=forks&page=%d&per_page=%d", @@ -59,8 +65,11 @@ func fetchForkedReposPage( } var forkedRepos []repo + + cutOffDate := time.Now().AddDate(0, 0, -olderThanDays) + for _, repo := range repos { - if repo.IsFork { + if repo.IsFork && repo.CreatedAt.Before(cutOffDate) { forkedRepos = append(forkedRepos, repo) } } @@ -74,11 +83,20 @@ func fetchForkedRepos( owner, token string, perPage, - maxPage int) ([]repo, error) { + maxPage, + olderThanDays int) ([]repo, error) { var allRepos []repo for pageNum := 1; pageNum <= maxPage; pageNum++ { - repos, err := fetchForkedReposPage(ctx, baseURL, owner, token, pageNum, perPage) + repos, err := fetchForkedReposPage( + ctx, + baseURL, + owner, + token, + pageNum, + perPage, + olderThanDays) + if err != nil { return nil, err } @@ -93,13 +111,16 @@ func fetchForkedRepos( } func doRequest(req *http.Request, v any) error { + httpClient := httpClientPool.Get().(*http.Client) + defer httpClientPool.Put(httpClient) + resp, err := httpClient.Do(req) if err != nil { return err } defer resp.Body.Close() - if resp.StatusCode >= 400 { + if resp.StatusCode >= http.StatusBadRequest { return fmt.Errorf("API request failed with status: %d", resp.StatusCode) } @@ -153,26 +174,123 @@ func printWithColor(color, text string) { fmt.Println(color + text + Reset) } -func CLI() { +type CLIConfig struct { + + // Required + writer io.Writer + version string + exitFunc func(int) + + // Optional + flagErrorHandling flag.ErrorHandling + printWithColor func(color, text string) + fetchForkedRepos func( + ctx context.Context, + baseURL, + owner, + token string, + perPage, + maxPage, + olderThanDays int) ([]repo, error) + deleteRepos func(ctx context.Context, baseURL, token string, repos []repo) error +} + +// Dysfunctional options pattern + +func (c *CLIConfig) WithFlagErrorHandling(h flag.ErrorHandling) *CLIConfig { + c.flagErrorHandling = h + return c +} + +func (c *CLIConfig) WithPrintWithColor(f func(color, text string)) *CLIConfig { + c.printWithColor = printWithColor + return c +} + +func (c *CLIConfig) WithFetchForkedRepos( + f func( + ctx context.Context, + baseURL, + owner, + token string, + perPage, + maxPage, + olderThanDays int) ([]repo, error)) *CLIConfig { + + c.fetchForkedRepos = f + return c +} + +func (c *CLIConfig) WithDeleteRepos( + f func(ctx context.Context, baseURL, token string, repos []repo) error) *CLIConfig { + + c.deleteRepos = f + return c +} + +func NewCLIConfig( + writer io.Writer, + version string, + exitFunc func(int), +) *CLIConfig { + + return &CLIConfig{ + writer: writer, + version: version, + exitFunc: exitFunc, + flagErrorHandling: flag.ExitOnError, + printWithColor: printWithColor, + fetchForkedRepos: fetchForkedRepos, + deleteRepos: deleteRepos, + } +} + +func (c *CLIConfig) CLI(args []string) { var ( - owner string - token string - perPage int - maxPage int + owner string + token string + perPage int + maxPage int + olderThanDays int + version bool + delete bool + writer = c.writer + versionNum = c.version + exitFunc = c.exitFunc + flagErrorHandling = c.flagErrorHandling + printWithColor = c.printWithColor + fetchForkedRepos = c.fetchForkedRepos + deleteRepos = c.deleteRepos ) // Parsing command-line flags - flag.StringVar(&owner, "owner", "", "GitHub repository owner (required)") - flag.StringVar(&token, "token", "", "GitHub personal access token (required)") - flag.IntVar(&perPage, "per-page", 100, "Number of repositories per page") - flag.IntVar(&maxPage, "max-page", 100, "Maximum page number to fetch") - flag.Parse() + fs := flag.NewFlagSet("fork-sweeper", flagErrorHandling) + fs.SetOutput(writer) + + fs.StringVar(&owner, "owner", "", "GitHub repo owner (required)") + fs.StringVar(&token, "token", "", "GitHub access token (required)") + fs.IntVar(&perPage, "per-page", 100, "Number of forked repos fetched per page") + fs.IntVar(&maxPage, "max-page", 100, "Maximum page number to fetch") + fs.IntVar( + &olderThanDays, + "older-than-days", + 60, + "Delete forked repos older than this number of days") + fs.BoolVar(&version, "version", false, "Print version") + fs.BoolVar(&delete, "delete", false, "Delete forked repos") + fs.Parse(args) + + // Printing version + if version { + fmt.Println(versionNum) + return + } // Validating required arguments if owner == "" || token == "" { fmt.Fprintf(os.Stderr, "%sError:%s Owner and token are required.\n", Red, Reset) - flag.PrintDefaults() - os.Exit(1) + fs.PrintDefaults() + exitFunc(1) } ctx := context.Background() @@ -180,10 +298,18 @@ func CLI() { // Fetching repositories printWithColor(Blue, fmt.Sprintf("\nFetching repositories for %s...\n", owner)) - forkedRepos, err := fetchForkedRepos(ctx, baseURL, owner, token, perPage, maxPage) + forkedRepos, err := fetchForkedRepos( + ctx, + baseURL, + owner, + token, + perPage, + maxPage, + olderThanDays) + if err != nil { fmt.Fprintf(os.Stderr, "%sError fetching repositories:%s %v\n", Red, Reset, err) - os.Exit(1) + exitFunc(1) } if len(forkedRepos) == 0 { @@ -198,10 +324,14 @@ func CLI() { } // Deleting forked repositories + if !delete { + return + } + printWithColor(Blue, "\nDeleting forked repositories...\n") if err := deleteRepos(ctx, baseURL, token, forkedRepos); err != nil { fmt.Fprintf(os.Stderr, "%sError deleting repositories:%s %v\n", Red, Reset, err) - os.Exit(1) + exitFunc(1) } printWithColor(Green, "Deletion completed successfully.") } diff --git a/src/cli_test.go b/src/cli_test.go index 2f69ad4..b4b44b5 100644 --- a/src/cli_test.go +++ b/src/cli_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "flag" "fmt" "io" "net/http" @@ -51,7 +52,9 @@ func TestUnmarshalRepo(t *testing.T) { // Compare the expected and actual repo structs if !reflect.DeepEqual(expected, result) { - t.Errorf("Unmarshalled repo does not match expected value. Expected %+v, got %+v", expected, result) + t.Errorf( + `Unmarshalled repo does not match expected value. + Expected %+v, got %+v`, expected, result) } } @@ -83,7 +86,7 @@ func TestFetchForkedReposPage(t *testing.T) { } forkedRepos, err := fetchForkedReposPage( - context.Background(), mockServer.URL, "example", "fake-token", 1, 10) + context.Background(), mockServer.URL, "example", "fake-token", 1, 10, 60) if err != nil { t.Fatalf("fetchForkedReposPage returned an error: %v", err) } @@ -145,7 +148,13 @@ func TestFetchForkedRepos(t *testing.T) { } forkedRepos, err := fetchForkedRepos( - context.Background(), mockServer.URL, "example", "fake-token", 10, 1) + context.Background(), + mockServer.URL, + "example", + "fake-token", + 10, + 1, + 60) if err != nil { t.Fatalf("fetchForkedRepos returned an error: %v", err) } @@ -323,3 +332,144 @@ func TestPrintWithColor(t *testing.T) { }) } } + +// Test cli flow + +// Mock functions to replace actual behavior in tests +var ( + mockExitFunc = func(called *int) func(int) { + return func(code int) { + *called++ + } + } + mockFlagErrorHandler = flag.ContinueOnError + mockPrintWithColor = func(color, text string) { fmt.Println("mockPrintWithColor") } + + mockFetchForkedRepos = func( + ctx context.Context, + baseURL, + owner, + token string, + perPage, + maxPage, + olderThanDays int) ([]repo, error) { + fmt.Println("mockFetchForkedRepos") + return []repo{{Name: "test-repo"}}, nil + } + + mockDeleteRepos = func( + ctx context.Context, + baseURL, + token string, + repos []repo) error { + fmt.Println("mockDeleteRepos") + return nil + } +) + +func TestNewCLIConfig_Defaults(t *testing.T) { + t.Parallel() + config := NewCLIConfig(nil, "", nil) + + if config.printWithColor == nil || config.fetchForkedRepos == nil || config.deleteRepos == nil { + t.Fatal("Default functions were not set correctly") + } +} + +func TestWithFlagErrorHandling_Option(t *testing.T) { + t.Parallel() + config := NewCLIConfig(nil, "", nil).WithFlagErrorHandling(mockFlagErrorHandler) + if config.flagErrorHandling != mockFlagErrorHandler { + t.Fatal("WithFlagErrorHandling did not set the flag error handling") + } +} + +func TestWithPrintWithColor_Option(t *testing.T) { + t.Parallel() + config := NewCLIConfig(nil, "", nil).WithPrintWithColor(mockPrintWithColor) + if config.printWithColor == nil { + t.Fatal("WithPrintWithColor did not set the function") + } +} + +func TestWithFetchForkedRepos_Option(t *testing.T) { + t.Parallel() + config := NewCLIConfig(nil, "", nil).WithFetchForkedRepos(mockFetchForkedRepos) + + if config.fetchForkedRepos == nil { + t.Fatal("WithFetchForkedRepos did not set the function") + } +} + +func TestWithDeleteRepos_Option(t *testing.T) { + t.Parallel() + config := NewCLIConfig(nil, "", nil).WithDeleteRepos(mockDeleteRepos) + if config.deleteRepos == nil { + t.Fatal("WithDeleteRepos did not set the function") + } +} + +func captureStderr(testFunc func()) string { + r, w, _ := os.Pipe() + originalStderr := os.Stderr + os.Stderr = w + + outputChan := make(chan string) + go func() { + var buf bytes.Buffer + io.Copy(&buf, r) + outputChan <- buf.String() + }() + + testFunc() + + w.Close() + os.Stderr = originalStderr + return <-outputChan +} + +func TestCLI_MissingOwnerToken(t *testing.T) { + t.Parallel() + exitCalls := new(int) + stderrOutput := captureStderr( + func() { + cliConfig := NewCLIConfig( + os.Stderr, + "test-version", + mockExitFunc(exitCalls), + ).WithFetchForkedRepos(mockFetchForkedRepos). + WithDeleteRepos(mockDeleteRepos). + WithPrintWithColor(mockPrintWithColor). + WithFlagErrorHandling(mockFlagErrorHandler) + + // Execute the CLI + cliConfig.CLI([]string{"cmd"}) + }) + + // Verify + if !strings.Contains(stderrOutput, "Owner and token are required") { + t.Errorf("Expected error message not found in output") + } + + if *exitCalls != 1 { + t.Errorf("Expected os.Exit to be called once, got %d", *exitCalls) + } +} +func TestCLI_Success(t *testing.T) { + t.Parallel() + exitCalls := new(int) + cliConfig := NewCLIConfig( + os.Stderr, + "test-version", + mockExitFunc(exitCalls), + ).WithDeleteRepos(mockDeleteRepos). + WithFetchForkedRepos(mockFetchForkedRepos). + WithPrintWithColor(mockPrintWithColor). + WithFlagErrorHandling(mockFlagErrorHandler) + + // Execute the CLI + args := []string{"cmd", "--owner", "testOwner", "--token", "testToken"} + + cliConfig.CLI(args) + +}