From bf846bde74a5dd635fa7896a282b0af2e19a256f Mon Sep 17 00:00:00 2001 From: redowan Date: Fri, 15 Mar 2024 19:58:24 +0100 Subject: [PATCH 1/7] Fetch forked repos by modification date, refs #3 --- README.md | 28 ++++++++++++++-------------- src/cli.go | 23 +++++++++++------------ src/cli_test.go | 25 +++++++++++++------------ 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index c469b72..a3accbb 100644 --- a/README.md +++ b/README.md @@ -49,20 +49,20 @@ Remove unused GitHub forks ```txt Usage of fork-sweeper: - -delete - Delete forked repos - -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) - -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 - Print version + -delete + Delete forked repos + -max-page int + Maximum number of pages to fetch (default 100) + -older-than-days int + Fetch forked repos modified more than n days ago (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 + Print version ``` - List forked repos older than `n` days. By default, it'll fetch all repositories that diff --git a/src/cli.go b/src/cli.go index 497fba2..4f37765 100644 --- a/src/cli.go +++ b/src/cli.go @@ -27,7 +27,7 @@ type repo struct { Owner struct { Name string `json:"name"` } `json:"owner"` - CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } var httpClientPool = sync.Pool{ @@ -57,10 +57,8 @@ 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 } @@ -69,7 +67,7 @@ func fetchForkedReposPage( cutOffDate := time.Now().AddDate(0, 0, -olderThanDays) for _, repo := range repos { - if repo.IsFork && repo.CreatedAt.Before(cutOffDate) { + if repo.IsFork && repo.UpdatedAt.Before(cutOffDate) { forkedRepos = append(forkedRepos, repo) } } @@ -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,8 +125,8 @@ 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 } } @@ -138,10 +139,8 @@ func deleteRepo(ctx context.Context, baseURL, owner, name, token string) error { 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 { @@ -263,7 +262,7 @@ func (c *cliConfig) CLI(args []string) int { &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") diff --git a/src/cli_test.go b/src/cli_test.go index 58b5a28..7c55bc9 100644 --- a/src/cli_test.go +++ b/src/cli_test.go @@ -25,7 +25,7 @@ func TestUnmarshalRepo(t *testing.T) { "owner": { "name": "example" }, - "created_at": "2020-01-01T00:00:00Z" + "updated_at": "2020-01-01T00:00:00Z" }` // Expected repo object based on the JSON string @@ -38,7 +38,7 @@ func TestUnmarshalRepo(t *testing.T) { }{ Name: "example", }, - CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), } // Unmarshal the JSON string into a repo struct @@ -67,7 +67,7 @@ func TestFetchForkedReposPage(t *testing.T) { w, `[{"full_name": "example/forkedrepo",`+ `"html_url": "https://github.com/example/forkedrepo", "fork": true,`+ - `"owner": {"name": "example"}, "created_at": "2020-01-01T00:00:00Z"}]`) + `"owner": {"name": "example"}, "updated_at": "2020-01-01T00:00:00Z"}]`) })) defer mockServer.Close() @@ -79,7 +79,7 @@ func TestFetchForkedReposPage(t *testing.T) { Owner: struct { Name string `json:"name"` }{Name: "example"}, - CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), }, } @@ -98,7 +98,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) } } @@ -114,11 +114,11 @@ func TestFetchForkedRepos(t *testing.T) { w, `[{"full_name": "example/forkedrepo",`+ `"html_url": "https://test.com/example/forkedrepo", "fork": true,`+ - `"owner": {"name": "example"}, "created_at": "2020-01-01T00:00:00Z"},`+ + `"owner": {"name": "example"}, "updated_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"}]`) + `"owner": {"name": "example2"}, "updated_at": "2020-01-01T00:00:00Z"}]`) })) @@ -132,7 +132,7 @@ func TestFetchForkedRepos(t *testing.T) { Owner: struct { Name string `json:"name"` }{Name: "example"}, - CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), }, { Name: "example/forkedrepo2", @@ -141,7 +141,7 @@ func TestFetchForkedRepos(t *testing.T) { Owner: struct { Name string `json:"name"` }{Name: "example2"}, - CreatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), }, } @@ -166,7 +166,7 @@ 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.UpdatedAt.Equal(expected[i].UpdatedAt) { t.Errorf("Expected repo %+v, got %+v", expected[i], repo) } } @@ -219,9 +219,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 { @@ -390,7 +391,7 @@ func TestCLI_Success(t *testing.T) { withFlagErrorHandling(mockFlagErrorHandler) // 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) From e50cd96ab40de72bb6babfce412e30b3007c6af6 Mon Sep 17 00:00:00 2001 From: redowan Date: Fri, 15 Mar 2024 21:21:34 +0100 Subject: [PATCH 2/7] Use repo name instead of full_name, refs #3 --- src/cli.go | 2 +- src/cli_test.go | 73 +++++++++++++++++++++++++++---------------------- 2 files changed, 42 insertions(+), 33 deletions(-) diff --git a/src/cli.go b/src/cli.go index 4f37765..1ff1259 100644 --- a/src/cli.go +++ b/src/cli.go @@ -21,7 +21,7 @@ const ( ) type repo struct { - Name string `json:"full_name"` + Name string `json:"name"` URL string `json:"html_url"` IsFork bool `json:"fork"` Owner struct { diff --git a/src/cli_test.go b/src/cli_test.go index 7c55bc9..31a0086 100644 --- a/src/cli_test.go +++ b/src/cli_test.go @@ -19,24 +19,24 @@ 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", + "name": "test-repo", + "html_url": "https://github.com/test-owner/test-repo", "fork": false, "owner": { - "name": "example" + "name": "test-owner" }, "updated_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: "example", + Name: "test-owner", }, UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), } @@ -65,26 +65,34 @@ 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"}, "updated_at": "2020-01-01T00:00:00Z"}]`) + `[{"name": "test-forked-repo",`+ + `"html_url": "https://github.com/test-owner/test-forked-repo", "fork": true,`+ + `"owner": {"name": "test-owner"}, "updated_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: "test-owner"}, UpdatedAt: 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 + 60, // olderThanDays + ) + if err != nil { t.Fatalf("fetchForkedReposPage returned an error: %v", err) } @@ -112,13 +120,13 @@ 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"}, "updated_at": "2020-01-01T00:00:00Z"},`+ + `[{"name": "test-repo-1",`+ + `"html_url": "https://test.com/test-owner/test-repo-1", "fork": true,`+ + `"owner": {"name": "test-owner"}, "updated_at": "2020-01-01T00:00:00Z"},`+ - `{"full_name": "example/forkedrepo2",`+ - `"html_url": "https://test.com/example/forkedrepo2", "fork": true,`+ - `"owner": {"name": "example2"}, "updated_at": "2020-01-01T00:00:00Z"}]`) + `{"name": "test-repo-2",`+ + `"html_url": "https://test.com/test-owner/test-repo-2", "fork": true,`+ + `"owner": {"name": "test-owner"}, "updated_at": "2020-01-01T00:00:00Z"}]`) })) @@ -126,33 +134,34 @@ func TestFetchForkedRepos(t *testing.T) { 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: "test-owner"}, UpdatedAt: 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: "test-owner"}, UpdatedAt: 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 + 60, // olderThanDays + ) if err != nil { t.Fatalf("fetchForkedRepos returned an error: %v", err) } From f0186134c4221461ffc7d9d5a83504bd92a09b1f Mon Sep 17 00:00:00 2001 From: redowan Date: Fri, 15 Mar 2024 21:24:05 +0100 Subject: [PATCH 3/7] Add param name comments, refs #3 --- src/cli.go | 30 ++++++++++++++++-------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/src/cli.go b/src/cli.go index 1ff1259..5f42374 100644 --- a/src/cli.go +++ b/src/cli.go @@ -87,13 +87,14 @@ func fetchForkedRepos( 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 + olderThanDays, // olderThanDays + ) if err != nil { return nil, err @@ -287,13 +288,14 @@ func (c *cliConfig) CLI(args []string) int { // Fetching repositories fmt.Fprintf(stdout, "\nFetching 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 + olderThanDays, // olderThanDays + ) if err != nil { switch err.Error() { From 362c063b92dc6a2d1d70918b0709fd7eaf9fa274 Mon Sep 17 00:00:00 2001 From: redowan Date: Sun, 17 Mar 2024 20:36:03 +0100 Subject: [PATCH 4/7] Add --guard arugment to protect repositories, refs #3 --- src/cli.go | 179 +++++++++++++++++++++++++++++++++--------------- src/cli_test.go | 145 +++++++++++++++++++++++++++++++++++---- 2 files changed, 258 insertions(+), 66 deletions(-) diff --git a/src/cli.go b/src/cli.go index 5f42374..d49a81f 100644 --- a/src/cli.go +++ b/src/cli.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "net/http" + "strings" "sync" "time" ) @@ -15,9 +16,9 @@ const ( 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" + userNotFoundMsg = "API request failed with status: 404" + invalidTokenMsg = "API request failed with status: 401" + insufficientTokenPermissionMsg = "API request failed with status: 403" ) type repo struct { @@ -25,7 +26,7 @@ type repo struct { URL string `json:"html_url"` IsFork bool `json:"fork"` Owner struct { - Name string `json:"name"` + Name string `json:"login"` } `json:"owner"` UpdatedAt time.Time `json:"updated_at"` } @@ -42,8 +43,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", @@ -63,15 +63,11 @@ func fetchForkedReposPage( } var forkedRepos []repo - - cutOffDate := time.Now().AddDate(0, 0, -olderThanDays) - - for _, repo := range repos { - if repo.IsFork && repo.UpdatedAt.Before(cutOffDate) { - forkedRepos = append(forkedRepos, repo) + for _, r := range repos { + if r.IsFork { + forkedRepos = append(forkedRepos, r) } } - return forkedRepos, nil } @@ -81,19 +77,17 @@ 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, // ctx - baseURL, // baseURL - owner, // owner - token, // token - pageNum, // pageNum - perPage, // perPage - olderThanDays, // olderThanDays + ctx, // ctx + baseURL, // baseURL + owner, // owner + token, // token + pageNum, // pageNum + perPage, // perPage ) if err != nil { @@ -134,8 +128,43 @@ func doRequest(req *http.Request, token string, result any) error { 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 := make([]repo, 0), make([]repo, 0) + cutOffDate := time.Now().AddDate(0, 0, -olderThanDays) + + for _, repo := range forkedRepos { + if repo.UpdatedAt.After(cutOffDate) { + guardedRepos = append(guardedRepos, repo) + continue + } + + guarded := false + for _, guardedRepoName := range guardedRepoNames { + // Simple fuzzy match: check if protectedRepo is contained within repo.Name + if strings.Contains(strings.ToLower(repo.Name), strings.ToLower(guardedRepoName)) { + guarded = true + break + } + } + + if guarded { + 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 @@ -184,8 +213,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 +253,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 +276,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 +314,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 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,22 +341,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, // ctx - baseURL, // baseURL - owner, // owner - token, // token - perPage, // perPage - maxPage, // maxPage - olderThanDays, // olderThanDays + ctx, // ctx + baseURL, // baseURL + owner, // owner + token, // token + perPage, // perPage + maxPage, // maxPage ) if err != nil { switch err.Error() { - case errUserNotFound: + case userNotFoundMsg: fmt.Fprintf(stderr, "Error: user not found\n") - case errInvalidToken: + case invalidTokenMsg: fmt.Fprintf(stderr, "Error: invalid token\n") default: fmt.Fprintf(stderr, "Error: %s\n", err) @@ -313,21 +367,38 @@ 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) + } + + // 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 forked repositories + // 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 insufficientTokenPermissionMsg: fmt.Fprintf(stderr, "Error: token does not have permission to delete repos\n") default: fmt.Fprintf(stderr, "Error: %s\n", err) diff --git a/src/cli_test.go b/src/cli_test.go index 31a0086..e089ac0 100644 --- a/src/cli_test.go +++ b/src/cli_test.go @@ -23,7 +23,7 @@ func TestUnmarshalRepo(t *testing.T) { "html_url": "https://github.com/test-owner/test-repo", "fork": false, "owner": { - "name": "test-owner" + "login": "test-owner" }, "updated_at": "2020-01-01T00:00:00Z" }` @@ -34,7 +34,7 @@ func TestUnmarshalRepo(t *testing.T) { URL: "https://github.com/test-owner/test-repo", IsFork: false, Owner: struct { - Name string `json:"name"` + Name string `json:"login"` }{ Name: "test-owner", }, @@ -67,7 +67,7 @@ func TestFetchForkedReposPage(t *testing.T) { w, `[{"name": "test-forked-repo",`+ `"html_url": "https://github.com/test-owner/test-forked-repo", "fork": true,`+ - `"owner": {"name": "test-owner"}, "updated_at": "2020-01-01T00:00:00Z"}]`) + `"owner": {"login": "test-owner"}, "updated_at": "2020-01-01T00:00:00Z"}]`) })) defer mockServer.Close() @@ -77,7 +77,7 @@ func TestFetchForkedReposPage(t *testing.T) { URL: "https://github.com/test-owner/test-forked-repo", IsFork: true, Owner: struct { - Name string `json:"name"` + Name string `json:"login"` }{Name: "test-owner"}, UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), }, @@ -90,7 +90,6 @@ func TestFetchForkedReposPage(t *testing.T) { "test-token", // token 1, // pageNum 10, // perPage - 60, // olderThanDays ) if err != nil { @@ -122,11 +121,11 @@ func TestFetchForkedRepos(t *testing.T) { w, `[{"name": "test-repo-1",`+ `"html_url": "https://test.com/test-owner/test-repo-1", "fork": true,`+ - `"owner": {"name": "test-owner"}, "updated_at": "2020-01-01T00:00:00Z"},`+ + `"owner": {"login": "test-owner"}, "updated_at": "2020-01-01T00:00:00Z"},`+ `{"name": "test-repo-2",`+ `"html_url": "https://test.com/test-owner/test-repo-2", "fork": true,`+ - `"owner": {"name": "test-owner"}, "updated_at": "2020-01-01T00:00:00Z"}]`) + `"owner": {"login": "test-owner"}, "updated_at": "2020-01-01T00:00:00Z"}]`) })) @@ -138,7 +137,7 @@ func TestFetchForkedRepos(t *testing.T) { URL: "https://test.com/test-owner/test-repo-1", IsFork: true, Owner: struct { - Name string `json:"name"` + Name string `json:"login"` }{Name: "test-owner"}, UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), }, @@ -147,7 +146,7 @@ func TestFetchForkedRepos(t *testing.T) { URL: "https://test.com/test-owner/test-repo-2", IsFork: true, Owner: struct { - Name string `json:"name"` + Name string `json:"login"` }{Name: "test-owner"}, UpdatedAt: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), }, @@ -160,7 +159,6 @@ func TestFetchForkedRepos(t *testing.T) { "test-token", // token 10, // perPage 1, // maxPage - 60, // olderThanDays ) if err != nil { t.Fatalf("fetchForkedRepos returned an error: %v", err) @@ -247,6 +245,130 @@ func TestDoRequest(t *testing.T) { } } +func TestFilterForkedRepos_NoForksMatched(t *testing.T) { + now := time.Now() + forkedRepos := []repo{ + { + Name: "UnrelatedRepo", + URL: "http://example.com/1", + IsFork: true, + Owner: struct { + Name string `json:"login"` + }{Name: "owner1"}, + UpdatedAt: now.AddDate(0, 0, -10), + }, + } + guardedRepoNames := []string{"GuardedRepo"} + olderThanDays := 7 + + unguardedRepos, guardedRepos := filterForkedRepos(forkedRepos, guardedRepoNames, olderThanDays) + + if len(unguardedRepos) != 1 || len(guardedRepos) != 0 { + t.Fatalf("Expected 1 unguarded repo due to no match and 0 guarded repos, got %d unguarded, %d guarded", len(unguardedRepos), len(guardedRepos)) + } +} + +func TestFilterForkedRepos_AllReposGuarded(t *testing.T) { + now := time.Now() + forkedRepos := []repo{ + { + Name: "GuardedRepo1", + URL: "http://example.com/1", + IsFork: true, + Owner: struct { + Name string `json:"login"` + }{Name: "owner1"}, + UpdatedAt: now.AddDate(0, 0, -15), + }, + { + Name: "GuardedRepo2", + URL: "http://example.com/2", + IsFork: true, + Owner: struct { + Name string `json:"login"` + }{Name: "owner2"}, + UpdatedAt: now.AddDate(0, 0, -20), + }, + } + guardedRepoNames := []string{"GuardedRepo1", "GuardedRepo2"} + olderThanDays := 7 + + unguardedRepos, guardedRepos := filterForkedRepos(forkedRepos, guardedRepoNames, olderThanDays) + + if len(unguardedRepos) != 0 || len(guardedRepos) != 2 { + t.Fatalf("Expected 0 unguarded repos and 2 guarded repos, got %d unguarded, %d guarded", len(unguardedRepos), len(guardedRepos)) + } +} + +func TestFilterForkedRepos_RecentUpdateExclusion(t *testing.T) { + now := time.Now() + forkedRepos := []repo{ + { + Name: "RecentUpdateRepo", + URL: "http://example.com/1", + IsFork: true, + Owner: struct { + Name string `json:"login"` + }{Name: "owner1"}, + UpdatedAt: now.AddDate(0, 0, -3), + }, + } + guardedRepoNames := []string{"DoesNotMatter"} + olderThanDays := 7 + + unguardedRepos, guardedRepos := filterForkedRepos(forkedRepos, guardedRepoNames, olderThanDays) + + if len(unguardedRepos) != 0 || len(guardedRepos) != 1 { + t.Fatalf("Expected 0 unguarded repos and 1 guarded repo due to recent update, got %d unguarded, %d guarded", len(unguardedRepos), len(guardedRepos)) + } +} + +func TestFilterForkedRepos_GuardedByName(t *testing.T) { + now := time.Now() + forkedRepos := []repo{ + { + Name: "PartiallyGuardedRepo", + URL: "http://example.com/1", + IsFork: true, + Owner: struct { + Name string `json:"login"` + }{Name: "owner1"}, + UpdatedAt: now.AddDate(0, 0, -10), + }, + } + guardedRepoNames := []string{"Guarded", "PartiallyGuardedRepo"} + olderThanDays := 7 + + unguardedRepos, guardedRepos := filterForkedRepos(forkedRepos, guardedRepoNames, olderThanDays) + + if len(unguardedRepos) != 0 || len(guardedRepos) != 1 { + t.Fatalf("Expected 0 unguarded repos and 1 guarded repo by name, got %d unguarded, %d guarded", len(unguardedRepos), len(guardedRepos)) + } +} + +func TestFilterForkedRepos_BoundaryCheckOnUpdatedAt(t *testing.T) { + cutoff := time.Now().AddDate(0, 0, -7) + forkedRepos := []repo{ + { + Name: "OnTheEdgeRepo", + URL: "http://example.com/1", + IsFork: true, + Owner: struct { + Name string `json:"login"` + }{Name: "owner1"}, + UpdatedAt: cutoff, + }, + } + guardedRepoNames := []string{"OnTheEdgeRepo"} + olderThanDays := 7 + + unguardedRepos, guardedRepos := filterForkedRepos(forkedRepos, guardedRepoNames, olderThanDays) + + if len(unguardedRepos) != 0 || len(guardedRepos) != 1 { + t.Fatalf("Expected 0 unguarded repos and 1 guarded repo exactly on boundary, got %d unguarded, %d guarded", len(unguardedRepos), len(guardedRepos)) + } +} + func TestDeleteRepo(t *testing.T) { t.Parallel() // Setup a local HTTP test server @@ -308,8 +430,7 @@ var ( owner, token string, perPage, - maxPage, - olderThanDays int) ([]repo, error) { + maxPage int) ([]repo, error) { fmt.Println("mockFetchForkedRepos") return []repo{{Name: "test-repo"}}, nil } From c87fbf037aa91a2d0e35f7566d19811276c95df0 Mon Sep 17 00:00:00 2001 From: redowan Date: Sun, 17 Mar 2024 20:44:23 +0100 Subject: [PATCH 5/7] Compare against created, updated, pushed dates to protect repos, refs #3 --- src/cli.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/cli.go b/src/cli.go index d49a81f..cb78271 100644 --- a/src/cli.go +++ b/src/cli.go @@ -28,7 +28,9 @@ type repo struct { Owner struct { 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{ @@ -138,7 +140,8 @@ func filterForkedRepos( cutOffDate := time.Now().AddDate(0, 0, -olderThanDays) for _, repo := range forkedRepos { - if repo.UpdatedAt.After(cutOffDate) { + if repo.CreatedAt.After(cutOffDate) || + repo.UpdatedAt.After(cutOffDate) || repo.PushedAt.After(cutOffDate) { guardedRepos = append(guardedRepos, repo) continue } From ead620782f656caf9c711a804132629873f5f937 Mon Sep 17 00:00:00 2001 From: redowan Date: Mon, 18 Mar 2024 01:31:40 +0100 Subject: [PATCH 6/7] Add filtration logic, refs #3 --- src/cli.go | 51 ++++++----- src/cli_test.go | 237 ++++++++++++++++++++++++++++-------------------- 2 files changed, 167 insertions(+), 121 deletions(-) diff --git a/src/cli.go b/src/cli.go index cb78271..e6e4044 100644 --- a/src/cli.go +++ b/src/cli.go @@ -13,12 +13,14 @@ import ( ) const ( + // Exit codes exitOk = 0 exitErr = 1 - userNotFoundMsg = "API request failed with status: 404" - invalidTokenMsg = "API request failed with status: 401" - insufficientTokenPermissionMsg = "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 { @@ -64,6 +66,7 @@ func fetchForkedReposPage( return nil, err } + // Filter out non-forked repositories var forkedRepos []repo for _, r := range repos { if r.IsFork { @@ -136,26 +139,30 @@ func filterForkedRepos( guardedRepoNames []string, olderThanDays int) ([]repo, []repo) { - unguardedRepos, guardedRepos := make([]repo, 0), make([]repo, 0) - cutOffDate := time.Now().AddDate(0, 0, -olderThanDays) + 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 { - if repo.CreatedAt.After(cutOffDate) || - repo.UpdatedAt.After(cutOffDate) || repo.PushedAt.After(cutOffDate) { - guardedRepos = append(guardedRepos, repo) - continue - } + // 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) - guarded := false - for _, guardedRepoName := range guardedRepoNames { - // Simple fuzzy match: check if protectedRepo is contained within repo.Name - if strings.Contains(strings.ToLower(repo.Name), strings.ToLower(guardedRepoName)) { - guarded = true + if strings.TrimSpace(name) != "" && strings.Contains(repoName, name) { + isGuardedName = true break } } - if guarded { + if hasRecentActivity || isGuardedName { guardedRepos = append(guardedRepos, repo) } else { unguardedRepos = append(unguardedRepos, repo) @@ -356,9 +363,9 @@ func (c *cliConfig) CLI(args []string) int { if err != nil { switch err.Error() { - case userNotFoundMsg: + case ErrMsg404: fmt.Fprintf(stderr, "Error: user not found\n") - case invalidTokenMsg: + case ErrMsg401: fmt.Fprintf(stderr, "Error: invalid token\n") default: fmt.Fprintf(stderr, "Error: %s\n", err) @@ -377,13 +384,13 @@ func (c *cliConfig) CLI(args []string) int { olderThanDays) // Displaying safeguarded repositories - fmt.Fprintf(stdout, "\nGuarded forked repos (won't be deleted):\n") + fmt.Fprintf(stdout, "\nGuarded forked repos [won't be deleted]:\n") for _, repo := range guardedRepos { fmt.Fprintf(stdout, " - %s\n", repo.URL) } // Displaying unguarded repositories - fmt.Fprintf(stdout, "\nUnguarded forked repos (will be deleted):\n") + fmt.Fprintf(stdout, "\nUnguarded forked repos [will be deleted]:\n") for _, repo := range unguardedRepos { fmt.Fprintf(stdout, " - %s\n", repo.URL) } @@ -401,8 +408,10 @@ func (c *cliConfig) CLI(args []string) int { fmt.Fprintf(stdout, "\nDeleting forked repositories...\n") if err := deleteRepos(ctx, baseURL, token, unguardedRepos); err != nil { switch err.Error() { - case insufficientTokenPermissionMsg: + 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 e089ac0..f33731f 100644 --- a/src/cli_test.go +++ b/src/cli_test.go @@ -18,14 +18,16 @@ import ( func TestUnmarshalRepo(t *testing.T) { t.Parallel() // Example JSON string that represents a repo's data - jsonString := `{ + jsonStr := `{ "name": "test-repo", "html_url": "https://github.com/test-owner/test-repo", "fork": false, "owner": { "login": "test-owner" }, - "updated_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 @@ -38,12 +40,14 @@ func TestUnmarshalRepo(t *testing.T) { }{ 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) } @@ -66,8 +70,12 @@ func TestFetchForkedReposPage(t *testing.T) { fmt.Fprintln( w, `[{"name": "test-forked-repo",`+ - `"html_url": "https://github.com/test-owner/test-forked-repo", "fork": true,`+ - `"owner": {"login": "test-owner"}, "updated_at": "2020-01-01T00:00:00Z"}]`) + `"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() @@ -79,7 +87,9 @@ func TestFetchForkedReposPage(t *testing.T) { Owner: struct { 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), }, } @@ -120,13 +130,20 @@ func TestFetchForkedRepos(t *testing.T) { fmt.Fprintln( w, `[{"name": "test-repo-1",`+ - `"html_url": "https://test.com/test-owner/test-repo-1", "fork": true,`+ - `"owner": {"login": "test-owner"}, "updated_at": "2020-01-01T00:00:00Z"},`+ + `"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"}, "updated_at": "2020-01-01T00:00:00Z"}]`) - + `"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() @@ -139,7 +156,9 @@ func TestFetchForkedRepos(t *testing.T) { Owner: struct { 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: "test-repo-2", @@ -148,7 +167,9 @@ func TestFetchForkedRepos(t *testing.T) { Owner: struct { 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), }, } @@ -173,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.UpdatedAt.Equal(expected[i].UpdatedAt) { + !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) } } @@ -244,128 +267,124 @@ 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_NoForksMatched(t *testing.T) { +func TestFilterForkedRepos_AllGuarded(t *testing.T) { now := time.Now() forkedRepos := []repo{ - { - Name: "UnrelatedRepo", - URL: "http://example.com/1", - IsFork: true, - Owner: struct { - Name string `json:"login"` - }{Name: "owner1"}, - UpdatedAt: now.AddDate(0, 0, -10), - }, + {Name: "test-repo-1", CreatedAt: now, UpdatedAt: now, PushedAt: now}, + {Name: "test-repo-2", CreatedAt: now, UpdatedAt: now, PushedAt: now}, } - guardedRepoNames := []string{"GuardedRepo"} - olderThanDays := 7 - - unguardedRepos, guardedRepos := filterForkedRepos(forkedRepos, guardedRepoNames, olderThanDays) - - if len(unguardedRepos) != 1 || len(guardedRepos) != 0 { - t.Fatalf("Expected 1 unguarded repo due to no match and 0 guarded repos, got %d unguarded, %d guarded", len(unguardedRepos), len(guardedRepos)) + 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_AllReposGuarded(t *testing.T) { - now := time.Now() +func TestFilterForkedRepos_AllUnguardedDueToDate(t *testing.T) { forkedRepos := []repo{ { - Name: "GuardedRepo1", - URL: "http://example.com/1", - IsFork: true, - Owner: struct { - Name string `json:"login"` - }{Name: "owner1"}, - UpdatedAt: now.AddDate(0, 0, -15), - }, + 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: "GuardedRepo2", - URL: "http://example.com/2", - IsFork: true, - Owner: struct { - Name string `json:"login"` - }{Name: "owner2"}, - UpdatedAt: now.AddDate(0, 0, -20), - }, + 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{"GuardedRepo1", "GuardedRepo2"} - olderThanDays := 7 - - unguardedRepos, guardedRepos := filterForkedRepos(forkedRepos, guardedRepoNames, olderThanDays) + var guardedRepoNames []string + unguarded, guarded := filterForkedRepos(forkedRepos, guardedRepoNames, 10) - if len(unguardedRepos) != 0 || len(guardedRepos) != 2 { - t.Fatalf("Expected 0 unguarded repos and 2 guarded repos, got %d unguarded, %d guarded", len(unguardedRepos), len(guardedRepos)) + 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_RecentUpdateExclusion(t *testing.T) { - now := time.Now() +func TestFilterForkedRepos_UnknownGuardRepoName(t *testing.T) { forkedRepos := []repo{ { - Name: "RecentUpdateRepo", - URL: "http://example.com/1", - IsFork: true, - Owner: struct { - Name string `json:"login"` - }{Name: "owner1"}, - UpdatedAt: now.AddDate(0, 0, -3), - }, + 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{"DoesNotMatter"} - olderThanDays := 7 + guardedRepoNames := []string{"unknown-repo-1", "unknown-repo-2"} - unguardedRepos, guardedRepos := filterForkedRepos(forkedRepos, guardedRepoNames, olderThanDays) + unguarded, guarded := filterForkedRepos(forkedRepos, guardedRepoNames, 10) - if len(unguardedRepos) != 0 || len(guardedRepos) != 1 { - t.Fatalf("Expected 0 unguarded repos and 1 guarded repo due to recent update, got %d unguarded, %d guarded", len(unguardedRepos), len(guardedRepos)) + 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_GuardedByName(t *testing.T) { - now := time.Now() +func TestFilterForkedRepos_MixedGuardedUnguarded(t *testing.T) { forkedRepos := []repo{ { - Name: "PartiallyGuardedRepo", - URL: "http://example.com/1", - IsFork: true, - Owner: struct { - Name string `json:"login"` - }{Name: "owner1"}, - UpdatedAt: now.AddDate(0, 0, -10), - }, + 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{"Guarded", "PartiallyGuardedRepo"} - olderThanDays := 7 - - unguardedRepos, guardedRepos := filterForkedRepos(forkedRepos, guardedRepoNames, olderThanDays) - if len(unguardedRepos) != 0 || len(guardedRepos) != 1 { - t.Fatalf("Expected 0 unguarded repos and 1 guarded repo by name, got %d unguarded, %d guarded", len(unguardedRepos), len(guardedRepos)) + 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_BoundaryCheckOnUpdatedAt(t *testing.T) { - cutoff := time.Now().AddDate(0, 0, -7) +func TestFilterForkedRepos_CaseInsensitive(t *testing.T) { forkedRepos := []repo{ { - Name: "OnTheEdgeRepo", - URL: "http://example.com/1", - IsFork: true, - Owner: struct { - Name string `json:"login"` - }{Name: "owner1"}, - UpdatedAt: cutoff, - }, + Name: "Case-Sensitive-Repo", + CreatedAt: time.Now(), + UpdatedAt: time.Now(), + PushedAt: time.Now()}, } - guardedRepoNames := []string{"OnTheEdgeRepo"} - olderThanDays := 7 + 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)) + } +} - unguardedRepos, guardedRepos := filterForkedRepos(forkedRepos, guardedRepoNames, olderThanDays) +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"} - if len(unguardedRepos) != 0 || len(guardedRepos) != 1 { - t.Fatalf("Expected 0 unguarded repos and 1 guarded repo exactly on boundary, got %d unguarded, %d guarded", len(unguardedRepos), len(guardedRepos)) + 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)) } } @@ -435,6 +454,14 @@ var ( 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, @@ -473,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) @@ -493,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"}) @@ -518,7 +554,8 @@ 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-days", "30"} From 9447b01410a3465c4559103482a22fa93446e290 Mon Sep 17 00:00:00 2001 From: redowan Date: Mon, 18 Mar 2024 01:41:37 +0100 Subject: [PATCH 7/7] Update readme, refs #3 --- README.md | 71 +++++++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 53 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index a3accbb..57c7a2b 100644 --- a/README.md +++ b/README.md @@ -49,27 +49,43 @@ Remove unused GitHub forks ```txt Usage of fork-sweeper: - -delete - Delete forked repos - -max-page int - Maximum number of pages to fetch (default 100) - -older-than-days int - Fetch forked repos modified more than n days ago (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 - Print version + -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 modified more than n days ago (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 + 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