Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
aeb18ab
Add support for limiting deletion to defined prefixes
jburnham Jun 27, 2023
300d8b1
Add prefix support to criteria in readme
jburnham Jun 28, 2023
455227f
Refactor input params into an object to remove functions with tons of…
luispabon Sep 11, 2023
c7577ec
Rename action::prefixes to allowed_prefixes
luispabon Sep 11, 2023
46d33dc
Upgrade to python 3.11
luispabon Sep 11, 2023
4dc7b43
Refactor further input handling by introducing actual arguments
luispabon Sep 11, 2023
fbd6489
Cleanup io
luispabon Sep 11, 2023
776194c
Tweak action definition to pass input using arguments
luispabon Sep 11, 2023
e32980d
Tweak test
luispabon Sep 11, 2023
a84a5be
Fix arg dash
luispabon Sep 11, 2023
aa2d205
Fix crash when option is undefined
luispabon Sep 11, 2023
2565bb9
Add another more complex test run
luispabon Sep 11, 2023
5d43fa3
Run action against v2 container
luispabon Sep 11, 2023
392036d
Update README
luispabon Sep 11, 2023
c4861f4
Fix allowed_prefixes parsing, again
luispabon Sep 11, 2023
5542951
Update README
luispabon Sep 11, 2023
fd30b0e
Tweak tests to pass env vars
luispabon Sep 11, 2023
b208929
Remove validate step as we're better covered now with better input pa…
luispabon Sep 11, 2023
3990dbe
Tweak README
luispabon Sep 11, 2023
6077b75
Remove required from commit age arg
luispabon Sep 11, 2023
a1526a0
Add extra test with all defaults and ensure we pass the GITHUB_OUTPUT…
luispabon Sep 11, 2023
baae433
More arg parsing fixes
luispabon Sep 11, 2023
5523ea8
Fix command on test
luispabon Sep 11, 2023
46a1054
Flesh out tests
luispabon Sep 11, 2023
87851ee
Update README examples with v2
luispabon Sep 11, 2023
e67250c
Better test step names
luispabon Sep 11, 2023
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
2 changes: 1 addition & 1 deletion .github/workflows/build-and-push-image.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,4 @@ jobs:
uses: docker/build-push-action@v4
with:
push: true
tags: phpdockerio/github-actions-delete-abandoned-branches:v1
tags: phpdockerio/github-actions-delete-abandoned-branches:v2
47 changes: 36 additions & 11 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,41 @@ jobs:
name: Runs the action with no ignore branches
steps:
- name: Checkout
uses: actions/checkout@v2
uses: actions/checkout@v3

- name: Run our action
uses: ./
id: delete_stuff
with:
github_token: ${{ github.token }}
last_commit_age_days: 9
dry_run: yes
ignore_branches: deletable-but-ignored
- name: Build action container
run: docker build -t action_container .

- name: Get output
run: "echo 'Deleted branches: ${{ steps.delete_stuff.outputs.deleted_branches }}'"
- name: "Test: default options"
run: |
docker run --rm -t \
-e GITHUB_REPOSITORY \
-e GITHUB_OUTPUT \
-v "${GITHUB_OUTPUT}:${GITHUB_OUTPUT}" \
action_container \
--github-token="${{ github.token }}"

- name: "Test: ignore branch 'test_prefix/two'"
run: |
docker run --rm -t \
-e GITHUB_REPOSITORY \
-e GITHUB_OUTPUT \
-v "${GITHUB_OUTPUT}:${GITHUB_OUTPUT}" \
action_container \
--ignore-branches="test_prefix/two" \
--last-commit-age-days=9 \
--dry-run=yes \
--github-token="${{ github.token }}"

- name: "Test: allow only`test_prefix/*` except for `test_prefix/two`"
run: |
docker run --rm -t \
-e GITHUB_REPOSITORY \
-e GITHUB_OUTPUT \
-v "${GITHUB_OUTPUT}:${GITHUB_OUTPUT}" \
action_container \
--allowed-prefixes=test_prefix/ \
--ignore-branches=test_prefix/two \
--last-commit-age-days=9 \
--dry-run=yes \
--github-token="${{ github.token }}"
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
FROM python:3.9-alpine
FROM python:3.11-alpine

WORKDIR /application

Expand Down
54 changes: 45 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,19 +17,21 @@ A branch must meet all the following criteria to be deemed abandoned and safe to
* Must NOT be the base of an open pull request of another branch. The base of a pull request is the branch you told
GitHub you want to merge your pull request into.
* Must NOT be in an optional list of branches to ignore
* Must match one of the given branch prefixes (optional)
* Must be older than a given amount of days

## Inputs

`* mandatory`

| Name | Description | Example |
| ------------- | ------------- | ------------- |
| `ignore_branches` | Comma-separated list of branches to ignore and never delete. You don't need to add your protected branches here. | `foo,bar`
| `last_commit_age_days` | How old in days must be the last commit into the branch for the branch to be deleted. Default: `60` | `90`
| `dry_run`* | Whether we're actually deleting branches at all. Possible values: `yes, no` (case sensitive). Default: `yes` | `no`
| `github_token`* | The github token to use on requests to the github api. You can use the one github actions provide | `${{ github.token }}`
| `github_base_url` | The github API's base url. You only need to override this when using Github Enterprise on a different domain. Default: `https://api.github.com` | `https://github.mycompany.com/api/v3`
| Name | Description | Example |
|------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------|
| `github_token`* | **Required.** The github token to use on requests to the github api. You can use the one github actions provide. | `${{ github.token }}` |
| `last_commit_age_days` | How old in days must be the last commit into the branch for the branch to be deleted. **Default:** `60` | `90` |
| `ignore_branches` | Comma-separated list of branches to ignore and never delete. You don't need to add your protected branches here. **Default:** `null` | `foo,bar` |
| `allowed_prefixes` | Comma-separated list of prefixes a branch must match to be deleted. **Default:** `null` | `feature/,bugfix/` |
| `dry_run` | Whether we're actually deleting branches at all. **Possible values:** `yes, no` (case sensitive). **Default:** `yes` | `no` |
| `github_base_url` | The github API's base url. You only need to override this when using Github Enterprise on a different domain. **Default:** `https://api.github.com` | `https://github.mycompany.com/api/v3` |

### Note: dry run

Expand All @@ -40,7 +42,7 @@ correctly before setting `dry_run` to `no`
## Example

The following workflow will run on a schedule (daily at 00:00) and will delete all abandoned branches older than 100
days:
days on a github enterprise install.

```yaml
name: Delete abandoned branches
Expand All @@ -59,7 +61,7 @@ jobs:
name: Satisfy my repo CDO
steps:
- name: Delete those pesky dead branches
uses: phpdocker-io/github-actions-delete-abandoned-branches@v1
uses: phpdocker-io/github-actions-delete-abandoned-branches@v2
id: delete_stuff
with:
github_token: ${{ github.token }}
Expand All @@ -72,5 +74,39 @@ jobs:

- name: Get output
run: "echo 'Deleted branches: ${{ steps.delete_stuff.outputs.deleted_branches }}'"
```

The following workflow will run on a schedule (daily at 13:00) and will delete all abandoned branches older than 7 days
that are prefixed with `feature/` and `deleteme/`, leaving all the rest.

```yaml
name: Delete abandoned branches

on:
# Run daily at midnight
schedule:
- cron: "0 13 * * *"

# Allow workflow to be manually run from the GitHub UI
workflow_dispatch:

jobs:
cleanup_old_branches:
runs-on: ubuntu-latest
name: Satisfy my repo CDO
steps:
- name: Delete those pesky dead branches
uses: phpdocker-io/github-actions-delete-abandoned-branches@v2
id: delete_stuff
with:
github_token: ${{ github.token }}
last_commit_age_days: 7
allowed_prefixes: feature/,deleteme/
ignore_branches: next-version,dont-deleteme

# Disable dry run and actually get stuff deleted
dry_run: no

- name: Get output
run: "echo 'Deleted branches: ${{ steps.delete_stuff.outputs.deleted_branches }}'"
```
17 changes: 11 additions & 6 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ inputs:
description: "How old in days must be the last commit into the branch for the branch to be deleted."
required: false
default: "60"
allowed_prefixes:
description: "Comma-separated list of prefixes a branch must match to be deleted."
required: false
default: ""
dry_run:
description: "Whether we're actually deleting branches at all. Defaults to 'yes'. Possible values: yes, no (case sensitive)"
required: true
Expand All @@ -33,10 +37,11 @@ outputs:

runs:
using: 'docker'
image: 'docker://phpdockerio/github-actions-delete-abandoned-branches:v1'
image: 'docker://phpdockerio/github-actions-delete-abandoned-branches:v2'
args:
- ${{ inputs.ignore_branches }}
- ${{ inputs.last_commit_age_days }}
- ${{ inputs.dry_run }}
- ${{ inputs.github_token }}
- ${{ inputs.github_base_url }}
- --ignore-branches="${{ inputs.ignore_branches }}"
- --last-commit-age-days="${{ inputs.last_commit_age_days }}"
- --allowed-prefixes=${{ inputs.allowed_prefixes }}
- --dry-run="${{ inputs.dry_run }}"
- --github-token="${{ inputs.github_token }}"
- --github-base-url="${{ inputs.github_base_url }}"
14 changes: 3 additions & 11 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,7 @@
from src import actions, io
from src.io import InputParser

if __name__ == '__main__':
ignore_branches, last_commit_age_days, dry_run, github_token, github_repo, github_base_url = io.parse_input()

deleted_branches = actions.run_action(
ignore_branches=ignore_branches,
last_commit_age_days=last_commit_age_days,
dry_run=dry_run,
github_repo=github_repo,
github_token=github_token,
github_base_url=github_base_url
)

options = InputParser().parse_input()
deleted_branches = actions.run_action(options)
io.format_output({'deleted_branches': deleted_branches})
30 changes: 10 additions & 20 deletions src/actions.py
Original file line number Diff line number Diff line change
@@ -1,30 +1,20 @@
from src.github import Github
from src.io import Options


def run_action(
github_repo: str,
ignore_branches: list,
last_commit_age_days: int,
github_token: str,
github_base_url: str,
dry_run: bool = True
) -> list:
input_data = {
'github_repo': github_repo,
'ignore_branches': ignore_branches,
'last_commit_age_days': last_commit_age_days,
'dry_run': dry_run,
'github_base_url': github_base_url
}
def run_action(options: Options) -> list:
print(f"Starting github action to cleanup old branches. Input: {options}")

print(f"Starting github action to cleanup old branches. Input: {input_data}")
github = Github(repo=options.github_repo, token=options.github_token, base_url=options.github_base_url)

github = Github(github_repo=github_repo, github_token=github_token, github_base_url=github_base_url)

branches = github.get_deletable_branches(last_commit_age_days=last_commit_age_days, ignore_branches=ignore_branches)
branches = github.get_deletable_branches(
last_commit_age_days=options.last_commit_age_days,
ignore_branches=options.ignore_branches,
allowed_prefixes=options.allowed_prefixes
)

print(f"Branches queued for deletion: {branches}")
if dry_run is False:
if options.dry_run is False:
print('This is NOT a dry run, deleting branches')
github.delete_branches(branches=branches)
else:
Expand Down
46 changes: 31 additions & 15 deletions src/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,28 @@

from src import requests


class Github:
def __init__(self, github_repo: str, github_token: str, github_base_url: str):
self.github_token = github_token
self.github_repo = github_repo
self.github_base_url = github_base_url
def __init__(self, repo: str, token: str, base_url: str):
self.token = token
self.repo = repo
self.base_url = base_url

def make_headers(self) -> dict:
return {
'authorization': f'Bearer {self.github_token}',
'authorization': f'Bearer {self.token}',
'content-type': 'application/vnd.github.v3+json',
}

def get_paginated_branches_url(self, page: int = 0) -> str:
return f'{self.github_base_url}/repos/{self.github_repo}/branches?protected=false&per_page=30&page={page}'

def get_deletable_branches(self, last_commit_age_days: int, ignore_branches: list) -> list:
return f'{self.base_url}/repos/{self.repo}/branches?protected=false&per_page=30&page={page}'

def get_deletable_branches(
self,
last_commit_age_days: int,
ignore_branches: list[str],
allowed_prefixes: list[str]
) -> list[str]:
# Default branch might not be protected
default_branch = self.get_default_branch()

Expand All @@ -42,7 +48,7 @@ def get_deletable_branches(self, last_commit_age_days: int, ignore_branches: lis

print(f'Analyzing branch `{branch_name}`...')

# Immediately discard protected branches, default branch and ignored branches
# Immediately discard protected branches, default branch, ignored branches and branches not in prefix
if branch_name == default_branch:
print(f'Ignoring `{branch_name}` because it is the default branch')
continue
Expand All @@ -57,9 +63,19 @@ def get_deletable_branches(self, last_commit_age_days: int, ignore_branches: lis
print(f'Ignoring `{branch_name}` because it is on the list of ignored branches')
continue

# If allowed_prefixes are provided, only consider branches that match one of the prefixes
if len(allowed_prefixes) > 0:
found_prefix = False
for prefix in allowed_prefixes:
if branch_name.startswith(prefix):
found_prefix = True
if found_prefix is False:
print(f'Ignoring `{branch_name}` because it does not match any provided allowed_prefixes')
continue

# Move on if commit is in an open pull request
if self.has_open_pulls(commit_hash=commit_hash):
print(f'Ignoring `{branch_name}` because it has open pulls')
print(f'Ignoring `{branch_name}` because it has open pull requests')
continue

# Move on if branch is base for a pull request
Expand All @@ -86,10 +102,10 @@ def get_deletable_branches(self, last_commit_age_days: int, ignore_branches: lis

return deletable_branches

def delete_branches(self, branches: list) -> None:
def delete_branches(self, branches: list[str]) -> None:
for branch in branches:
print(f'Deleting branch `{branch}`...')
url = f'{self.github_base_url}/repos/{self.github_repo}/git/refs/heads/{branch.replace("#", "%23")}'
url = f'{self.base_url}/repos/{self.repo}/git/refs/heads/{branch.replace("#", "%23")}'

response = requests.request(method='DELETE', url=url, headers=self.make_headers())
if response.status_code != 204:
Expand All @@ -99,7 +115,7 @@ def delete_branches(self, branches: list) -> None:
print(f'Branch `{branch}` DELETED!')

def get_default_branch(self) -> str:
url = f'{self.github_base_url}/repos/{self.github_repo}'
url = f'{self.base_url}/repos/{self.repo}'
headers = self.make_headers()

response = requests.get(url=url, headers=headers)
Expand All @@ -113,7 +129,7 @@ def has_open_pulls(self, commit_hash: str) -> bool:
"""
Returns true if commit is part of an open pull request or the branch is the base for a pull request
"""
url = f'{self.github_base_url}/repos/{self.github_repo}/commits/{commit_hash}/pulls'
url = f'{self.base_url}/repos/{self.repo}/commits/{commit_hash}/pulls'
headers = self.make_headers()
headers['accept'] = 'application/vnd.github.groot-preview+json'

Expand All @@ -132,7 +148,7 @@ def is_pull_request_base(self, branch: str) -> bool:
"""
Returns true if the given branch is base for another pull request.
"""
url = f'{self.github_base_url}/repos/{self.github_repo}/pulls?base={branch}'
url = f'{self.base_url}/repos/{self.repo}/pulls?base={branch}'
headers = self.make_headers()
headers['accept'] = 'application/vnd.github.groot-preview+json'

Expand Down
Loading