Skip to content
Draft
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,15 @@ The needed GitHub app permissions are the following under `Repository permission
| `LABELS` | False | "" | A comma separated list of labels that should be added to pull requests opened by dependabot. |
| `DEPENDABOT_CONFIG_FILE` | False | "" | Location of the configuration file for `dependabot.yml` configurations. If the file is present locally it takes precedence over the one in the repository. |

#### Rate Limiting

| field | required | default | description |
| ----------------------------------- | -------- | ------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `RATE_LIMIT_ENABLED` | False | true | If set to true, rate limiting will be enabled to prevent hitting GitHub API rate limits. It is recommended to keep this enabled to avoid workflow failures. |
| `RATE_LIMIT_REQUESTS_PER_SECOND` | False | 2.0 | Maximum number of requests per second to the GitHub API. Adjust this based on your API rate limits. Lower values are more conservative. |
| `RATE_LIMIT_BACKOFF_FACTOR` | False | 2.0 | Exponential backoff multiplier for retries when rate limits are hit. A value of 2.0 means wait times double with each retry (1s, 2s, 4s, etc.). |
| `RATE_LIMIT_MAX_RETRIES` | False | 3 | Maximum number of retry attempts when a rate limit error occurs. Set to 0 to disable retries. |

### Private repositories configuration

Dependabot allows the configuration of [private registries](https://docs.github.com/en/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file#configuration-options-for-private-registries) for dependabot to use.
Expand Down
64 changes: 64 additions & 0 deletions env.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,25 @@ def get_int_env_var(env_var_name: str) -> int | None:
return None


def get_float_env_var(env_var_name: str, default: float | None = None) -> float | None:
"""Get a float environment variable.

Args:
env_var_name: The name of the environment variable to retrieve.
default: The default value to return if the environment variable is not set.

Returns:
The value of the environment variable as a float or the default value.
"""
env_var = os.environ.get(env_var_name)
if env_var is None or not env_var.strip():
return default
try:
return float(env_var)
except ValueError:
return default


def parse_repo_specific_exemptions(repo_specific_exemptions_str: str) -> dict:
"""Parse the REPO_SPECIFIC_EXEMPTIONS environment variable into a dictionary.

Expand Down Expand Up @@ -126,6 +145,10 @@ def get_env_vars(
str | None,
list[str],
str | None,
bool,
float,
float,
int,
]:
"""
Get the environment variables for use in the action.
Expand Down Expand Up @@ -162,6 +185,10 @@ def get_env_vars(
team_name (str): The team to search for repositories in
labels (list[str]): A list of labels to be added to dependabot configuration
dependabot_config_file (str): Dependabot extra configuration file location path
rate_limit_enabled (bool): Whether rate limiting is enabled
rate_limit_requests_per_second (float): Maximum requests per second
rate_limit_backoff_factor (float): Exponential backoff factor for retries
rate_limit_max_retries (int): Maximum number of retry attempts
"""

if not test: # pragma: no cover
Expand Down Expand Up @@ -352,6 +379,39 @@ def get_env_vars(
f"No dependabot extra configuration found. Please create one in {dependabot_config_file}"
)

# Rate limiting configuration
rate_limit_enabled = get_bool_env_var("RATE_LIMIT_ENABLED", default=True)
rate_limit_requests_per_second_value = get_float_env_var(
"RATE_LIMIT_REQUESTS_PER_SECOND", default=2.0
)
rate_limit_backoff_factor_value = get_float_env_var(
"RATE_LIMIT_BACKOFF_FACTOR", default=2.0
)
rate_limit_max_retries_value = get_int_env_var("RATE_LIMIT_MAX_RETRIES")

# Ensure non-None values with defaults
rate_limit_requests_per_second = (
rate_limit_requests_per_second_value
if rate_limit_requests_per_second_value is not None
else 2.0
)
rate_limit_backoff_factor = (
rate_limit_backoff_factor_value
if rate_limit_backoff_factor_value is not None
else 2.0
)
rate_limit_max_retries = (
rate_limit_max_retries_value if rate_limit_max_retries_value is not None else 3
)

# Validate rate limiting parameters
if rate_limit_requests_per_second <= 0:
raise ValueError("RATE_LIMIT_REQUESTS_PER_SECOND must be greater than 0")
if rate_limit_backoff_factor <= 0:
raise ValueError("RATE_LIMIT_BACKOFF_FACTOR must be greater than 0")
if rate_limit_max_retries < 0:
raise ValueError("RATE_LIMIT_MAX_RETRIES must be 0 or greater")

return (
organization,
repositories_list,
Expand Down Expand Up @@ -382,4 +442,8 @@ def get_env_vars(
team_name,
labels_list,
dependabot_config_file,
rate_limit_enabled,
rate_limit_requests_per_second,
rate_limit_backoff_factor,
rate_limit_max_retries,
)
82 changes: 61 additions & 21 deletions evergreen.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import ruamel.yaml
from dependabot_file import build_dependabot_file
from exceptions import OptionalFileNotFoundError, check_optional_file
from rate_limiter import RateLimiter


def main(): # pragma: no cover
Expand Down Expand Up @@ -48,8 +49,20 @@ def main(): # pragma: no cover
team_name,
labels,
dependabot_config_file,
rate_limit_enabled,
rate_limit_requests_per_second,
rate_limit_backoff_factor,
rate_limit_max_retries,
) = env.get_env_vars()

# Initialize rate limiter
rate_limiter = RateLimiter(
requests_per_second=rate_limit_requests_per_second,
enabled=rate_limit_enabled,
backoff_factor=rate_limit_backoff_factor,
max_retries=rate_limit_max_retries,
)

# Auth to GitHub.com or GHE
github_connection = auth.auth_to_github(
token,
Expand All @@ -75,7 +88,9 @@ def main(): # pragma: no cover
raise ValueError(
"ORGANIZATION environment variable was not set. Please set it"
)
project_global_id = get_global_project_id(ghe, token, organization, project_id)
project_global_id = get_global_project_id(
ghe, token, organization, project_id, rate_limiter
)

# Get the repositories from the organization, team name, or list of repositories
repos = get_repos_iterator(
Expand Down Expand Up @@ -211,9 +226,11 @@ def main(): # pragma: no cover
# Get dependabot security updates enabled if possible
if enable_security_updates:
if not is_dependabot_security_updates_enabled(
ghe, repo.owner, repo.name, token
ghe, repo.owner, repo.name, token, rate_limiter
):
enable_dependabot_security_updates(ghe, repo.owner, repo.name, token)
enable_dependabot_security_updates(
ghe, repo.owner, repo.name, token, rate_limiter
)

if follow_up_type == "issue":
skip = check_pending_issues_for_duplicates(title, repo)
Expand All @@ -225,9 +242,11 @@ def main(): # pragma: no cover
summary_content += f"| {repo.full_name} | {'✅' if enable_security_updates else '❌'} | {follow_up_type} | [Link]({issue.html_url}) |\n"
if project_global_id:
issue_id = get_global_issue_id(
ghe, token, organization, repo.name, issue.number
ghe, token, organization, repo.name, issue.number, rate_limiter
)
link_item_to_project(
ghe, token, project_global_id, issue_id, rate_limiter
)
link_item_to_project(ghe, token, project_global_id, issue_id)
print(f"\tLinked issue to project {project_global_id}")
else:
# Try to detect if the repo already has an open pull request for dependabot
Expand Down Expand Up @@ -256,10 +275,15 @@ def main(): # pragma: no cover
)
if project_global_id:
pr_id = get_global_pr_id(
ghe, token, organization, repo.name, pull.number
ghe,
token,
organization,
repo.name,
pull.number,
rate_limiter,
)
response = link_item_to_project(
ghe, token, project_global_id, pr_id
ghe, token, project_global_id, pr_id, rate_limiter
)
if response:
print(
Expand All @@ -283,7 +307,9 @@ def is_repo_created_date_before(repo_created_at: str, created_after_date: str):
)


def is_dependabot_security_updates_enabled(ghe, owner, repo, access_token):
def is_dependabot_security_updates_enabled(
ghe, owner, repo, access_token, rate_limiter
):
"""
Check if Dependabot security updates are enabled at the /repos/:owner/:repo/automated-security-fixes endpoint using the requests library
API: https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#check-if-automated-security-fixes-are-enabled-for-a-repository
Expand All @@ -295,7 +321,9 @@ def is_dependabot_security_updates_enabled(ghe, owner, repo, access_token):
"Accept": "application/vnd.github.london-preview+json",
}

response = requests.get(url, headers=headers, timeout=20)
response = rate_limiter.execute_with_backoff(
requests.get, url, headers=headers, timeout=20
)
if response.status_code == 200:
return response.json()["enabled"]
return False
Expand Down Expand Up @@ -325,7 +353,7 @@ def check_existing_config(repo, filename):
return None


def enable_dependabot_security_updates(ghe, owner, repo, access_token):
def enable_dependabot_security_updates(ghe, owner, repo, access_token, rate_limiter):
"""
Enable Dependabot security updates at the /repos/:owner/:repo/automated-security-fixes endpoint using the requests library
API: https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#enable-automated-security-fixes
Expand All @@ -337,7 +365,9 @@ def enable_dependabot_security_updates(ghe, owner, repo, access_token):
"Accept": "application/vnd.github.london-preview+json",
}

response = requests.put(url, headers=headers, timeout=20)
response = rate_limiter.execute_with_backoff(
requests.put, url, headers=headers, timeout=20
)
if response.status_code == 204:
print("\tDependabot security updates enabled successfully.")
else:
Expand Down Expand Up @@ -438,7 +468,7 @@ def commit_changes(
return pull


def get_global_project_id(ghe, token, organization, number):
def get_global_project_id(ghe, token, organization, number, rate_limiter):
"""
Fetches the project ID from GitHub's GraphQL API.
API: https://docs.github.com/en/graphql/guides/forming-calls-with-graphql
Expand All @@ -451,7 +481,9 @@ def get_global_project_id(ghe, token, organization, number):
}

try:
response = requests.post(url, headers=headers, json=data, timeout=20)
response = rate_limiter.execute_with_backoff(
requests.post, url, headers=headers, json=data, timeout=20
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
print(f"Request failed: {e}")
Expand All @@ -464,7 +496,9 @@ def get_global_project_id(ghe, token, organization, number):
return None


def get_global_issue_id(ghe, token, organization, repository, issue_number):
def get_global_issue_id(
ghe, token, organization, repository, issue_number, rate_limiter
):
"""
Fetches the issue ID from GitHub's GraphQL API
API: https://docs.github.com/en/graphql/guides/forming-calls-with-graphql
Expand All @@ -473,7 +507,7 @@ def get_global_issue_id(ghe, token, organization, repository, issue_number):
url = f"{api_endpoint}/graphql"
headers = {"Authorization": f"Bearer {token}"}
data = {
"query": f"""
"query": f"""
query {{
repository(owner: "{organization}", name: "{repository}") {{
issue(number: {issue_number}) {{
Expand All @@ -485,7 +519,9 @@ def get_global_issue_id(ghe, token, organization, repository, issue_number):
}

try:
response = requests.post(url, headers=headers, json=data, timeout=20)
response = rate_limiter.execute_with_backoff(
requests.post, url, headers=headers, json=data, timeout=20
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
print(f"Request failed: {e}")
Expand All @@ -498,7 +534,7 @@ def get_global_issue_id(ghe, token, organization, repository, issue_number):
return None


def get_global_pr_id(ghe, token, organization, repository, pr_number):
def get_global_pr_id(ghe, token, organization, repository, pr_number, rate_limiter):
"""
Fetches the pull request ID from GitHub's GraphQL API
API: https://docs.github.com/en/graphql/guides/forming-calls-with-graphql
Expand All @@ -507,7 +543,7 @@ def get_global_pr_id(ghe, token, organization, repository, pr_number):
url = f"{api_endpoint}/graphql"
headers = {"Authorization": f"Bearer {token}"}
data = {
"query": f"""
"query": f"""
query {{
repository(owner: "{organization}", name: "{repository}") {{
pullRequest(number: {pr_number}) {{
Expand All @@ -519,7 +555,9 @@ def get_global_pr_id(ghe, token, organization, repository, pr_number):
}

try:
response = requests.post(url, headers=headers, json=data, timeout=20)
response = rate_limiter.execute_with_backoff(
requests.post, url, headers=headers, json=data, timeout=20
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
print(f"Request failed: {e}")
Expand All @@ -532,7 +570,7 @@ def get_global_pr_id(ghe, token, organization, repository, pr_number):
return None


def link_item_to_project(ghe, token, project_global_id, item_id):
def link_item_to_project(ghe, token, project_global_id, item_id, rate_limiter):
"""
Links an item (issue or pull request) to a project in GitHub.
API: https://docs.github.com/en/graphql/guides/forming-calls-with-graphql
Expand All @@ -545,7 +583,9 @@ def link_item_to_project(ghe, token, project_global_id, item_id):
}

try:
response = requests.post(url, headers=headers, json=data, timeout=20)
response = rate_limiter.execute_with_backoff(
requests.post, url, headers=headers, json=data, timeout=20
)
response.raise_for_status()
return response
except requests.exceptions.RequestException as e:
Expand Down
Loading
Loading