Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support github enterprise api #256

Merged
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
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
Loading