Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: allow glob support for allowed branches, add --ignored-prefixes flag #6

Merged
merged 3 commits into from
Jan 17, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/stale-branches.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ jobs:
with:
token: ${{ secrets.GITHUB_TOKEN }}
repository: ${{ github.repository }}
dry-run: false

21 changes: 11 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 }}
Expand Down
24 changes: 19 additions & 5 deletions cmd/cleanup-stale-branches-action/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"fmt"
"log"
"os"
"regexp"
"strings"
"time"

Expand All @@ -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 {
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
}
Expand Down
16 changes: 12 additions & 4 deletions cmd/cleanup-stale-branches-action/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
})
}
Expand Down
Loading