Problem
GitLab has a concept of stale branches, where they defined all the branches updated more than 3 months ago as stale.
However, it's not possible to do a bulk removal or have a more sophisticated control of these branches.
Here is a full example of how you can set this up. As for scheduled tasks you can use Gitlab scheduled jobs.
Code
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
remove-stale-branches: | |
needs: [] | |
image: python:slim | |
variables: | |
THRESHOLD_PERIOD_DAYS: 30 | |
STALE_PROJECT_NAMESPACES: "$CI_PROJECT_PATH" | |
parallel: | |
matrix: | |
- STALE_PROJECT_NAMESPACES: "foo/baz" | |
STALE_EXCLUSION_PATTERNS: "example/.*" | |
rules: | |
- if: "$CI_PIPELINE_SOURCE == 'schedule'" | |
when: always | |
script: | |
- pip install requests | |
- python3 clean-stale-branches.py --project-namespace=$STALE_PROJECT_NAMESPACES --days-threshold=$THRESHOLD_PERIOD_DAYS --ignore-patterns=$STALE_EXCLUSION_PATTERNS |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import os | |
import requests | |
from urllib.parse import quote | |
import re | |
import argparse | |
from datetime import datetime, timedelta, timezone | |
def get_arguments(): | |
parser = argparse.ArgumentParser(description="Remove outdated GitLab branches based on specified criteria.") | |
parser.add_argument("--days-threshold", type=int, required=True, default=30, help="Number of days to consider a branch stale.") | |
parser.add_argument("--namespace", required=True, help="Namespace of the GitLab project.") | |
parser.add_argument("--ignore-patterns", help="Comma-separated string of patterns to exclude branches from deletion.") | |
return parser.parse_args() | |
def main(): | |
args = get_arguments() | |
days_limit = args.days_threshold | |
project_space = args.namespace | |
namespace_encoded = quote(project_space, safe='') | |
access_token = os.environ.get("GITLAB_ACCESS_TOKEN") | |
if access_token is None: | |
print("Error: Missing GitLab access token in environment variables.") | |
exit(1) | |
request_headers = {"PRIVATE-TOKEN": access_token} | |
ignore_list = args.ignore_patterns.split(",") if args.ignore_patterns else [] | |
ignore_regex = [re.compile(pattern) for pattern in ignore_list] | |
group_api_url = f"https://gitlab.com/api/v4/groups/{namespace_encoded}" | |
project_api_url = f"https://gitlab.com/api/v4/projects/{namespace_encoded}" | |
group_response = requests.get(group_api_url, headers=request_headers) | |
if group_response.status_code == 404: | |
print(f"Identified {namespace_encoded} as a project") | |
project_info = requests.get(project_api_url, headers=request_headers).json() | |
handle_project(ignore_regex, project_info['id'], request_headers, days_limit, project_info['name']) | |
else: | |
print(f"Identified {namespace_encoded} as a group, processing all contained projects") | |
group_info = group_response.json() | |
for project in group_info['projects']: | |
handle_project(ignore_regex, project['id'], request_headers, days_limit, project['name']) | |
def handle_project(ignore_regex, project_id, headers, days_limit, project_name): | |
print("=========") | |
print(f"Working on project {project_name}") | |
branches_api_url = f"https://gitlab.com/api/v4/projects/{project_id}/repository/branches?per_page=100" | |
branches_response = requests.get(branches_api_url, headers=headers) | |
if branches_response.status_code == 200: | |
branches_info = branches_response.json() | |
stale_date = datetime.utcnow() - timedelta(days=days_limit) | |
stale_date = stale_date.replace(tzinfo=timezone.utc) | |
stale_branches = [ | |
branch for branch in branches_info | |
if | |
(datetime.fromisoformat(branch['commit']['committed_date']).replace(tzinfo=timezone.utc) < stale_date) | |
and not any(regex.match(branch['name']) for regex in ignore_regex) | |
and not branch.get('protected', True) | |
] | |
print(f"Branches not updated in over {days_limit} days, count:", len(stale_branches)) | |
print(f"Branches to be deleted:\n", [branch['name'] for branch in stale_branches]) | |
for branch in stale_branches: | |
branch_encoded = quote(branch['name'], safe='') | |
delete_api_url = f"https://gitlab.com/api/v4/projects/{project_id}/repository/branches/{branch_encoded}" | |
delete_response = requests.delete(delete_api_url, headers=headers) | |
if delete_response.status_code == 204: | |
print(f"Successfully deleted branch '{branch['name']}'.") | |
else: | |
print(f"Failed to delete branch '{branch['name']}': Status Code {delete_response.status_code}") | |
else: | |
print(f"Error: Unable to fetch branches. Status Code: {branches_response.status_code}") | |
exit(1) | |
if __name__ == "__main__": | |
main() |
Top comments (0)