diff --git a/.gitignore b/.gitignore index 53aab97..b0cbca4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ # Generated graph images: assets/ +# Cloned repo path: +repo/ + # Library roots: venv/ diff --git a/Makefile b/Makefile index 0324f48..cd0485f 100644 --- a/Makefile +++ b/Makefile @@ -47,6 +47,7 @@ lint: venv clean: @ # Clean all build files, including: libraries, package manager configs, docker images and containers rm -rf venv + rm -rf repo rm -rf assets rm -f package*.json docker rm -f waka-readme-stats 2>/dev/null || true diff --git a/sources/graphics_chart_drawer.py b/sources/graphics_chart_drawer.py index 70806d3..6d48d18 100644 --- a/sources/graphics_chart_drawer.py +++ b/sources/graphics_chart_drawer.py @@ -5,10 +5,11 @@ import matplotlib.patches as mpatches import matplotlib.pyplot as plt from manager_download import DownloadManager as DM +from manager_file import FileManager as FM MAX_LANGUAGES = 5 # Number of top languages to add to chart, for each year quarter -GRAPH_PATH = "assets/bar_graph.png" # Chart saving path. +GRAPH_PATH = f"{FM.ASSETS_DIR}/bar_graph.png" # Chart saving path. async def create_loc_graph(yearly_data: Dict, save_path: str): diff --git a/sources/main.py b/sources/main.py index 50a69d4..7d63b50 100644 --- a/sources/main.py +++ b/sources/main.py @@ -163,7 +163,7 @@ async def get_stats() -> str: if EM.SHOW_LOC_CHART: await create_loc_graph(yearly_data, GRAPH_PATH) - stats += GHM.update_chart(GRAPH_PATH) + stats += f"**{FM.t('Timeline')}**\n\n{GHM.update_chart('Lines of Code', GRAPH_PATH)}" if EM.SHOW_UPDATED_DATE: DBM.i("Adding last updated time...") @@ -185,11 +185,10 @@ async def main(): stats = await get_stats() if not EM.DEBUG_RUN: - if GHM.update_readme(stats): - DBM.g("Readme updated!") + GHM.update_readme(stats) + GHM.commit_update() else: - if GHM.set_github_output(stats): - DBM.g("Debug run, readme not updated. Check the latest comment for the generated stats.") + GHM.set_github_output(stats) await DM.close_remote_resources() diff --git a/sources/manager_file.py b/sources/manager_file.py index 28041b6..a01f909 100644 --- a/sources/manager_file.py +++ b/sources/manager_file.py @@ -20,6 +20,7 @@ class FileManager: Stores localization in dictionary. """ + ASSETS_DIR = "assets" _LOCALIZATION: Dict[str, str] = dict() @staticmethod @@ -53,7 +54,7 @@ class FileManager: :param append: True for appending to file, false for rewriting. :param assets: True for saving to 'assets' directory, false otherwise. """ - name = join("assets", name) if assets else name + name = join(FileManager.ASSETS_DIR, name) if assets else name with open(name, "a" if append else "w", encoding="utf-8") as file: file.write(content) @@ -67,7 +68,7 @@ class FileManager: :param assets: True for saving to 'assets' directory, false otherwise. :returns: File cache contents if content is None, None otherwise. """ - name = join("assets", name) if assets else name + name = join(FileManager.ASSETS_DIR, name) if assets else name if content is None and not isfile(name): return None diff --git a/sources/manager_github.py b/sources/manager_github.py index e890154..93eb806 100644 --- a/sources/manager_github.py +++ b/sources/manager_github.py @@ -1,11 +1,13 @@ -from base64 import b64decode, b64encode -from os import environ +from base64 import b64encode +from os import environ, makedirs +from os.path import dirname, join from random import choice from re import sub +from shutil import copy, rmtree from string import ascii_letters -from git import Repo -from github import Github, AuthenticatedUser, Repository, ContentFile, InputGitAuthor, UnknownObjectException +from git import Repo, Actor +from github import Github, AuthenticatedUser, Repository from manager_environment import EnvironmentManager as EM from manager_file import FileManager as FM @@ -18,17 +20,17 @@ def init_github_manager(): Current user, user readme repo and readme file are downloaded. """ GitHubManager.prepare_github_env() - DBM.i(f"Current user: {GitHubManager.USER.login}") + DBM.i(f"Current user: {GitHubManager.USER.login}.") class GitHubManager: USER: AuthenticatedUser REPO: Repo REMOTE: Repository - _README: ContentFile - _README_CONTENTS: str - _REPO_PATH = "repo" + _REMOTE_NAME: str + _REMOTE_PATH: str + _START_COMMENT = f"" _END_COMMENT = f"" _README_REGEX = f"{_START_COMMENT}[\\s\\S]+{_END_COMMENT}" @@ -39,29 +41,24 @@ class GitHubManager: Download and store for future use: - Current GitHub user. - Named repo of the user [username]/[username]. - - README.md file of this repo. - - Parsed contents of the file. + - Clone of the named repo. """ github = Github(EM.GH_TOKEN) + clone_path = "repo" GitHubManager.USER = github.get_user() - GitHubManager.REMOTE = github.get_repo(f"{GitHubManager.USER.login}/{GitHubManager.USER.login}") - GitHubManager.REPO = Repo.clone_from(GitHubManager.REMOTE.clone_url, to_path=GitHubManager._REPO_PATH) - GitHubManager._README = GitHubManager.REMOTE.get_readme() - GitHubManager._README_CONTENTS = str(b64decode(GitHubManager._README.content), "utf-8") + rmtree(clone_path) + + GitHubManager._REMOTE_NAME = f"{GitHubManager.USER.login}/{GitHubManager.USER.login}" + GitHubManager._REPO_PATH = f"https://{EM.GH_TOKEN}@github.com/{GitHubManager._REMOTE_NAME}.git" + + GitHubManager.REMOTE = github.get_repo(GitHubManager._REMOTE_NAME) + GitHubManager.REPO = Repo.clone_from(GitHubManager._REPO_PATH, to_path=clone_path) + + GitHubManager.REPO.git.checkout(GitHubManager.branch()) + # TODO: delete and recreate branch if single commit @staticmethod - def _generate_new_readme(stats: str) -> str: - """ - Generates new README.md file, inserts its contents between start and end tags. - - :param stats: contents to insert. - :returns: new README.md string. - """ - readme_stats = f"{GitHubManager._START_COMMENT}\n{stats}\n{GitHubManager._END_COMMENT}" - return sub(GitHubManager._README_REGEX, readme_stats, GitHubManager._README_CONTENTS) - - @staticmethod - def _get_author() -> InputGitAuthor: + def _get_author() -> Actor: """ Gets GitHub commit author specified by environmental variables. It is the user himself or a 'readme-bot'. @@ -69,9 +66,9 @@ class GitHubManager: :returns: Commit author. """ if EM.COMMIT_BY_ME: - return InputGitAuthor(GitHubManager.USER.login or EM.COMMIT_USERNAME, GitHubManager.USER.email or EM.COMMIT_EMAIL) + return Actor(EM.COMMIT_USERNAME or GitHubManager.USER.login, EM.COMMIT_EMAIL or GitHubManager.USER.email) else: - return InputGitAuthor(EM.COMMIT_USERNAME or "readme-bot", EM.COMMIT_EMAIL or "41898282+github-actions[bot]@users.noreply.github.com") + return Actor(EM.COMMIT_USERNAME or "readme-bot", EM.COMMIT_EMAIL or "41898282+github-actions[bot]@users.noreply.github.com") @staticmethod def branch() -> str: @@ -84,82 +81,95 @@ class GitHubManager: return GitHubManager.REMOTE.default_branch if EM.BRANCH_NAME == "" else EM.BRANCH_NAME @staticmethod - def update_readme(stats: str) -> bool: + def _copy_file_and_add_to_repo(src_path: str): + """ + Copies file to repository folder, creating path if needed and adds file to git. + The copied file relative to repository root path will be equal the source file relative to work directory path. + + :param src_path: Source file path. + """ + dst_path = join(GitHubManager.REPO.working_tree_dir, src_path) + makedirs(dirname(src_path), exist_ok=True) + copy(dst_path, src_path) + GitHubManager.REPO.git.add(dst_path) + + @staticmethod + def update_readme(stats: str): """ Updates readme with given data if necessary. Uses commit author, commit message and branch name specified by environmental variables. - - :returns: whether the README.md file was updated or not. """ DBM.i("Updating README...") - new_readme = GitHubManager._generate_new_readme(stats) - if new_readme != GitHubManager._README_CONTENTS: - GitHubManager.REMOTE.update_file( - path=GitHubManager._README.path, - message=EM.COMMIT_MESSAGE, - content=new_readme, - sha=GitHubManager._README.sha, - branch=GitHubManager.branch(), - committer=GitHubManager._get_author(), - ) - DBM.g("README updated!") - return True - else: - DBM.w("README update not needed!") - return False + readme_path = join(GitHubManager.REPO.working_tree_dir, GitHubManager.REMOTE.get_readme().path) + + with open(readme_path, "r") as readme_file: + readme_contents = readme_file.read() + readme_stats = f"{GitHubManager._START_COMMENT}\n{stats}\n{GitHubManager._END_COMMENT}" + new_readme = sub(GitHubManager._README_REGEX, readme_stats, readme_contents) + + with open(readme_path, "w") as readme_file: + readme_file.write(new_readme) + + GitHubManager.REPO.git.add(readme_path) + DBM.g("README updated!") @staticmethod - def set_github_output(stats: str) -> bool: + def update_chart(name: str, path: str) -> str: """ - Outputs readme data as current action output instead of committing it. + Updates a chart. + Inlines data into readme if in debug mode, commits otherwise. + Uses commit author, commit message and branch name specified by environmental variables. - param stats: Readme stats to be outputted. + :param name: Name of the chart to update. + :param path: Path of the chart to update. + :returns: String to add to README file. + """ + output = str() + DBM.i(f"Updating {name} chart...") + if not EM.DEBUG_RUN: + DBM.i("\tAdding chart to repo...") + GitHubManager._copy_file_and_add_to_repo(path) + chart_path = f"https://raw.githubusercontent.com/{GitHubManager._REMOTE_NAME}/{GitHubManager.branch()}/{path}" + output += f"![{name} chart]({chart_path})\n\n" + + else: + DBM.i("\tInlining chart...") + hint = "You can use [this website](https://codebeautify.org/base64-to-image-converter) to view the generated base64 image." + with open(path, "rb") as input_file: + output += f"{hint}\n```\ndata:image/png;base64,{b64encode(input_file.read()).decode('utf-8')}\n```\n\n" + return output + + @staticmethod + def commit_update(): + """ + Commit update data to repository. + """ + actor = GitHubManager._get_author() + DBM.i("Committing files to repo...") + GitHubManager.REPO.index.commit(EM.COMMIT_MESSAGE, author=actor, committer=actor) + DBM.i("Pushing files to repo...") + headers = GitHubManager.REPO.remote(name="origin").push() + if len(headers) == 0: + DBM.i(f"Repository push error: {headers}!") + else: + DBM.i("Repository synchronized!") + + @staticmethod + def set_github_output(stats: str): + """ + Output readme data as current action output instead of committing it. + + :param stats: String representation of stats to output. """ DBM.i("Setting README contents as action output...") if "GITHUB_OUTPUT" not in environ.keys(): DBM.p("Not in GitHub environment, not setting action output!") - return False + return + else: + DBM.i("Outputting readme contents, check the latest comment for the generated stats.") prefix = "README stats current output:" eol = "".join(choice(ascii_letters) for _ in range(10)) FM.write_file(environ["GITHUB_OUTPUT"], f"README_CONTENT<<{eol}\n{prefix}\n\n{stats}\n{eol}\n", append=True) DBM.g("Action output set!") - return True - - @staticmethod - def update_chart(chart_path: str) -> str: - """ - Updates lines of code chart. - Inlines data into readme if in debug mode, commits otherwise. - Uses commit author, commit message and branch name specified by environmental variables. - - :param chart_path: path to saved lines of code chart. - :returns: string to add to README file. - """ - DBM.i("Updating lines of code chart...") - with open(chart_path, "rb") as input_file: - data = input_file.read() - - if not EM.DEBUG_RUN: - DBM.i("Pushing chart to repo...") - - try: - contents = GitHubManager.REMOTE.get_contents(chart_path) - GitHubManager.REMOTE.update_file(contents.path, "Charts Updated", data, contents.sha, committer=GitHubManager._get_author()) - DBM.g("Lines of code chart updated!") - except UnknownObjectException: - GitHubManager.REMOTE.create_file(chart_path, "Charts Added", data, committer=GitHubManager._get_author()) - DBM.g("Lines of code chart created!") - - chart_path = f"https://raw.githubusercontent.com/{GitHubManager.USER.login}/{GitHubManager.USER.login}/{GitHubManager.branch()}/{chart_path}" - return f"**{FM.t('Timeline')}**\n\n![Lines of Code chart]({chart_path})\n\n" - - else: - DBM.i("Inlining chart...") - hint = "You can use [this website](https://codebeautify.org/base64-to-image-converter) to view the generated base64 image." - return f"{hint}\n```\ndata:image/png;base64,{b64encode(data).decode('utf-8')}\n```\n\n" - - @staticmethod - def commit_repo(): - pass