Skip to content

Commit

Permalink
Merge pull request #256 from ricardojdsilva87/feat/support-github-ent…
Browse files Browse the repository at this point in the history
…erprise-api

feat: support github enterprise api
  • Loading branch information
jmeridth authored Oct 25, 2024
2 parents c5775b2 + 6c372ac commit 8edb1f2
Show file tree
Hide file tree
Showing 9 changed files with 372 additions and 115 deletions.
1 change: 1 addition & 0 deletions .env-example
Original file line number Diff line number Diff line change
Expand Up @@ -21,3 +21,4 @@ UPDATE_EXISTING = ""
GH_APP_ID = ""
GH_INSTALLATION_ID = ""
GH_PRIVATE_KEY = ""
GITHUB_APP_ENTERPRISE_ONLY = ""
4 changes: 4 additions & 0 deletions .github/linters/.markdown-lint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,7 @@ MD013: false
MD025: false
# duplicate headers
MD024: false
# MD033/no-inline-html - Inline HTML
MD033:
# Allowed elements
allowed_elements: [br, li, ul]
146 changes: 109 additions & 37 deletions README.md

Large diffs are not rendered by default.

19 changes: 15 additions & 4 deletions auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ def auth_to_github(
gh_app_installation_id: int | None,
gh_app_private_key_bytes: bytes,
ghe: str,
gh_app_enterprise_only: bool,
) -> github3.GitHub:
"""
Connect to github.com or GitHub Enterprise, depending on env variables.
Expand All @@ -20,18 +21,22 @@ def auth_to_github(
gh_app_installation_id (int | None): the GitHub App Installation ID
gh_app_private_key_bytes (bytes): the GitHub App Private Key
ghe (str): the GitHub Enterprise URL
gh_app_enterprise_only (bool): Set this to true if the GH APP is created on GHE and needs to communicate with GHE api only
Returns:
github3.GitHub: the GitHub connection object
"""
if gh_app_id and gh_app_private_key_bytes and gh_app_installation_id:
gh = github3.github.GitHub()
if ghe and gh_app_enterprise_only:
gh = github3.github.GitHubEnterprise(url=ghe)
else:
gh = github3.github.GitHub()
gh.login_as_app_installation(
gh_app_private_key_bytes, gh_app_id, gh_app_installation_id
)
github_connection = gh
elif ghe and token:
github_connection = github3.github.GitHubEnterprise(ghe, token=token)
github_connection = github3.github.GitHubEnterprise(url=ghe, token=token)
elif token:
github_connection = github3.login(token=token)
else:
Expand All @@ -45,12 +50,17 @@ def auth_to_github(


def get_github_app_installation_token(
gh_app_id: str, gh_app_private_key_bytes: bytes, gh_app_installation_id: str
ghe: str,
gh_app_id: str,
gh_app_private_key_bytes: bytes,
gh_app_installation_id: str,
) -> str | None:
"""
Get a GitHub App Installation token.
API: https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/authenticating-as-a-github-app-installation
Args:
ghe (str): the GitHub Enterprise endpoint
gh_app_id (str): the GitHub App ID
gh_app_private_key_bytes (bytes): the GitHub App Private Key
gh_app_installation_id (str): the GitHub App Installation ID
Expand All @@ -59,7 +69,8 @@ def get_github_app_installation_token(
str: the GitHub App token
"""
jwt_headers = github3.apps.create_jwt_headers(gh_app_private_key_bytes, gh_app_id)
url = f"https://api.github.com/app/installations/{gh_app_installation_id}/access_tokens"
api_endpoint = f"{ghe}/api/v3" if ghe else "https://api.github.com"
url = f"{api_endpoint}/app/installations/{gh_app_installation_id}/access_tokens"

try:
response = requests.post(url, headers=jwt_headers, json=None, timeout=5)
Expand Down
4 changes: 4 additions & 0 deletions env.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ def get_env_vars(
int | None,
int | None,
bytes,
bool,
str,
str,
list[str],
Expand Down Expand Up @@ -132,6 +133,7 @@ def get_env_vars(
gh_app_id (int | None): The GitHub App ID to use for authentication
gh_app_installation_id (int | None): The GitHub App Installation ID to use for authentication
gh_app_private_key_bytes (bytes): The GitHub App Private Key as bytes to use for authentication
gh_app_enterprise_only (bool): Set this to true if the GH APP is created on GHE and needs to communicate with GHE api only
token (str): The GitHub token to use for authentication
ghe (str): The GitHub Enterprise URL to use for authentication
exempt_repositories_list (list[str]): A list of repositories to exempt from the action
Expand Down Expand Up @@ -183,6 +185,7 @@ def get_env_vars(
gh_app_id = get_int_env_var("GH_APP_ID")
gh_app_private_key_bytes = os.environ.get("GH_APP_PRIVATE_KEY", "").encode("utf8")
gh_app_installation_id = get_int_env_var("GH_APP_INSTALLATION_ID")
gh_app_enterprise_only = get_bool_env_var("GITHUB_APP_ENTERPRISE_ONLY")

if gh_app_id and (not gh_app_private_key_bytes or not gh_app_installation_id):
raise ValueError(
Expand Down Expand Up @@ -340,6 +343,7 @@ def get_env_vars(
gh_app_id,
gh_app_installation_id,
gh_app_private_key_bytes,
gh_app_enterprise_only,
token,
ghe,
exempt_repositories_list,
Expand Down
128 changes: 74 additions & 54 deletions evergreen.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ def main(): # pragma: no cover
gh_app_id,
gh_app_installation_id,
gh_app_private_key,
gh_app_enterprise_only,
token,
ghe,
exempt_repositories_list,
Expand All @@ -46,12 +47,17 @@ def main(): # pragma: no cover

# Auth to github.com or GHE
github_connection = auth.auth_to_github(
token, gh_app_id, gh_app_installation_id, gh_app_private_key, ghe
token,
gh_app_id,
gh_app_installation_id,
gh_app_private_key,
ghe,
gh_app_enterprise_only,
)

if not token and gh_app_id and gh_app_installation_id and gh_app_private_key:
token = auth.get_github_app_installation_token(
gh_app_id, gh_app_private_key, gh_app_installation_id
ghe, gh_app_id, gh_app_private_key, gh_app_installation_id
)

# If Project ID is set, lookup the global project ID
Expand All @@ -61,7 +67,7 @@ def main(): # pragma: no cover
raise ValueError(
"ORGANIZATION environment variable was not set. Please set it"
)
project_id = get_global_project_id(token, organization, project_id)
project_id = get_global_project_id(ghe, token, organization, project_id)

# Get the repositories from the organization, team name, or list of repositories
repos = get_repos_iterator(
Expand All @@ -78,13 +84,13 @@ def main(): # pragma: no cover

# Check all the things to see if repo is eligble for a pr/issue
if repo.full_name in exempt_repositories_list:
print("Skipping " + repo.full_name + " (exempted)")
print(f"Skipping {repo.full_name} (exempted)")
continue
if repo.archived:
print("Skipping " + repo.full_name + " (archived)")
print(f"Skipping {repo.full_name} (archived)")
continue
if repo.visibility.lower() not in filter_visibility:
print("Skipping " + repo.full_name + " (visibility-filtered)")
print(f"Skipping {repo.full_name} (visibility-filtered)")
continue
existing_config = None
filename_list = [".github/dependabot.yaml", ".github/dependabot.yml"]
Expand All @@ -97,19 +103,17 @@ def main(): # pragma: no cover

if existing_config and not update_existing:
print(
"Skipping "
+ repo.full_name
+ " (dependabot file already exists and update_existing is False)"
f"Skipping {repo.full_name} (dependabot file already exists and update_existing is False)"
)
continue

if created_after_date and is_repo_created_date_before(
repo.created_at, created_after_date
):
print("Skipping " + repo.full_name + " (created after filter)")
print(f"Skipping {repo.full_name} (created after filter)")
continue

print("Checking " + repo.full_name + " for compatible package managers")
print(f"Checking {repo.full_name} for compatible package managers")
# Try to detect package managers and build a dependabot file
dependabot_file = build_dependabot_file(
repo,
Expand All @@ -133,42 +137,36 @@ def main(): # pragma: no cover
if not skip:
print("\tEligible for configuring dependabot.")
count_eligible += 1
print("\tConfiguration:\n" + dependabot_file)
print(f"\tConfiguration:\n {dependabot_file}")
if follow_up_type == "pull":
# Try to detect if the repo already has an open pull request for dependabot
skip = check_pending_pulls_for_duplicates(title, repo)
if not skip:
print("\tEligible for configuring dependabot.")
count_eligible += 1
print("\tConfiguration:\n" + dependabot_file)
print(f"\tConfiguration:\n {dependabot_file}")
continue

# Get dependabot security updates enabled if possible
if enable_security_updates:
if not is_dependabot_security_updates_enabled(repo.owner, repo.name, token):
enable_dependabot_security_updates(repo.owner, repo.name, token)
if not is_dependabot_security_updates_enabled(
ghe, repo.owner, repo.name, token
):
enable_dependabot_security_updates(ghe, repo.owner, repo.name, token)

if follow_up_type == "issue":
skip = check_pending_issues_for_duplicates(title, repo)
if not skip:
count_eligible += 1
body_issue = (
body
+ "\n\n```yaml\n"
+ "# "
+ dependabot_filename_to_use
+ "\n"
+ dependabot_file
+ "\n```"
)
body_issue = f"{body}\n\n```yaml\n# {dependabot_filename_to_use} \n{dependabot_file}\n```"
issue = repo.create_issue(title, body_issue)
print("\tCreated issue " + issue.html_url)
print(f"\tCreated issue {issue.html_url}")
if project_id:
issue_id = get_global_issue_id(
token, organization, repo.name, issue.number
ghe, token, organization, repo.name, issue.number
)
link_item_to_project(token, project_id, issue_id)
print("\tLinked issue to project " + project_id)
link_item_to_project(ghe, token, project_id, issue_id)
print(f"\tLinked issue to project {project_id}")
else:
# Try to detect if the repo already has an open pull request for dependabot
skip = check_pending_pulls_for_duplicates(title, repo)
Expand All @@ -186,19 +184,19 @@ def main(): # pragma: no cover
dependabot_filename_to_use,
existing_config,
)
print("\tCreated pull request " + pull.html_url)
print(f"\tCreated pull request {pull.html_url}")
if project_id:
pr_id = get_global_pr_id(
token, organization, repo.name, pull.number
ghe, token, organization, repo.name, pull.number
)
response = link_item_to_project(token, project_id, pr_id)
response = link_item_to_project(ghe, token, project_id, pr_id)
if response:
print("\tLinked pull request to project " + project_id)
print(f"\tLinked pull request to project {project_id}")
except github3.exceptions.NotFoundError:
print("\tFailed to create pull request. Check write permissions.")
continue

print("Done. " + str(count_eligible) + " repositories were eligible.")
print(f"Done. {str(count_eligible)} repositories were eligible.")


def is_repo_created_date_before(repo_created_at: str, created_after_date: str):
Expand All @@ -209,11 +207,13 @@ def is_repo_created_date_before(repo_created_at: str, created_after_date: str):
)


def is_dependabot_security_updates_enabled(owner, repo, access_token):
"""Check if Dependabot security updates are enabled at the
/repos/:owner/:repo/automated-security-fixes endpoint using the requests library
def is_dependabot_security_updates_enabled(ghe, owner, repo, access_token):
"""
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
"""
url = f"https://api.github.com/repos/{owner}/{repo}/automated-security-fixes"
api_endpoint = f"{ghe}/api/v3" if ghe else "https://api.github.com"
url = f"{api_endpoint}/repos/{owner}/{repo}/automated-security-fixes"
headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/vnd.github.london-preview+json",
Expand Down Expand Up @@ -247,9 +247,13 @@ def check_existing_config(repo, filename):
return None


def enable_dependabot_security_updates(owner, repo, access_token):
"""Enable Dependabot security updates at the /repos/:owner/:repo/automated-security-fixes endpoint using the requests library"""
url = f"https://api.github.com/repos/{owner}/{repo}/automated-security-fixes"
def enable_dependabot_security_updates(ghe, owner, repo, access_token):
"""
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
"""
api_endpoint = f"{ghe}/api/v3" if ghe else "https://api.github.com"
url = f"{api_endpoint}/repos/{owner}/{repo}/automated-security-fixes"
headers = {
"Authorization": f"Bearer {access_token}",
"Accept": "application/vnd.github.london-preview+json",
Expand Down Expand Up @@ -290,7 +294,7 @@ def check_pending_pulls_for_duplicates(title, repo) -> bool:
skip = False
for pull_request in pull_requests:
if pull_request.title.startswith(title):
print("\tPull request already exists: " + pull_request.html_url)
print(f"\tPull request already exists: {pull_request.html_url}")
skip = True
break
return skip
Expand All @@ -302,7 +306,7 @@ def check_pending_issues_for_duplicates(title, repo) -> bool:
skip = False
for issue in issues:
if issue.title.startswith(title):
print("\tIssue already exists: " + issue.html_url)
print(f"\tIssue already exists: {issue.html_url}")
skip = True
break
return skip
Expand Down Expand Up @@ -344,9 +348,13 @@ def commit_changes(
return pull


def get_global_project_id(token, organization, number):
"""Fetches the project ID from GitHub's GraphQL API."""
url = "https://api.github.com/graphql"
def get_global_project_id(ghe, token, organization, number):
"""
Fetches the project ID from GitHub's GraphQL API.
API: https://docs.github.com/en/graphql/guides/forming-calls-with-graphql
"""
api_endpoint = f"{ghe}/api/v3" if ghe else "https://api.github.com"
url = f"{api_endpoint}/graphql"
headers = {"Authorization": f"Bearer {token}"}
data = {
"query": f'query{{organization(login: "{organization}") {{projectV2(number: {number}){{id}}}}}}'
Expand All @@ -366,9 +374,13 @@ def get_global_project_id(token, organization, number):
return None


def get_global_issue_id(token, organization, repository, issue_number):
"""Fetches the issue ID from GitHub's GraphQL API"""
url = "https://api.github.com/graphql"
def get_global_issue_id(ghe, token, organization, repository, issue_number):
"""
Fetches the issue ID from GitHub's GraphQL API
API: https://docs.github.com/en/graphql/guides/forming-calls-with-graphql
"""
api_endpoint = f"{ghe}/api/v3" if ghe else "https://api.github.com"
url = f"{api_endpoint}/graphql"
headers = {"Authorization": f"Bearer {token}"}
data = {
"query": f"""
Expand Down Expand Up @@ -396,9 +408,13 @@ def get_global_issue_id(token, organization, repository, issue_number):
return None


def get_global_pr_id(token, organization, repository, pr_number):
"""Fetches the pull request ID from GitHub's GraphQL API"""
url = "https://api.github.com/graphql"
def get_global_pr_id(ghe, token, organization, repository, pr_number):
"""
Fetches the pull request ID from GitHub's GraphQL API
API: https://docs.github.com/en/graphql/guides/forming-calls-with-graphql
"""
api_endpoint = f"{ghe}/api/v3" if ghe else "https://api.github.com"
url = f"{api_endpoint}/graphql"
headers = {"Authorization": f"Bearer {token}"}
data = {
"query": f"""
Expand Down Expand Up @@ -426,9 +442,13 @@ def get_global_pr_id(token, organization, repository, pr_number):
return None


def link_item_to_project(token, project_id, item_id):
"""Links an item (issue or pull request) to a project in GitHub."""
url = "https://api.github.com/graphql"
def link_item_to_project(ghe, token, project_id, item_id):
"""
Links an item (issue or pull request) to a project in GitHub.
API: https://docs.github.com/en/graphql/guides/forming-calls-with-graphql
"""
api_endpoint = f"{ghe}/api/v3" if ghe else "https://api.github.com"
url = f"{api_endpoint}/graphql"
headers = {"Authorization": f"Bearer {token}"}
data = {
"query": f'mutation {{addProjectV2ItemById(input: {{projectId: "{project_id}", contentId: "{item_id}"}}) {{item {{id}}}}}}'
Expand Down
Loading

0 comments on commit 8edb1f2

Please sign in to comment.