diff --git a/.github/workflows/build-and-push-image.yaml b/.github/workflows/build-and-push-image.yaml index 1504c89..5642fc2 100644 --- a/.github/workflows/build-and-push-image.yaml +++ b/.github/workflows/build-and-push-image.yaml @@ -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 diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 6fb5254..a3d9964 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -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 }}" diff --git a/Dockerfile b/Dockerfile index 594f470..591ae05 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.9-alpine +FROM python:3.11-alpine WORKDIR /application diff --git a/README.md b/README.md index a3ab6d3..2a4190c 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 }} @@ -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 }}'" ``` diff --git a/action.yml b/action.yml index 62cf4df..6de7e30 100644 --- a/action.yml +++ b/action.yml @@ -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 @@ -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 }}" diff --git a/main.py b/main.py index e20146b..1cc7d76 100644 --- a/main.py +++ b/main.py @@ -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}) diff --git a/src/actions.py b/src/actions.py index 1facfd0..774bddb 100644 --- a/src/actions.py +++ b/src/actions.py @@ -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: diff --git a/src/github.py b/src/github.py index 0ec3517..08aa162 100644 --- a/src/github.py +++ b/src/github.py @@ -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() @@ -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 @@ -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 @@ -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: @@ -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) @@ -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' @@ -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' diff --git a/src/io.py b/src/io.py index 3cf9ff1..8e1ad85 100644 --- a/src/io.py +++ b/src/io.py @@ -1,35 +1,90 @@ -import sys +import argparse from os import getenv -from typing import List +DEFAULT_GITHUB_API_URL = 'https://api.github.com' -def parse_input() -> (list, int, bool, str, str, str): - args: List[str] = sys.argv - num_args = len(args) +class Options: + def __init__( + self, + ignore_branches: list[str], + last_commit_age_days: int, + allowed_prefixes: list[str], + github_token: str, + github_repo: str, + dry_run: bool = True, + github_base_url: str = DEFAULT_GITHUB_API_URL + ): + self.ignore_branches = ignore_branches + self.last_commit_age_days = last_commit_age_days + self.allowed_prefixes = allowed_prefixes + self.github_token = github_token + self.github_repo = github_repo + self.dry_run = dry_run + self.github_base_url = github_base_url - if num_args < 4 or num_args > 6: - input_string = ' '.join(args) - expected_string = f'{args[0]} ignore_branches last_commit_age_days dry_run_yes_no github_token github_repo github_base_url' - raise RuntimeError(f'Incorrect input: {input_string}. Expected: {expected_string}') - branches_raw: str = args[1] - ignore_branches = branches_raw.split(',') - if ignore_branches == ['']: - ignore_branches = [] +class InputParser: + @staticmethod + def get_args() -> argparse.Namespace: + parser = argparse.ArgumentParser('Github Actions Delete Old Branches') - last_commit_age_days = int(args[2]) + parser.add_argument("--ignore-branches", help="Comma-separated list of branches to ignore") - # Dry run can only be either `true` or `false`, as strings due to github actions input limitations - dry_run = False if args[3] == 'no' else True + parser.add_argument( + "--allowed-prefixes", + help="Comma-separated list of prefixes a branch must match to be deleted" + ) - github_token = args[4] + parser.add_argument("--github-token", required=True) - github_repo = getenv('GITHUB_REPOSITORY') + parser.add_argument( + "--github-base-url", + default=DEFAULT_GITHUB_API_URL, + help="The API base url to be used in requests to GitHub Enterprise" + ) - github_base_url = args[5] if num_args >= 6 else 'https://api.github.com' + parser.add_argument( + "--last-commit-age-days", + help="How old in days must be the last commit into the branch for the branch to be deleted", + default=60, + type=int, + ) - return ignore_branches, last_commit_age_days, dry_run, github_token, github_repo, github_base_url + parser.add_argument( + "--dry-run", + choices=["yes", "no"], + default="yes", + help="Whether to delete branches at all. Defaults to 'yes'. Possible values: yes, no (case sensitive)" + ) + + return parser.parse_args() + + def parse_input(self) -> Options: + args = self.get_args() + + branches_raw: str = "" if args.ignore_branches is None else args.ignore_branches + ignore_branches = branches_raw.split(',') + if ignore_branches == ['']: + ignore_branches = [] + + allowed_prefixes_raw: str = "" if args.allowed_prefixes is None else args.allowed_prefixes + allowed_prefixes = allowed_prefixes_raw.split(',') + if allowed_prefixes == ['']: + allowed_prefixes = [] + + # Dry run can only be either `true` or `false`, as strings due to github actions input limitations + dry_run = False if args.dry_run == 'no' else True + + return Options( + ignore_branches=ignore_branches, + last_commit_age_days=args.last_commit_age_days, + allowed_prefixes=allowed_prefixes, + dry_run=dry_run, + github_token=args.github_token, + github_repo=getenv('GITHUB_REPOSITORY'), + github_base_url=args.github_base_url + ) def format_output(output_strings: dict) -> None: