""" This module contains utilities for continuous delivery on GitHub.
- upload_to_github: Upload the project to GitHub using the credentials and the configuration
(make a release and upload the assets, handle existing tag, generate changelog, etc.)
.. image:: https://raw.githubusercontent.com/Stoupy51/stouputils/refs/heads/main/assets/continuous_delivery/github_module.gif
:alt: stouputils upload_to_github examples
"""
# Imports
from typing import Any
from ..decorators import handle_error, measure_time
from ..print.message import info, progress
from .cd_utils import handle_response
from .release_common import (
PlatformConfig,
create_release,
delete_resource_unconditional,
generate_changelog,
get_commits_since_tag,
get_latest_tag,
handle_existing_tag,
log_success,
upload_files,
validate_required_keys,
)
# Constants
GITHUB_API_URL: str = "https://api.github.com"
[docs]
def validate_github_credentials(credentials: dict[str, dict[str, str]]) -> tuple[str, dict[str, str]]:
""" Get and validate GitHub credentials.
Args:
credentials: Credentials dictionary with 'github' key containing 'api_key' and 'username'
Returns:
tuple: (owner username, headers dict)
Raises:
ValueError: If required keys are missing
"""
if "github" not in credentials:
raise ValueError(
"The credentials file must contain a 'github' key, which is a dictionary containing:\n"
"- 'api_key': a PAT for the GitHub API (https://github.com/settings/tokens)\n"
"- 'username': the username of the account to use"
)
validate_required_keys(credentials["github"], ["api_key", "username"], "github credentials")
api_key: str = credentials["github"]["api_key"]
owner: str = credentials["github"]["username"]
headers: dict[str, str] = {"Authorization": f"Bearer {api_key}"}
return owner, headers
[docs]
def validate_github_config(github_config: dict[str, Any]) -> tuple[str, str, str, list[str]]:
""" Validate GitHub configuration.
Args:
github_config: Configuration dictionary
Returns:
tuple: (project_name, version, build_folder, endswith list)
Raises:
ValueError: If required keys are missing
"""
validate_required_keys(github_config, ["project_name", "version", "build_folder"], "github_config")
return (
github_config["project_name"],
github_config["version"],
github_config["build_folder"],
github_config.get("endswith", []),
)
[docs]
def build_github_config(
owner: str,
headers: dict[str, str],
project_name: str,
version: str,
build_folder: str,
endswith: list[str],
api_url: str,
) -> PlatformConfig:
""" Build a PlatformConfig for GitHub.
Args:
owner: GitHub username
headers: HTTP headers with authorization
project_name: Name of the repository
version: Version to release
build_folder: Path to build assets
endswith: File suffixes to upload
api_url: GitHub API URL
Returns:
PlatformConfig: Configuration for GitHub release
"""
project_identifier = f"{owner}/{project_name}"
return PlatformConfig(
base_url=api_url,
project_identifier=project_identifier,
headers=headers,
version=version,
build_folder=build_folder,
endswith=endswith,
project_api_url=f"{api_url}/repos/{project_identifier}",
web_url="https://github.com",
tag_api_path="/tags",
commit_api_path="/commits",
release_api_path="/releases",
commit_url_path="/commit/",
compare_url_path="/compare/",
platform_name="GitHub",
)
[docs]
def delete_github_release(config: PlatformConfig) -> None:
""" Delete existing GitHub release for the configured version. """
import requests
releases_url: str = f"{config.project_api_url}/releases/tags/v{config.version}"
release_response: requests.Response = requests.get(releases_url, headers=config.headers)
if release_response.status_code == 200:
release_id: int = release_response.json()["id"]
delete_url: str = f"{config.project_api_url}/releases/{release_id}"
delete_response: requests.Response = requests.delete(delete_url, headers=config.headers)
handle_response(delete_response, "Failed to delete existing release")
info(f"Deleted existing release for v{config.version}")
[docs]
def delete_github_tag(config: PlatformConfig) -> None:
""" Delete existing GitHub tag for the configured version. """
tag_url: str = f"{config.project_api_url}/git/refs/tags/v{config.version}"
delete_resource_unconditional(config, tag_url, "tag")
[docs]
def get_github_sha(tag: dict[str, Any]) -> str:
""" Extract SHA from a GitHub tag response. """
return tag["commit"]["sha"]
[docs]
def get_github_commit_date(commit: dict[str, Any]) -> str:
""" Extract date from a GitHub commit response. """
return str(commit["commit"]["committer"]["date"])
[docs]
def create_github_tag(config: PlatformConfig) -> None:
""" Create a new tag on GitHub. """
import requests
progress(f"Creating tag v{config.version}")
create_tag_url: str = f"{config.project_api_url}/git/refs"
latest_commit_url: str = f"{config.project_api_url}/git/refs/heads/main"
commit_response: requests.Response = requests.get(latest_commit_url, headers=config.headers)
handle_response(commit_response, "Failed to get latest commit")
commit_sha: str = commit_response.json()["object"]["sha"]
tag_data: dict[str, str] = {
"ref": f"refs/tags/v{config.version}",
"sha": commit_sha
}
response: requests.Response = requests.post(create_tag_url, headers=config.headers, json=tag_data)
handle_response(response, "Failed to create tag")
[docs]
def create_github_release(config: PlatformConfig, changelog: str) -> int:
""" Create a new GitHub release and return its ID.
Args:
config: Platform configuration
changelog: Changelog text for the release body
Returns:
int: Release ID for asset uploads
"""
project_name = config.project_identifier.split("/")[-1]
release_data: dict[str, str | bool] = {
"tag_name": f"v{config.version}",
"name": f"{project_name} [v{config.version}]",
"body": changelog,
"draft": False,
"prerelease": False
}
response = create_release(config, release_data)
return response["id"]
[docs]
def upload_github_assets(config: PlatformConfig, release_id: int) -> None:
""" Upload release assets to GitHub.
Args:
config: Platform configuration
release_id: ID of the release to upload assets to
"""
import requests
# Get upload URL template
release_url: str = f"{config.project_api_url}/releases/{release_id}"
response: requests.Response = requests.get(release_url, headers=config.headers)
handle_response(response, "Failed to get release details")
upload_url_template: str = response.json()["upload_url"]
upload_url_base: str = upload_url_template.split("{", maxsplit=1)[0]
def upload_file(file_path: str, file_name: str) -> None:
with open(file_path, "rb") as f:
headers_with_content: dict[str, str] = {
**config.headers,
"Content-Type": "application/zip"
}
params: dict[str, str] = {"name": file_name}
resp: requests.Response = requests.post(
upload_url_base,
headers=headers_with_content,
params=params,
data=f.read()
)
handle_response(resp, f"Failed to upload {file_name}")
upload_files(config, upload_file)
[docs]
@measure_time(message="Uploading to GitHub took")
@handle_error
def upload_to_github(
credentials: dict[str, Any],
github_config: dict[str, Any],
api_url: str = GITHUB_API_URL,
) -> str:
""" Upload the project to GitHub using the credentials and the configuration.
Args:
credentials: Credentials for the GitHub API
github_config: Configuration for the GitHub project
api_url: GitHub API URL (default: https://api.github.com)
Returns:
str: Generated changelog text
Examples:
.. code-block:: python
> upload_to_github(
credentials={
"github": {
"api_key": "ghp_...",
"username": "Stoupy"
}
},
github_config={
"project_name": "stouputils",
"version": "1.0.0",
"build_folder": "build",
"endswith": [".zip"]
}
)
"""
import requests # type: ignore # noqa: F401
# Validate inputs
owner, headers = validate_github_credentials(credentials)
project_name, version, build_folder, endswith = validate_github_config(github_config)
# Build configuration
config = build_github_config(owner, headers, project_name, version, build_folder, endswith, api_url)
# Handle existing tag
tag_url: str = f"{config.project_api_url}/git/refs/tags/v{version}"
can_create: bool = handle_existing_tag(config, tag_url, delete_github_tag, delete_github_release)
# Get commits and generate changelog
latest_tag_sha, latest_tag_version = get_latest_tag(config, get_github_sha)
commits: list[dict[str, Any]] = get_commits_since_tag(config, latest_tag_sha, get_github_commit_date)
commit_tuples = extract_github_commit_data(commits)
changelog: str = generate_changelog(commit_tuples, config, latest_tag_version)
# Create release
if can_create:
create_github_tag(config)
release_id: int = create_github_release(config, changelog)
upload_github_assets(config, release_id)
log_success(config)
return changelog