Skip to content
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
2 changes: 1 addition & 1 deletion .github/linters/.mypy.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[mypy]
disable_error_code = attr-defined, import-not-found

[mypy-github3.*]
[mypy-github.*]
ignore_missing_imports = True
54 changes: 24 additions & 30 deletions auth.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
"""This is the module that contains functions related to authenticating to GitHub with a personal access token."""

import env
import github3
import requests
from github import Auth, Github, GithubException, GithubIntegration


def auth_to_github(
Expand All @@ -13,7 +12,7 @@ def auth_to_github(
ghe: str,
gh_app_enterprise_only: bool,
ghe_api_url: str = "",
) -> github3.GitHub:
) -> Github:
"""
Connect to GitHub.com or GitHub Enterprise, depending on env variables.

Expand All @@ -27,33 +26,27 @@ def auth_to_github(
ghe_api_url (str): the full GitHub Enterprise API endpoint URL override

Returns:
github3.GitHub: the GitHub connection object
Github: the GitHub connection object
"""
if gh_app_id and gh_app_private_key_bytes and gh_app_installation_id:
app_auth = Auth.AppAuth(int(gh_app_id), gh_app_private_key_bytes.decode())
installation_auth = app_auth.get_installation_auth(int(gh_app_installation_id))
if ghe and gh_app_enterprise_only:
gh = github3.github.GitHubEnterprise(url=ghe)
if ghe_api_url:
gh.session.base_url = ghe_api_url
base_url = env.get_api_endpoint(ghe, ghe_api_url)
github_connection = Github(base_url=base_url, auth=installation_auth)
else:
gh = github3.github.GitHub()
gh.login_as_app_installation(
gh_app_private_key_bytes, str(gh_app_id), gh_app_installation_id
)
github_connection = gh
github_connection = Github(auth=installation_auth)
elif ghe and token:
github_connection = github3.github.GitHubEnterprise(url=ghe, token=token)
if ghe_api_url:
github_connection.session.base_url = ghe_api_url
base_url = env.get_api_endpoint(ghe, ghe_api_url)
github_connection = Github(base_url=base_url, auth=Auth.Token(token))
elif token:
github_connection = github3.login(token=token)
github_connection = Github(auth=Auth.Token(token))
else:
raise ValueError(
"GH_TOKEN or the set of [GH_APP_ID, GH_APP_INSTALLATION_ID, GH_APP_PRIVATE_KEY] environment variables are not set"
)

if not github_connection:
raise ValueError("Unable to authenticate to GitHub")
return github_connection # type: ignore
return github_connection


def get_github_app_installation_token(
Expand All @@ -75,18 +68,19 @@ def get_github_app_installation_token(
ghe_api_url (str): the full GitHub Enterprise API endpoint URL override

Returns:
str: the GitHub App token
str | None: the GitHub App token, or None if IDs are missing or the request fails
"""
jwt_headers = github3.apps.create_jwt_headers(
gh_app_private_key_bytes, str(gh_app_id)
)
api_endpoint = env.get_api_endpoint(ghe, ghe_api_url)
url = f"{api_endpoint}/app/installations/{gh_app_installation_id}/access_tokens"

if not gh_app_id or not gh_app_installation_id:
return None
try:
response = requests.post(url, headers=jwt_headers, json=None, timeout=5)
response.raise_for_status()
except requests.exceptions.RequestException as e:
app_auth = Auth.AppAuth(int(gh_app_id), gh_app_private_key_bytes.decode())
if ghe:
base_url = env.get_api_endpoint(ghe, ghe_api_url)
gi = GithubIntegration(auth=app_auth, base_url=base_url)
else:
gi = GithubIntegration(auth=app_auth)
installation_token = gi.get_access_token(int(gh_app_installation_id))
return installation_token.token
except GithubException as e:
print(f"Request failed: {e}")
return None
return response.json().get("token")
26 changes: 10 additions & 16 deletions dependabot_file.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,9 @@
import io
from collections.abc import Mapping

import github3
import ruamel.yaml
from exceptions import OptionalFileNotFoundError, check_optional_file
from github import UnknownObjectException
from ruamel.yaml.scalarstring import SingleQuotedScalarString

# Define data structure for dependabot.yaml
Expand Down Expand Up @@ -349,8 +349,8 @@ def build_dependabot_file(
# detect package managers with variable file names
if "terraform" not in exempt_ecosystems_list:
try:
for file in repo.directory_contents("/"):
if file[0].endswith(".tf"):
for file in repo.get_contents("/"):
Comment thread
jmeridth marked this conversation as resolved.
if file.name.endswith(".tf"):
package_managers_found["terraform"] = True
make_dependabot_config(
"terraform",
Expand All @@ -363,14 +363,12 @@ def build_dependabot_file(
cooldown,
)
break
except github3.exceptions.NotFoundError:
# The file does not exist and is not required,
# so we should continue to the next one rather than raising error or logging
except UnknownObjectException:
pass
if "github-actions" not in exempt_ecosystems_list:
try:
for file in repo.directory_contents(".github/workflows"):
if file[0].endswith(".yml") or file[0].endswith(".yaml"):
for file in repo.get_contents(".github/workflows"):
if file.name.endswith(".yml") or file.name.endswith(".yaml"):
package_managers_found["github-actions"] = True
make_dependabot_config(
"github-actions",
Expand All @@ -383,14 +381,12 @@ def build_dependabot_file(
cooldown,
)
break
except github3.exceptions.NotFoundError:
# The file does not exist and is not required,
# so we should continue to the next one rather than raising error or logging
except UnknownObjectException:
pass
if "devcontainers" not in exempt_ecosystems_list:
try:
for file in repo.directory_contents(".devcontainer"):
if file[0] == "devcontainer.json":
for file in repo.get_contents(".devcontainer"):
if file.name == "devcontainer.json":
package_managers_found["devcontainers"] = True
make_dependabot_config(
"devcontainers",
Expand All @@ -403,9 +399,7 @@ def build_dependabot_file(
cooldown,
)
break
except github3.exceptions.NotFoundError:
# The file does not exist and is not required,
# so we should continue to the next one rather than raising error or logging
except UnknownObjectException:
pass

if any(package_managers_found.values()):
Expand Down
57 changes: 31 additions & 26 deletions evergreen.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@

import auth
import env
import github3
import requests
import ruamel.yaml
from dependabot_file import build_dependabot_file, validate_cooldown_config
from exceptions import OptionalFileNotFoundError, check_optional_file
from github import UnknownObjectException


def main(): # pragma: no cover
Expand Down Expand Up @@ -213,10 +213,10 @@ def main(): # pragma: no cover
# Get dependabot security updates enabled if possible
if config.enable_security_updates:
if not is_dependabot_security_updates_enabled(
config.ghe, config.ghe_api_url, repo.owner, repo.name, token
config.ghe, config.ghe_api_url, repo.owner.login, repo.name, token
):
enable_dependabot_security_updates(
config.ghe, config.ghe_api_url, repo.owner, repo.name, token
config.ghe, config.ghe_api_url, repo.owner.login, repo.name, token
)

if config.follow_up_type == "issue":
Expand Down Expand Up @@ -290,7 +290,7 @@ def main(): # pragma: no cover
print(
f"\tLinked pull request to project {project_global_id}"
)
except github3.exceptions.NotFoundError:
except UnknownObjectException:
print("\tFailed to create pull request. Check write permissions.")
continue

Expand All @@ -300,9 +300,16 @@ def main(): # pragma: no cover
append_to_github_summary(summary_content)


def is_repo_created_date_before(repo_created_at: str, created_after_date: str):
def is_repo_created_date_before(
repo_created_at: str | datetime, created_after_date: str
):
"""Check if the repository was created before the created_after_date"""
repo_created_at_date = datetime.fromisoformat(repo_created_at).replace(tzinfo=None)
if isinstance(repo_created_at, datetime):
repo_created_at_date = repo_created_at.replace(tzinfo=None)
else:
repo_created_at_date = datetime.fromisoformat(repo_created_at).replace(
tzinfo=None
)
return created_after_date and repo_created_at_date < datetime.strptime(
created_after_date, "%Y-%m-%d"
)
Expand Down Expand Up @@ -332,11 +339,11 @@ def check_existing_config(repo, filename):
repository and return the existing config if it does

Args:
repo (github3.repos.repo.Repository): The repository to check
repo: The repository to check
filename (str): The configuration filename to check

Returns:
github3.repos.contents.Contents | None: The existing config if it exists, otherwise None
The existing config if it exists, otherwise None
"""
existing_config = None
try:
Expand Down Expand Up @@ -376,36 +383,33 @@ def get_repos_iterator(
# Use GitHub search API if REPOSITORY_SEARCH_QUERY is set
if search_query:
# Return repositories matching the search query
repos = []
# Search results need to be converted to a list of repositories since they are returned as a search iterator
for repo in github_connection.search_repositories(search_query):
repos.append(repo.repository)
repos = list(github_connection.search_repositories(search_query))
return repos

repos = []
# Default behavior: list all organization/team repositories or specific repository list
if organization and not repository_list and not team_name:
repos = github_connection.organization(organization).repositories()
repos = github_connection.get_organization(organization).get_repos()
elif team_name and organization:
# Get the repositories from the team
team = github_connection.organization(organization).team_by_name(team_name)
team = github_connection.get_organization(organization).get_team_by_slug(
team_name
)
if team.repos_count == 0:
print(f"Team {team_name} has no repositories")
sys.exit(1)
repos = team.repositories()
repos = team.get_repos()
else:
# Get the repositories from the repository_list
for repo in repository_list:
repos.append(
github_connection.repository(repo.split("/")[0], repo.split("/")[1])
)
repos.append(github_connection.get_repo(repo))

return repos


def check_pending_pulls_for_duplicates(title, repo) -> bool:
"""Check if there are any open pull requests for dependabot and return the bool skip"""
pull_requests = repo.pull_requests(state="open")
pull_requests = repo.get_pulls(state="open")
skip = False
for pull_request in pull_requests:
if pull_request.title.startswith(title):
Expand All @@ -417,7 +421,7 @@ def check_pending_pulls_for_duplicates(title, repo) -> bool:

def check_pending_issues_for_duplicates(title, repo) -> bool:
"""Check if there are any open issues for dependabot and return the bool skip"""
issues = repo.issues(state="open")
issues = repo.get_issues(state="open")
skip = False
for issue in issues:
if issue.title.startswith(title):
Expand All @@ -439,21 +443,22 @@ def commit_changes(
"""Commit the changes to the repo and open a pull request and return the pull request object"""
default_branch = repo.default_branch
# Get latest commit sha from default branch
default_branch_commit = repo.ref("heads/" + default_branch).object.sha
front_matter = "refs/heads/"
default_branch_commit = repo.get_git_ref("heads/" + default_branch).object.sha
branch_name = "dependabot-" + str(uuid.uuid4())
repo.create_ref(front_matter + branch_name, default_branch_commit)
repo.create_git_ref(ref="refs/heads/" + branch_name, sha=default_branch_commit)
if existing_config:
repo.file_contents(dependabot_filename).update(
repo.update_file(
path=dependabot_filename,
message=message,
content=dependabot_file.encode(), # Convert to bytes object
content=dependabot_file,
sha=existing_config.sha,
branch=branch_name,
)
else:
repo.create_file(
path=dependabot_filename,
message=message,
content=dependabot_file.encode(), # Convert to bytes object
content=dependabot_file,
branch=branch_name,
)

Expand Down
22 changes: 11 additions & 11 deletions exceptions.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,20 @@
"""Custom exceptions for the evergreen application."""

import github3.exceptions
from github import UnknownObjectException


class OptionalFileNotFoundError(github3.exceptions.NotFoundError):
class OptionalFileNotFoundError(UnknownObjectException):
"""Exception raised when an optional file is not found.

This exception inherits from github3.exceptions.NotFoundError but provides
This exception inherits from github.UnknownObjectException but provides
a more explicit name for cases where missing files are expected and should
not be treated as errors. This is typically used for optional configuration
files or dependency files that may not exist in all repositories.

Args:
resp: The HTTP response object from the failed request
status: The HTTP status code
data: The response data
headers: The response headers
"""


Expand All @@ -35,15 +37,13 @@ def check_optional_file(repo, filename):
Other exceptions: For unexpected errors (permissions, network issues, etc.)
"""
try:
file_contents = repo.file_contents(filename)
# Handle both real file contents objects and test mocks that return boolean
file_contents = repo.get_contents(filename)
if hasattr(file_contents, "size"):
# Real file contents object
if file_contents.size > 0:
return file_contents
return None
# Test mock or other truthy value
return file_contents if file_contents else None
except github3.exceptions.NotFoundError as e:
# Re-raise as our more specific exception type for better semantic clarity
raise OptionalFileNotFoundError(resp=e.response) from e
except UnknownObjectException as e:
raise OptionalFileNotFoundError(
status=e.status, data=e.data, headers=e.headers
) from e
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ version = "1.0.0"
description = "GitHub Action that enables Dependabot for all repositories in a GitHub organization."
requires-python = ">=3.11"
dependencies = [
"github3-py==4.0.1",
"PyGithub>=2.6.0",
"python-dotenv==1.2.2",
"requests==2.34.2",
"ruamel-yaml==0.19.1",
Expand Down
Loading
Loading