diff --git a/.github/workflows/stale-branches.yml b/.github/workflows/stale-branches.yml index 1c65fd1..fe50fec 100644 --- a/.github/workflows/stale-branches.yml +++ b/.github/workflows/stale-branches.yml @@ -14,4 +14,5 @@ jobs: with: token: ${{ secrets.GITHUB_TOKEN }} repository: ${{ github.repository }} + dry-run: false diff --git a/README.md b/README.md index 744aee8..afa362a 100644 --- a/README.md +++ b/README.md @@ -12,13 +12,13 @@ This GitHub Action deems a branch as stale or abandoned based on the following criteria: -- **Not Default Branch**: The branch is not the repository's default branch. -- **Not Protected**: The branch is not a protected branch. -- **No Open Pull Requests**: There are no open pull requests that originate from the branch. -- **Not Base of an Open Pull Request**: The branch is not the base branch for any open pull requests. -- **Not in Ignore List**: The branch is not included in the optional list of branches to ignore. -- **Branch Prefix Match**: If specified, the branch name matches one of the given prefixes. -- **Age**: The branch's last commit is older than the specified number of days (e.g., no commits for 30 days). +- 🚫 **Not Default Branch**: The branch is not the repository's default branch. +- 🛡️ **Not Protected**: The branch is not a protected branch. +- 📭 **No Open Pull Requests**: There are no open pull requests that originate from the branch. +- 🔀 **Not Base of an Open Pull Request**: The branch is not the base branch for any open pull requests. +- 🚫 **Not in Ignore List**: The branch is not included in the optional list of branches to ignore. +- ❌ **(No) Branch Prefix Match**: If specified, the branch name does (not) match one of the given prefixes. +- ⏰ **Latest Commit Age**: The branch's last commit is older than the specified number of days (e.g., no commits for 30 days). Branches that meet all these criteria are considered as stale or abandoned and eligible for deletion. @@ -28,9 +28,10 @@ Branches that meet all these criteria are considered as stale or abandoned and e - `repository`: **Required** - The target GitHub repository in the format "owner/repo". - `ignore-branches`: Optional - Comma-separated list of branches to ignore from deletion. - `allowed-prefixes`: Optional - Comma-separated list of prefixes a branch must match to be considered for deletion. +- `ignored-prefixes`: Optional - Comma-separated list of prefixes a branch must NOT match to be considered for deletion. - `last-commit-age-days`: Optional - Number of days since the last commit for a branch to be considered abandoned. Defaults to `30` days. - `dry-run`: Optional - Perform a dry run without actually deleting branches. Defaults to `true`, meaning no branches will be deleted. -- `rate-limit`: Optional - Stop the action if it exceeds 95% of the GitHub API rate limit. Defaults to `true`, ensuring the action is halted before hitting the rate limit. +- `rate-limit`: Optional - Stop the action if it exceeds 95% of the GitHub API rate limit. Defaults to `true`, ensuring the action is halted before hitting the rate limit e.g. exiting with status code `0` instead of failing. ### Workflow Usage @@ -75,7 +76,7 @@ jobs: token: ${{ secrets.GITHUB_TOKEN }} repository: ${{ github.repository }} ignore-branches: "foobar,release-*" - allowed-prefixes: "feature/,bugfix/" + ignore-prefixes: "feature/,bugfix/" last-commit-age-days: 60 dry-run: false rate-limit: true @@ -85,7 +86,7 @@ In this advanced example: * The action is scheduled to run daily. * It ignores the branch `foobar` and branches starting with `release-`. -* Only branches prefixed with `feature/` or `bugfix/` are considered for deletion. +* Branches prefixed with `feature/` or `bugfix/` are not considered for deletion. * Branches with no commits in the last `60` days are eligible for deletion. * The action is not in `dry-run` mode, meaning branches will actually be deleted. * The `rate-limit` check is enabled to prevent exceeding the GitHub API rate limit. diff --git a/action.yml b/action.yml index a7f3701..8e9b93e 100644 --- a/action.yml +++ b/action.yml @@ -17,6 +17,10 @@ inputs: description: 'Comma-separated list of prefixes a branch must match to be deleted' required: false default: "" + ignored-prefixes: + description: 'Comma-separated list of prefixes a branch must NOT match to be deleted' + required: false + default: "" last-commit-age-days: description: 'Number of days since the last commit for a branch to be considered abandoned' required: false @@ -42,6 +46,8 @@ runs: - ${{ inputs.ignore-branches }} - --allowed-prefixes - ${{ inputs.allowed-prefixes }} + - --ignored-prefixes + - ${{ inputs.ignored-prefixes }} - --last-commit-age-days=${{ inputs.last-commit-age-days }} - --dry-run=${{ inputs.dry-run }} - --rate-limit=${{ inputs.rate-limit }} diff --git a/cmd/cleanup-stale-branches-action/main.go b/cmd/cleanup-stale-branches-action/main.go index f98c89b..40ce242 100644 --- a/cmd/cleanup-stale-branches-action/main.go +++ b/cmd/cleanup-stale-branches-action/main.go @@ -5,6 +5,7 @@ import ( "fmt" "log" "os" + "regexp" "strings" "time" @@ -14,13 +15,14 @@ import ( ) var args struct { - IgnoreBranches string `arg:"--ignore-branches,env:IGNORE_BRANCHES"` AllowedPrefixes string `arg:"--allowed-prefixes,env:ALLOWED_PREFIXES"` - GithubToken string `arg:"--github-token,required,env:GITHUB_TOKEN"` + DryRun bool `arg:"--dry-run,env:DRY_RUN"` GithubRepo string `arg:"--github-repo,required,env:GITHUB_REPOSITORY"` + GithubToken string `arg:"--github-token,required,env:GITHUB_TOKEN"` + IgnoreBranches string `arg:"--ignore-branches,env:IGNORE_BRANCHES"` + IgnoredPrefixes string `arg:"--ignored-prefixes,env:IGNORED_PREFIXES"` LastCommitAgeDays int `arg:"--last-commit-age-days,env:LAST_COMMIT_AGE_DAYS"` RateLimit bool `arg:"--rate-limit,env:RATE_LIMIT"` - DryRun bool `arg:"--dry-run,env:DRY_RUN"` } type GitHubClientWrapper struct { @@ -121,6 +123,8 @@ func (g *GitHubClientWrapper) getDeletableBranches(ctx context.Context) ([]strin ignoreBranches := strings.Split(args.IgnoreBranches, ",") allowedPrefixes := strings.Split(args.AllowedPrefixes, ",") + ignoredPrefixes := strings.Split(args.IgnoredPrefixes, ",") + deletableBranches := []string{} opts := &github.BranchListOptions{ @@ -160,6 +164,11 @@ func (g *GitHubClientWrapper) getDeletableBranches(ctx context.Context) ([]strin continue } + if startsWith(ignoredPrefixes, branchName) { + log.Printf("- Skipping `%s`: matches an ignored prefix\n", branchName) + continue + } + commitDate, err := g.getLatestCommitDate(ctx, branchName) if err != nil { log.Printf("- Skipping `%s`: %v\n", branchName, err) @@ -284,8 +293,13 @@ func (g *GitHubClientWrapper) deleteBranches(ctx context.Context, branches []str } func contains(slice []string, item string) bool { - for _, v := range slice { - if v == item { + for _, pattern := range slice { + // Replace * with .*, which is the regex equivalent + regexPattern := "^" + regexp.QuoteMeta(pattern) + regexPattern = strings.ReplaceAll(regexPattern, "\\*", ".*") + "$" + + matched, _ := regexp.MatchString(regexPattern, item) + if matched { return true } } diff --git a/cmd/cleanup-stale-branches-action/main_test.go b/cmd/cleanup-stale-branches-action/main_test.go index 39c6f8f..d564ee9 100644 --- a/cmd/cleanup-stale-branches-action/main_test.go +++ b/cmd/cleanup-stale-branches-action/main_test.go @@ -73,16 +73,24 @@ func TestContains(t *testing.T) { item string want bool }{ - {"Present", []string{"a", "b", "c"}, "b", true}, - {"NotPresent", []string{"a", "b", "c"}, "d", false}, + {"ExactMatchPresent", []string{"a", "b", "c"}, "b", true}, + {"ExactMatchNotPresent", []string{"a", "b", "c"}, "d", false}, {"EmptySlice", []string{}, "a", false}, {"EmptyString", []string{"a", "b", ""}, "", true}, + {"WildcardSimpleMatch", []string{"foo*", "bar"}, "foobar", true}, + {"WildcardComplexMatch", []string{"release-*", "dev"}, "release-v1", true}, + {"WildcardNoMatch", []string{"test-*"}, "demo-test", false}, + {"WildcardEmptyMatch", []string{"test-*"}, "", false}, + {"WildcardOnly", []string{"*"}, "anything", true}, + {"MultipleWildcards", []string{"*foo*", "*bar*"}, "myfoobar", true}, + {"MiddleWildcard", []string{"fo*ar"}, "foobar", true}, + {"NonMatchingMultipleWildcards", []string{"*foo*", "*bar*"}, "baz", false}, + {"ExactMatchAmongWildcards", []string{"foo*", "exact", "*bar"}, "exact", true}, } - for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { if got := contains(tt.slice, tt.item); got != tt.want { - t.Errorf("contains() = %v, want %v", got, tt.want) + t.Errorf("contains() = %v, want %v for slice %v and item %v", got, tt.want, tt.slice, tt.item) } }) }