"""Lambda github_public_repo_audit function module.""" from datetime import datetime, timezone from typing import Dict, List import requests from lambdacommon.aws import ses from lambdacommon.common_config import logger from lambdacommon.util import init_sentry_for_lambda from config import ( GITHUB_GQL_ENDPOINT, GITHUB_ORG, GITHUB_PUBLIC_REPO_TO_EXCLUDE, GITHUB_TOKEN, SES_EMAIL_RECIPIENTS, SES_EMAIL_SENDER, ) from src.connectors import datadog class GitHubAPIError(Exception): """Raised when the GitHub GraphQL API returns an error or unexpected response.""" init_sentry_for_lambda() GITHUB_GRAPHQL_QUERY = """ query Organization($org: String!, $after: String) { organization(login: $org) { repositories( first: 100, visibility: PUBLIC, after: $after, orderBy: {field: CREATED_AT, direction: DESC} ) { nodes { name url createdAt } pageInfo { endCursor hasNextPage } } } } """ def get_public_repos() -> List[Dict[str, str]]: """ Fetch all public repositories for the specified GitHub organization using the GraphQL API. This function paginates through the organization's public repositories and returns a combined list of repositories with their name, URL, and creation date. Returns: List[Dict[str, str]]: A list of dictionaries representing public repositories. Raises: GitHubAPIError: If the GraphQL query fails or the response contains errors. """ headers = { 'Authorization': f'Bearer {GITHUB_TOKEN}', 'Content-Type': 'application/json' } repos: List[Dict[str, str]] = [] cursor = None try: while True: variables = {'org': GITHUB_ORG, 'after': cursor} resp = requests.post( GITHUB_GQL_ENDPOINT, json={ 'query': GITHUB_GRAPHQL_QUERY, 'variables': variables }, headers=headers ) data = resp.json() if not resp.ok: raise GitHubAPIError( f'GitHub API returned {resp.status_code} {resp.reason}: ' f"{data.get('message', data)}" ) if 'errors' in data: raise GitHubAPIError(f"GraphQL error: {data['errors']}") if 'data' not in data: raise GitHubAPIError(f"Unexpected GitHub API response, missing 'data': {data}") repo_nodes = data['data']['organization']['repositories']['nodes'] repos.extend(repo_nodes) page_info = data['data']['organization']['repositories']['pageInfo'] if not page_info['hasNextPage']: break cursor = page_info['endCursor'] return repos except Exception as e: logger.error('Failed to fetch public repositories from GitHub: %s', e) raise def notify(new_repos: List[Dict[str, str]]) -> None: """ Send notifications about new public GitHub repositories via SES email. This function formats a summary message listing all new repositories and sends an email. Args: new_repos (List[Dict[str, str]]): List of new public GitHub repositories. Returns: None """ message_lines = ['Public GitHub repositories found:'] for repo in new_repos: try: created_at_utc = datetime.strptime(repo['createdAt'], '%Y-%m-%dT%H:%M:%SZ') formatted_date = created_at_utc.strftime('%B %d, %Y at %I:%M %p UTC') except Exception as e: logger.warning(f"Could not parse date for repo {repo.get('name')}: {e}") formatted_date = repo.get('createdAt', 'Unknown date') message_lines.append( f"• {repo['name']} — {repo['url']} — created on {formatted_date}" ) full_message = '\n'.join(message_lines) logger.info(full_message) try: logger.info(SES_EMAIL_SENDER) logger.info(SES_EMAIL_RECIPIENTS) ses.send_email( sender=SES_EMAIL_SENDER, recipients=SES_EMAIL_RECIPIENTS, subject='Public GitHub Repositories Found', message=f'{full_message}\n\nPlease review. This alert is auto-generated.' ) logger.info('SES email sent successfully.') except Exception as e: logger.error('SES email notification failed: %s', e) raise def handler(event, context): """Lambda entry point.""" try: run_time = datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S %Z') logger.info(f'Lambda run at {run_time}') datadog.publish_metric('github_public_repo_audit.attempt', 1) all_public_repos = get_public_repos() logger.info(f'Total public repos fetched from GitHub: {len(all_public_repos)}') current_public_repo_names = {r['name'].lower() for r in all_public_repos} exclude_names = {name.lower() for name in GITHUB_PUBLIC_REPO_TO_EXCLUDE} new_repo_names = current_public_repo_names - exclude_names new_public_repos = [r for r in all_public_repos if r['name'].lower() in new_repo_names] logger.info(f'New public repos found: {len(new_public_repos)}') datadog.publish_metric('github_public_repo_audit.public_repos', len(new_public_repos)) if new_public_repos: notify(new_public_repos) logger.info('Notification sent for new public repos.') result_message = ( f'Found and notified about {len(new_public_repos)} new ones.' ) else: logger.info('No new public repos to notify.') result_message = 'No new public repos found.' datadog.publish_metric('github_public_repo_audit.success', 1) return { 'status': 'OK', 'message': result_message } except Exception as e: logger.exception(str(e)) datadog.publish_metric('github_public_repo_audit.error', 1) raise e