diff --git a/README.md b/README.md index c469b72..57c7a2b 100644 --- a/README.md +++ b/README.md @@ -51,10 +51,12 @@ Remove unused GitHub forks Usage of fork-sweeper: -delete Delete forked repos + -guard value + List of repos to protect from deletion (fuzzy match name) -max-page int Maximum number of pages to fetch (default 100) -older-than-days int - Fetch forked repos older than this number of days (default 60) + Fetch forked repos modified more than n days ago (default 60) -owner string GitHub repo owner (required) -per-page int @@ -65,11 +67,25 @@ Remove unused GitHub forks Print version ``` -- List forked repos older than `n` days. By default, it'll fetch all repositories that - were forked at least 60 days ago. +- List forked repos older than `n` days. By default, it'll fetch all forked repositories + that were modified at least 60 days ago. The following command lists all forked + repositories. ```sh - fork-sweeper --owner rednafi --token $GITHUB_TOKEN --older-than-days 60 + fork-sweeper --owner rednafi --token $GITHUB_TOKEN --older-than-days 0 + ``` + + This returns: + + ```txt + Fetching forked repositories for rednafi... + + Guarded forked repos [won't be deleted]: + + Unguarded forked repos [will be deleted]: + - https://github.com/rednafi/cpython + - https://github.com/rednafi/dysconfig + - https://github.com/rednafi/pydantic ``` - The CLI won't delete any repository unless you explicitly tell it to do so with the @@ -79,7 +95,26 @@ Remove unused GitHub forks fork-sweeper --owner rednafi --token $GITHUB_TOKEN --delete ``` -- By default, the CLI will fetch 100 pages of forked repositories with 100 entries in each +- You can explicitly protect some repositories from deletion with the `--guard` parameter: + + ```sh + fork-sweeper --owner "rednafi" --token $GITHUB_TOKEN --older-than-days 0 --guard 'py' + ``` + + This prints: + + ```txt + Fetching forked repositories for rednafi... + + Guarded forked repos [won't be deleted]: + - https://github.com/rednafi/cpython + - https://github.com/rednafi/pydantic + + Unguarded forked repos [will be deleted]: + - https://github.com/rednafi/dysconfig + ``` + +- By default, the CLI will fetch 100 pages of forked repositories with 100 entries on each page. If you need more, you can set the page number as follows: ```sh diff --git a/src/cli.go b/src/cli.go index 497fba2..e6e4044 100644 --- a/src/cli.go +++ b/src/cli.go @@ -7,27 +7,32 @@ import ( "fmt" "io" "net/http" + "strings" "sync" "time" ) const ( + // Exit codes exitOk = 0 exitErr = 1 - errUserNotFound = "API request failed with status: 404" - errInvalidToken = "API request failed with status: 401" - errInsufficientTokenPermission = "API request failed with status: 403" + // Error messages to catch from the GitHub API + ErrMsg401 = "API request failed with status: 401" + ErrMsg403 = "API request failed with status: 403" + ErrMsg404 = "API request failed with status: 404" ) type repo struct { - Name string `json:"full_name"` + Name string `json:"name"` URL string `json:"html_url"` IsFork bool `json:"fork"` Owner struct { - Name string `json:"name"` + Name string `json:"login"` } `json:"owner"` CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + PushedAt time.Time `json:"pushed_at"` } var httpClientPool = sync.Pool{ @@ -42,8 +47,7 @@ func fetchForkedReposPage( owner, token string, pageNum, - perPage, - olderThanDays int) ([]repo, error) { + perPage int) ([]repo, error) { url := fmt.Sprintf( "%s/users/%s/repos?type=forks&page=%d&per_page=%d", @@ -57,23 +61,18 @@ func fetchForkedReposPage( return nil, err } - req.Header.Add("Authorization", "Bearer "+token) - var repos []repo - if err := doRequest(req, &repos); err != nil { + if err := doRequest(req, token, &repos); err != nil { return nil, err } + // Filter out non-forked repositories var forkedRepos []repo - - cutOffDate := time.Now().AddDate(0, 0, -olderThanDays) - - for _, repo := range repos { - if repo.IsFork && repo.CreatedAt.Before(cutOffDate) { - forkedRepos = append(forkedRepos, repo) + for _, r := range repos { + if r.IsFork { + forkedRepos = append(forkedRepos, r) } } - return forkedRepos, nil } @@ -83,19 +82,18 @@ func fetchForkedRepos( owner, token string, perPage, - maxPage, - olderThanDays int) ([]repo, error) { + maxPage int) ([]repo, error) { var allRepos []repo for pageNum := 1; pageNum <= maxPage; pageNum++ { repos, err := fetchForkedReposPage( - ctx, - baseURL, - owner, - token, - pageNum, - perPage, - olderThanDays) + ctx, // ctx + baseURL, // baseURL + owner, // owner + token, // token + pageNum, // pageNum + perPage, // perPage + ) if err != nil { return nil, err @@ -110,10 +108,13 @@ func fetchForkedRepos( return allRepos, nil } -func doRequest(req *http.Request, v any) error { +func doRequest(req *http.Request, token string, result any) error { httpClient := httpClientPool.Get().(*http.Client) defer httpClientPool.Put(httpClient) + req.Header.Add("Authorization", "Bearer "+token) + req.Header.Add("Accept", "application/vnd.github.v3+json") + resp, err := httpClient.Do(req) if err != nil { return err @@ -124,24 +125,62 @@ func doRequest(req *http.Request, v any) error { return fmt.Errorf("API request failed with status: %d", resp.StatusCode) } - if v != nil { - if err := json.NewDecoder(resp.Body).Decode(v); err != nil { + if result != nil { + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { return err } } return nil } +// filterForkedRepos filters forked repositories based on their update date and whether their name matches any in the protectedRepos list using a basic form of fuzzy matching. +func filterForkedRepos( + forkedRepos []repo, + guardedRepoNames []string, + olderThanDays int) ([]repo, []repo) { + + unguardedRepos, guardedRepos := []repo{}, []repo{} + + now := time.Now() + + // Convert olderThanDays to duration and subtract from current time to get cutoff date + cutOffDate := now.Add(time.Duration(-olderThanDays) * 24 * time.Hour) + + for _, repo := range forkedRepos { + // Check if repo activity is after cutoff date or name matches guarded list + hasRecentActivity := repo.PushedAt.After(cutOffDate) || + repo.UpdatedAt.After(cutOffDate) || repo.CreatedAt.After(cutOffDate) + + isGuardedName := false + for _, name := range guardedRepoNames { + repoName := strings.ToLower(repo.Name) + name = strings.ToLower(name) + + if strings.TrimSpace(name) != "" && strings.Contains(repoName, name) { + isGuardedName = true + break + } + } + + if hasRecentActivity || isGuardedName { + guardedRepos = append(guardedRepos, repo) + } else { + unguardedRepos = append(unguardedRepos, repo) + } + } + + return unguardedRepos, guardedRepos +} + func deleteRepo(ctx context.Context, baseURL, owner, name, token string) error { - url := fmt.Sprintf("%s/repos%s/%s", baseURL, owner, name) + url := fmt.Sprintf("%s/repos/%s/%s", baseURL, owner, name) + req, err := http.NewRequestWithContext(ctx, "DELETE", url, nil) if err != nil { return err } - req.Header.Add("Authorization", "Bearer "+token) - req.Header.Add("Accept", "application/vnd.github.v3+json") - return doRequest(req, nil) + return doRequest(req, token, nil) } func deleteRepos(ctx context.Context, baseURL, token string, repos []repo) error { @@ -184,8 +223,13 @@ type cliConfig struct { owner, token string, perPage, - maxPage, - olderThanDays int) ([]repo, error) + maxPage int) ([]repo, error) + + filterForkedRepos func( + forkedRepos []repo, + protectedRepos []string, + olderThanDays int) ([]repo, []repo) + deleteRepos func(ctx context.Context, baseURL, token string, repos []repo) error } @@ -219,13 +263,22 @@ func (c *cliConfig) withFetchForkedRepos( owner, token string, perPage, - maxPage, - olderThanDays int) ([]repo, error)) *cliConfig { + maxPage int) ([]repo, error)) *cliConfig { c.fetchForkedRepos = f return c } +func (c *cliConfig) withFilterForkedRepos( + f func( + forkedRepos []repo, + protectedRepos []string, + olderThanDays int) ([]repo, []repo)) *cliConfig { + + c.filterForkedRepos = f + return c +} + func (c *cliConfig) withDeleteRepos( f func(ctx context.Context, baseURL, token string, repos []repo) error) *cliConfig { @@ -233,19 +286,31 @@ func (c *cliConfig) withDeleteRepos( return c } +type stringSlice []string + +func (s *stringSlice) Set(value string) error { + *s = append(*s, value) + return nil +} + +func (s *stringSlice) String() string { + return strings.Join(*s, ", ") +} + func (c *cliConfig) CLI(args []string) int { var ( - owner string - token string - perPage int - maxPage int - olderThanDays int - version bool - delete bool + owner string + token string + perPage int + maxPage int + olderThanDays int + version bool + delete bool + protectedRepos stringSlice stdout = c.stdout stderr = c.stderr - versionNum = c.version + versionNumber = c.version flagErrorHandling = c.flagErrorHandling fetchForkedRepos = c.fetchForkedRepos deleteRepos = c.deleteRepos @@ -259,19 +324,19 @@ func (c *cliConfig) CLI(args []string) int { 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 number of pages to fetch") - fs.IntVar( - &olderThanDays, + fs.IntVar(&olderThanDays, "older-than-days", 60, - "Fetch forked repos older than this number of days") + "Fetch forked repos modified more than n days ago") fs.BoolVar(&version, "version", false, "Print version") fs.BoolVar(&delete, "delete", false, "Delete forked repos") + fs.Var(&protectedRepos, "guard", "List of repos to protect from deletion (fuzzy match name)") fs.Parse(args) // Printing version if version { - fmt.Fprintln(stdout, versionNum) + fmt.Fprintln(stdout, versionNumber) return exitOk } @@ -286,21 +351,21 @@ func (c *cliConfig) CLI(args []string) int { baseURL := "https://api.github.com" // Fetching repositories - fmt.Fprintf(stdout, "\nFetching repositories for %s...\n", owner) + fmt.Fprintf(stdout, "\nFetching forked repositories for %s...\n", owner) forkedRepos, err := fetchForkedRepos( - ctx, - baseURL, - owner, - token, - perPage, - maxPage, - olderThanDays) + ctx, // ctx + baseURL, // baseURL + owner, // owner + token, // token + perPage, // perPage + maxPage, // maxPage + ) if err != nil { switch err.Error() { - case errUserNotFound: + case ErrMsg404: fmt.Fprintf(stderr, "Error: user not found\n") - case errInvalidToken: + case ErrMsg401: fmt.Fprintf(stderr, "Error: invalid token\n") default: fmt.Fprintf(stderr, "Error: %s\n", err) @@ -312,22 +377,41 @@ func (c *cliConfig) CLI(args []string) int { return exitOk } - // Listing forked repositories - fmt.Fprintf(stdout, "\nForked repos:\n") - for _, repo := range forkedRepos { + // Filtering repositories + unguardedRepos, guardedRepos := filterForkedRepos( + forkedRepos, + protectedRepos, + olderThanDays) + + // Displaying safeguarded repositories + fmt.Fprintf(stdout, "\nGuarded forked repos [won't be deleted]:\n") + for _, repo := range guardedRepos { fmt.Fprintf(stdout, " - %s\n", repo.URL) } - // Deleting forked repositories + // Displaying unguarded repositories + fmt.Fprintf(stdout, "\nUnguarded forked repos [will be deleted]:\n") + for _, repo := range unguardedRepos { + fmt.Fprintf(stdout, " - %s\n", repo.URL) + } + + // Deleting unguarded repositories if !delete { return exitOk } + if len(unguardedRepos) == 0 { + fmt.Fprintf(stdout, "\nNo unguarded forked repositories to delete\n") + return exitOk + } + fmt.Fprintf(stdout, "\nDeleting forked repositories...\n") - if err := deleteRepos(ctx, baseURL, token, forkedRepos); err != nil { + if err := deleteRepos(ctx, baseURL, token, unguardedRepos); err != nil { switch err.Error() { - case errInsufficientTokenPermission: + case ErrMsg403: fmt.Fprintf(stderr, "Error: token does not have permission to delete repos\n") + case ErrMsg404: + fmt.Fprintf(stderr, "Error: repo not found\n") default: fmt.Fprintf(stderr, "Error: %s\n", err) } diff --git a/src/cli_test.go b/src/cli_test.go index 58b5a28..f33731f 100644 --- a/src/cli_test.go +++ b/src/cli_test.go @@ -18,32 +18,36 @@ import ( func TestUnmarshalRepo(t *testing.T) { t.Parallel() // Example JSON string that represents a repo's data - jsonString := `{ - "full_name": "example/repo", - "html_url": "https://github.com/example/repo", + jsonStr := `{ + "name": "test-repo", + "html_url": "https://github.com/test-owner/test-repo", "fork": false, "owner": { - "name": "example" + "login": "test-owner" }, - "created_at": "2020-01-01T00:00:00Z" + "created_at": "2020-01-01T00:00:00Z", + "updated_at": "2020-01-01T00:00:00Z", + "pushed_at": "2020-01-01T00:00:00Z" }` // Expected repo object based on the JSON string expected := repo{ - Name: "example/repo", - URL: "https://github.com/example/repo", + Name: "test-repo", + URL: "https://github.com/test-owner/test-repo", IsFork: false, Owner: struct { - Name string `json:"name"` + Name string `json:"login"` }{ - Name: "example", + Name: "test-owner", }, CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + PushedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), } // Unmarshal the JSON string into a repo struct var result repo - err := json.Unmarshal([]byte(jsonString), &result) + err := json.Unmarshal([]byte(jsonStr), &result) if err != nil { t.Fatalf("Unmarshalling failed: %v", err) } @@ -65,26 +69,39 @@ func TestFetchForkedReposPage(t *testing.T) { w.WriteHeader(http.StatusOK) fmt.Fprintln( w, - `[{"full_name": "example/forkedrepo",`+ - `"html_url": "https://github.com/example/forkedrepo", "fork": true,`+ - `"owner": {"name": "example"}, "created_at": "2020-01-01T00:00:00Z"}]`) + `[{"name": "test-forked-repo",`+ + `"html_url": "https://github.com/test-owner/test-forked-repo", `+ + `"fork": true,`+ + `"owner": {"login": "test-owner"},`+ + `"created_at": "2020-01-01T00:00:00Z",`+ + `"updated_at": "2020-01-01T00:00:00Z",`+ + `"pushed_at": "2020-01-01T00:00:00Z"}]`) })) defer mockServer.Close() expected := []repo{ { - Name: "example/forkedrepo", - URL: "https://github.com/example/forkedrepo", + Name: "test-forked-repo", + URL: "https://github.com/test-owner/test-forked-repo", IsFork: true, Owner: struct { - Name string `json:"name"` - }{Name: "example"}, + Name string `json:"login"` + }{Name: "test-owner"}, CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + PushedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), }, } forkedRepos, err := fetchForkedReposPage( - context.Background(), mockServer.URL, "example", "fake-token", 1, 10, 60) + context.Background(), // ctx + mockServer.URL, // baseURL + "test-owner", // owner + "test-token", // token + 1, // pageNum + 10, // perPage + ) + if err != nil { t.Fatalf("fetchForkedReposPage returned an error: %v", err) } @@ -98,7 +115,7 @@ func TestFetchForkedReposPage(t *testing.T) { repo.URL != expected[i].URL || repo.IsFork != expected[i].IsFork || repo.Owner.Name != expected[i].Owner.Name || - !repo.CreatedAt.Equal(expected[i].CreatedAt) { + !repo.UpdatedAt.Equal(expected[i].UpdatedAt) { t.Errorf("Expected repo %+v, got %+v", expected[i], repo) } } @@ -112,47 +129,58 @@ func TestFetchForkedRepos(t *testing.T) { w.WriteHeader(http.StatusOK) fmt.Fprintln( w, - `[{"full_name": "example/forkedrepo",`+ - `"html_url": "https://test.com/example/forkedrepo", "fork": true,`+ - `"owner": {"name": "example"}, "created_at": "2020-01-01T00:00:00Z"},`+ - - `{"full_name": "example/forkedrepo2",`+ - `"html_url": "https://test.com/example/forkedrepo2", "fork": true,`+ - `"owner": {"name": "example2"}, "created_at": "2020-01-01T00:00:00Z"}]`) - + `[{"name": "test-repo-1",`+ + `"html_url": "https://test.com/test-owner/test-repo-1",`+ + `"fork": true,`+ + `"owner": {"login": "test-owner"},`+ + `"created_at": "2020-01-01T00:00:00Z",`+ + `"updated_at": "2020-01-01T00:00:00Z",`+ + `"pushed_at": "2020-01-01T00:00:00Z"},`+ + + `{"name": "test-repo-2",`+ + `"html_url": "https://test.com/test-owner/test-repo-2",`+ + `"fork": true,`+ + `"owner": {"login": "test-owner"},`+ + `"created_at": "2020-01-01T00:00:00Z",`+ + `"updated_at": "2020-01-01T00:00:00Z",`+ + `"pushed_at": "2020-01-01T00:00:00Z"}]`) })) defer mockServer.Close() expected := []repo{ { - Name: "example/forkedrepo", - URL: "https://test.com/example/forkedrepo", + Name: "test-repo-1", + URL: "https://test.com/test-owner/test-repo-1", IsFork: true, Owner: struct { - Name string `json:"name"` - }{Name: "example"}, + Name string `json:"login"` + }{Name: "test-owner"}, CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + PushedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), }, { - Name: "example/forkedrepo2", - URL: "https://test.com/example/forkedrepo2", + Name: "test-repo-2", + URL: "https://test.com/test-owner/test-repo-2", IsFork: true, Owner: struct { - Name string `json:"name"` - }{Name: "example2"}, + Name string `json:"login"` + }{Name: "test-owner"}, CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + PushedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), }, } forkedRepos, err := fetchForkedRepos( - context.Background(), - mockServer.URL, - "example", - "fake-token", - 10, - 1, - 60) + context.Background(), // ctx + mockServer.URL, // baseURL + "test-owner", // owner + "test-token", // token + 10, // perPage + 1, // maxPage + ) if err != nil { t.Fatalf("fetchForkedRepos returned an error: %v", err) } @@ -166,7 +194,9 @@ func TestFetchForkedRepos(t *testing.T) { repo.URL != expected[i].URL || repo.IsFork != expected[i].IsFork || repo.Owner.Name != expected[i].Owner.Name || - !repo.CreatedAt.Equal(expected[i].CreatedAt) { + !repo.CreatedAt.Equal(expected[i].CreatedAt) || + !repo.UpdatedAt.Equal(expected[i].UpdatedAt) || + !repo.PushedAt.Equal(expected[i].PushedAt) { t.Errorf("Expected repo %+v, got %+v", expected[i], repo) } } @@ -219,9 +249,10 @@ func TestDoRequest(t *testing.T) { // Attempt to decode into this variable var result map[string]interface{} + var token string // Call doRequest with the mock server's URL - err := doRequest(req, &result) + err := doRequest(req, token, &result) // Check for error existence if (err != nil) != tt.wantErr { @@ -236,6 +267,126 @@ func TestDoRequest(t *testing.T) { }) } } +func TestFilterForkedRepos_EmptyInput(t *testing.T) { + t.Parallel() + unguarded, guarded := filterForkedRepos(nil, nil, 30) + if len(unguarded) != 0 || len(guarded) != 0 { + t.Errorf("Expected both slices to be empty, got %v and %v", unguarded, guarded) + } +} + +func TestFilterForkedRepos_AllGuarded(t *testing.T) { + now := time.Now() + forkedRepos := []repo{ + {Name: "test-repo-1", CreatedAt: now, UpdatedAt: now, PushedAt: now}, + {Name: "test-repo-2", CreatedAt: now, UpdatedAt: now, PushedAt: now}, + } + guardedRepoNames := []string{"test-repo"} + unguarded, guarded := filterForkedRepos(forkedRepos, guardedRepoNames, 30) + if len(unguarded) != 0 || len(guarded) != 2 { + t.Errorf("Expected unguarded 0 and guarded 2, got unguarded %d and guarded %d", len(unguarded), len(guarded)) + } +} + +func TestFilterForkedRepos_AllUnguardedDueToDate(t *testing.T) { + forkedRepos := []repo{ + { + Name: "old-repo-1", + CreatedAt: time.Now().AddDate(0, -1, 0), + UpdatedAt: time.Now().AddDate(0, -1, 0), + PushedAt: time.Now().AddDate(0, -1, 0)}, + { + Name: "old-repo-2", + CreatedAt: time.Now().AddDate(0, -2, 0), + UpdatedAt: time.Now().AddDate(0, -2, 0), + PushedAt: time.Now().AddDate(0, -2, 0)}, + } + var guardedRepoNames []string + unguarded, guarded := filterForkedRepos(forkedRepos, guardedRepoNames, 10) + + if len(unguarded) != 2 || len(guarded) != 0 { + t.Errorf("Expected unguarded 2 and guarded 0, got unguarded %d and guarded %d", len(unguarded), len(guarded)) + } +} + +func TestFilterForkedRepos_UnknownGuardRepoName(t *testing.T) { + forkedRepos := []repo{ + { + Name: "old-repo-1", + CreatedAt: time.Now().AddDate(0, -1, 0), + UpdatedAt: time.Now().AddDate(0, -1, 0), + PushedAt: time.Now().AddDate(0, -1, 0)}, + { + Name: "old-repo-2", + CreatedAt: time.Now().AddDate(0, -2, 0), + UpdatedAt: time.Now().AddDate(0, -2, 0), + PushedAt: time.Now().AddDate(0, -2, 0)}, + } + guardedRepoNames := []string{"unknown-repo-1", "unknown-repo-2"} + + unguarded, guarded := filterForkedRepos(forkedRepos, guardedRepoNames, 10) + + if len(unguarded) != 2 || len(guarded) != 0 { + t.Errorf("Expected unguarded 2 and guarded 0, got unguarded %d and guarded %d", len(unguarded), len(guarded)) + } +} + +func TestFilterForkedRepos_MixedGuardedUnguarded(t *testing.T) { + forkedRepos := []repo{ + { + Name: "new-repo-1", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + PushedAt: time.Now()}, + { + Name: "protected-old-repo", + CreatedAt: time.Now().AddDate(0, -2, 0), + UpdatedAt: time.Now().AddDate(0, -2, 0), + PushedAt: time.Now().AddDate(0, -2, 0)}, + } + + guardedRepoNames := []string{"protected"} + unguarded, guarded := filterForkedRepos(forkedRepos, guardedRepoNames, 30) + if len(unguarded) != 0 || len(guarded) != 2 { + t.Errorf("Expected unguarded 0 and guarded 2, got unguarded %d and guarded %d", len(unguarded), len(guarded)) + } +} + +func TestFilterForkedRepos_CaseInsensitive(t *testing.T) { + forkedRepos := []repo{ + { + Name: "Case-Sensitive-Repo", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + PushedAt: time.Now()}, + } + guardedRepoNames := []string{"case-sensitive"} + unguarded, guarded := filterForkedRepos(forkedRepos, guardedRepoNames, 30) + if len(unguarded) != 0 || len(guarded) != 1 { + t.Errorf("Expected unguarded 0 and guarded 1, got unguarded %d and guarded %d", len(unguarded), len(guarded)) + } +} + +func TestFilterForkedRepos_MultipleMatches(t *testing.T) { + forkedRepos := []repo{ + { + Name: "match-1", + CreatedAt: time.Now().AddDate(0, -1, 0), + UpdatedAt: time.Now().AddDate(0, -1, 0), + PushedAt: time.Now().AddDate(0, -1, 0)}, + { + Name: "match-2", + CreatedAt: time.Now().AddDate(0, -2, 0), + UpdatedAt: time.Now().AddDate(0, -2, 0), + PushedAt: time.Now().AddDate(0, -2, 0)}, + } + guardedRepoNames := []string{"match-1", "match-2"} + + unguarded, guarded := filterForkedRepos(forkedRepos, guardedRepoNames, 29) + if len(unguarded) != 0 || len(guarded) != 2 { + t.Errorf("Expected unguarded 0 and guarded 2, got unguarded %d and guarded %d", len(unguarded), len(guarded)) + } +} func TestDeleteRepo(t *testing.T) { t.Parallel() @@ -298,12 +449,19 @@ var ( owner, token string, perPage, - maxPage, - olderThanDays int) ([]repo, error) { + maxPage int) ([]repo, error) { fmt.Println("mockFetchForkedRepos") return []repo{{Name: "test-repo"}}, nil } + mockFilterForkedRepos = func( + forkedRepos []repo, + guardedRepoNames []string, + olderThanDays int) ([]repo, []repo) { + fmt.Println("mockFilterForkedRepos") + return forkedRepos, nil + } + mockDeleteRepos = func( ctx context.Context, baseURL, @@ -342,6 +500,14 @@ func TestWithFetchForkedRepos_Option(t *testing.T) { } } +func TestWithFilterForkedRepos_Option(t *testing.T) { + t.Parallel() + config := NewCLIConfig(nil, nil, "").withFilterForkedRepos(filterForkedRepos) + if config.filterForkedRepos == nil { + t.Fatal("WithFilterForkedRepos did not set the function") + } +} + func TestWithDeleteRepos_Option(t *testing.T) { t.Parallel() config := NewCLIConfig(nil, nil, "").withDeleteRepos(mockDeleteRepos) @@ -362,7 +528,8 @@ func TestCLI_MissingOwnerToken(t *testing.T) { "test-version", ).withFetchForkedRepos(mockFetchForkedRepos). withDeleteRepos(mockDeleteRepos). - withFlagErrorHandling(mockFlagErrorHandler) + withFlagErrorHandling(mockFlagErrorHandler). + withFilterForkedRepos(mockFilterForkedRepos) // Execute the CLI exitCode := cliConfig.CLI([]string{"cmd"}) @@ -387,10 +554,11 @@ func TestCLI_Success(t *testing.T) { "test-version", ).withDeleteRepos(mockDeleteRepos). withFetchForkedRepos(mockFetchForkedRepos). - withFlagErrorHandling(mockFlagErrorHandler) + withFlagErrorHandling(mockFlagErrorHandler). + withFilterForkedRepos(mockFilterForkedRepos) // Execute the CLI - args := []string{"--owner", "testOwner", "--token", "testToken", "--older-than", "30"} + args := []string{"--owner", "testOwner", "--token", "testToken", "--older-than-days", "30"} exitCode := cliConfig.CLI(args)