diff --git a/.submodules/setup/Pipfile b/.submodules/setup/Pipfile index d35fbe7c..415df001 100644 --- a/.submodules/setup/Pipfile +++ b/.submodules/setup/Pipfile @@ -5,6 +5,7 @@ name = "pypi" [packages] colorama = "==0.4.6" +inquirer = "==3.4.0" [dev-packages] black = "==24.8.0" diff --git a/.submodules/setup/Pipfile.lock b/.submodules/setup/Pipfile.lock index 4ff413e0..4c63982b 100644 --- a/.submodules/setup/Pipfile.lock +++ b/.submodules/setup/Pipfile.lock @@ -1,7 +1,7 @@ { "_meta": { "hash": { - "sha256": "7ace988b06a3bbcd451ebf9dcd145ccc9de0d3fd55f583a0619944a4381aa98e" + "sha256": "7698450d46a6ab01ec8333eebb2719c8b41f0ccdf7e17ca7d53b4fa258503a92" }, "pipfile-spec": 6, "requires": { @@ -16,6 +16,14 @@ ] }, "default": { + "blessed": { + "hashes": [ + "sha256:0c542922586a265e699188e52d5f5ac5ec0dd517e5a1041d90d2bbf23f906058", + "sha256:2cdd67f8746e048f00df47a2880f4d6acbcdb399031b604e34ba8f71d5787680" + ], + "markers": "python_version >= '2.7'", + "version": "==1.20.0" + }, "colorama": { "hashes": [ "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", @@ -24,6 +32,62 @@ "index": "pypi", "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5, 3.6'", "version": "==0.4.6" + }, + "editor": { + "hashes": [ + "sha256:bb6989e872638cd119db9a4fce284cd8e13c553886a1c044c6b8d8a160c871f8", + "sha256:e818e6913f26c2a81eadef503a2741d7cca7f235d20e217274a009ecd5a74abf" + ], + "markers": "python_version >= '3.8'", + "version": "==1.6.6" + }, + "inquirer": { + "hashes": [ + "sha256:8edc99c076386ee2d2204e5e3653c2488244e82cb197b2d498b3c1b5ffb25d0b", + "sha256:bb0ec93c833e4ce7b51b98b1644b0a4d2bb39755c39787f6a504e4fee7a11b60" + ], + "index": "pypi", + "markers": "python_full_version >= '3.8.1'", + "version": "==3.4.0" + }, + "readchar": { + "hashes": [ + "sha256:2a587a27c981e6d25a518730ad4c88c429c315439baa6fda55d7a8b3ac4cb62a", + "sha256:44807cbbe377b72079fea6cba8aa91c809982d7d727b2f0dbb2d1a8084914faa" + ], + "markers": "python_version >= '3.8'", + "version": "==4.2.0" + }, + "runs": { + "hashes": [ + "sha256:0980dcbc25aba1505f307ac4f0e9e92cbd0be2a15a1e983ee86c24c87b839dfd", + "sha256:9dc1815e2895cfb3a48317b173b9f1eac9ba5549b36a847b5cc60c3bf82ecef1" + ], + "markers": "python_version >= '3.8'", + "version": "==1.2.2" + }, + "six": { + "hashes": [ + "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926", + "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254" + ], + "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'", + "version": "==1.16.0" + }, + "wcwidth": { + "hashes": [ + "sha256:3da69048e4540d84af32131829ff948f1e022c1c6bdb8d6102117aac784f6859", + "sha256:72ea0c06399eb286d978fdedb6923a9eb47e1c486ce63e9b4e64fc18303972b5" + ], + "version": "==0.2.13" + }, + "xmod": { + "hashes": [ + "sha256:38c76486b9d672c546d57d8035df0beb7f4a9b088bc3fb2de5431ae821444377", + "sha256:a24e9458a4853489042522bdca9e50ee2eac5ab75c809a91150a8a7f40670d48" + ], + "markers": "python_version >= '3.8'", + "version": "==1.8.1" } }, "develop": { @@ -74,11 +138,11 @@ }, "dill": { "hashes": [ - "sha256:3ebe3c479ad625c4553aca177444d89b486b1d84982eeacded644afc0cf797ca", - "sha256:c36ca9ffb54365bdd2f8eb3eff7d2a21237f8452b57ace88b1ac615b7e815bd7" + "sha256:468dff3b89520b474c0397703366b7b95eebe6303f108adf9b19da1f702be87a", + "sha256:81aa267dddf68cbfe8029c42ca9ec6a4ab3b22371d1c450abc54422577b4512c" ], "markers": "python_version >= '3.11'", - "version": "==0.3.8" + "version": "==0.3.9" }, "iniconfig": { "hashes": [ @@ -164,11 +228,11 @@ }, "platformdirs": { "hashes": [ - "sha256:50a5450e2e84f44539718293cbb1da0a0885c9d14adf21b77bae4e66fc99d9b5", - "sha256:d4e0b7d8ec176b341fb03cb11ca12d0276faa8c485f9cd218f613840463fc2c0" + "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907", + "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb" ], "markers": "python_version >= '3.8'", - "version": "==4.3.3" + "version": "==4.3.6" }, "pluggy": { "hashes": [ diff --git a/.submodules/setup/__main__.py b/.submodules/setup/__main__.py index b94dec84..e6f5f05e 100644 --- a/.submodules/setup/__main__.py +++ b/.submodules/setup/__main__.py @@ -6,15 +6,19 @@ """ import json +import os import re import subprocess +import sys import typing as t -from argparse import ArgumentParser, Namespace from dataclasses import dataclass from pathlib import Path +from shutil import rmtree from subprocess import CalledProcessError +from time import sleep -from colorama import Fore, Style +import inquirer # type: ignore[import-untyped] +from colorama import Back, Fore, Style from colorama import init as colorama_init BASE_DIR = Path(__file__).resolve().parent.parent.parent @@ -28,21 +32,139 @@ class Submodule: url: str -def get_namespace() -> Namespace: - """Get the command line values passed to this script. +def print_intro(): + """Prints the Code For Life logo with ascii art.""" + # short hand + M, C, Y = Fore.MAGENTA, Fore.CYAN, Fore.YELLOW + + print( + Style.BRIGHT + + f""" + {M}_____ {Y}_ ______ _ {M}_ {C}__ + {M}/ ____| {Y}| | | ____| | | {M}(_){C}/ _| +{M}| | {C}___ {Y}__| | {M}___ {Y}| |__ {M}___ {C}_ __ {Y}| | {M}_| {C}|_ {Y}___ +{M}| | {C}/ _ \\ {Y}/ _` |{M}/ _ \\ {Y}| __{M}/ _ \\{C}| '__| {Y}| | {M}| | {C}_{Y}/ _ \\ +{M}| |___{C}| (_) | {Y}(_| | {M}__/ {Y}| | {M}| (_) {C}| | {Y}| |____{M}| | {C}|{Y}| __/ + {M}\\_____{C}\\___/ {Y}\\__,_|{M}\\___| {Y}|_| {M}\\___/{C}|_| {Y}|______{M}|_|{C}_| {Y}\\___| +""" + + Style.RESET_ALL + + "\nTo learn more, " + + generate_console_link( + "https://docs.codeforlife.education/", + "read our documentation", + ) + + " and " + + generate_console_link( + "https://www.codeforlife.education/", + "visit our site", + ) + + ".\n\n" + + "👇👀👇 " + + Style.BRIGHT + + Back.YELLOW + + "PLEASE READ INSTRUCTIONS" + + Style.RESET_ALL + + " 👇👀👇\n\n" + + "This script will help you set up your CFL dev container by:\n" + + " - forking each repo within our " + + generate_console_link( + "https://github.com/ocadotechnology/codeforlife-workspace", + "workspace", + ) + + " into your personal GitHub account\n" + + " - cloning each fork from your personal GitHub account into this" + + " container\n\n" + + "In a moment you will be asked to log into your personal GitHub" + + " account so that we may set up your CFL dev container as described" + + " above. Use your keyboard to select/input your option when prompted." + + "\n\n" + + Style.DIM + + "If you have any concerns about logging into your personal GitHub" + + " account, rest assured we don't perform any malicious actions with" + " it. You're welcome to read the source code of this script here: " + + "/codeforlife-workspace/.submodules/setup/__main__.py.\n\n" + + Style.RESET_ALL + + "👆👀👆 " + + Style.BRIGHT + + Back.YELLOW + + "PLEASE READ INSTRUCTIONS" + + Style.RESET_ALL + + " 👆👀👆\n" + ) + input( + "Press " + + Style.BRIGHT + + "Enter" + + Style.RESET_ALL + + " after you have read the instructions..." + ) + print() - Returns: - An object containing all the command line values. + answers = inquirer.prompt( + [ + inquirer.Confirm( + "proceed", + message="Would you like to proceed with setting up your dev container?", + ) + ] + ) + + if answers and not t.cast(bool, answers["proceed"]): + sys.exit() + + +def print_exit(error: bool): + """Prints the exiting statement to the console. + + Args: + error: Whether there was an error during the script-run. """ - arg_parser = ArgumentParser() - arg_parser.add_argument( - "--skip-login", - action="store_true", - dest="skip_login", - default=False, + print() + print( + "💥💣💥 " + + Style.BRIGHT + + Fore.RED + + "Finished with errors." + + Style.RESET_ALL + + " 💥💣💥\n\n" + + "This may not be an issue and may be occurring because you've run" + + " this setup script before. Please read the above logs to discover if" + + " further action is required." + + "\n\n" + + "If you require help, please reach out to " + + generate_console_link( + "mailto:codeforlife@ocado.com", + "codeforlife@ocado.com", + ) + + "." + if error + else "✨🍰✨ " + + Style.BRIGHT + + Fore.GREEN + + "Finished without errors." + + Style.RESET_ALL + + " ✨🍰✨\n\n" + + "Happy coding!" ) - return arg_parser.parse_args() + +def generate_console_link( + url: str, + label: t.Optional[str] = None, + parameters: str = "", +): + """Generates a link to be printed in the console. + + Args: + url: The link to follow. + label: The label of the link. If not given, the url will be the label. + parameters: Any url parameters you may have. + + Returns: + A link that can be clicked in the console. + """ + # OSC 8 ; params ; URI ST OSC 8 ;; ST + return f"\033]8;{parameters};{url}\033\\{label or url}\033]8;;\033\\" def read_submodules() -> t.Dict[str, Submodule]: @@ -78,23 +200,51 @@ def read_submodules() -> t.Dict[str, Submodule]: def login_to_github(): - """Log into GitHub with the CLI and setup Git to use the CLI as a credential - helper. + """Log into GitHub with the CLI. + https://cli.github.com/manual/gh_auth_status + https://cli.github.com/manual/gh_auth_logout https://cli.github.com/manual/gh_auth_login - https://cli.github.com/manual/gh_auth_setup-git """ - subprocess.run( - ["gh", "auth", "login", "--web"], - check=True, - ) - subprocess.run( - ["gh", "auth", "setup-git"], - check=True, - ) + print(Style.BRIGHT + "Checking if you are logged into GitHub..." + Style.RESET_ALL) + + logged_in = True + + try: + subprocess.run( + ["gh", "auth", "status"], + check=True, + ) + except CalledProcessError: + logged_in = False + + if logged_in: + answers = inquirer.prompt( + [ + inquirer.Confirm( + "stay_logged_in", + message="Continue with logged in account?", + ) + ] + ) + + if answers: + logged_in = t.cast(bool, answers["stay_logged_in"]) + + if not logged_in: + subprocess.run( + ["gh", "auth", "logout"], + check=True, + ) + + if not logged_in: + subprocess.run( + ["gh", "auth", "login", "--web", "--git-protocol=https"], + check=True, + ) -def fork_repo(name: str, url: str): +def fork_repo(url: str): """Fork a repo on GitHub. https://cli.github.com/manual/gh_repo_fork @@ -102,8 +252,11 @@ def fork_repo(name: str, url: str): Args: owner: The owner of the repo to fork. name: The name of the repo to fork. + + Returns: + A flag designating whether the repo was successfully forked. """ - print(Style.BRIGHT + f'Forking repo "{name}".' + Style.RESET_ALL) + print(Style.BRIGHT + "Forking repo..." + Style.RESET_ALL) try: subprocess.run( @@ -118,26 +271,81 @@ def fork_repo(name: str, url: str): check=True, ) except CalledProcessError: - pass + print(Style.BRIGHT + Fore.RED + "Failed to fork repo." + Style.RESET_ALL) + + return False + + return True def clone_repo(name: str, path: str): + # pylint: disable=line-too-long """Clone a repo from GitHub. https://cli.github.com/manual/gh_repo_clone Args: name: The name of the repo to clone. + path: The paths to clone the repo to. + + Returns: + A flag designating whether the repo was successfully cloned. """ - print(Style.BRIGHT + f'Cloning repo "{name}".' + Style.RESET_ALL) + # pylint: enable=line-too-long + print(Style.BRIGHT + "Cloning repo..." + Style.RESET_ALL) - try: - subprocess.run( - ["gh", "repo", "clone", name, str(BASE_DIR / path)], - check=True, + repo_dir = str(BASE_DIR / path) + + if os.path.isdir(repo_dir) and os.listdir(repo_dir): + print(Style.BRIGHT + repo_dir + Style.RESET_ALL + " already exists.") + + answers = inquirer.prompt( + [ + inquirer.Confirm( + "overwrite", + message=( + "Delete the repo's current directory and clone the repo in" + " the directory?" + ), + ) + ] ) - except CalledProcessError: - pass + + if not answers or not t.cast(bool, answers["overwrite"]): + return True + + rmtree(repo_dir) + + max_attempts = 5 + retry_delay = 1 + retry_attempts = max_attempts - 1 + for attempt_index in range(max_attempts): + try: + subprocess.run( + ["gh", "repo", "clone", name, repo_dir], + check=True, + ) + + return True + except CalledProcessError: + if os.path.isdir(repo_dir): + rmtree(repo_dir) + + if attempt_index != retry_attempts: + print( + Style.BRIGHT + + Fore.YELLOW + + f"Retrying clone in {retry_delay} seconds." + + f" Attempt {attempt_index + 1}/{retry_attempts}." + + Style.RESET_ALL + ) + + sleep(retry_delay) + retry_delay *= 2 + + print(Style.BRIGHT + Fore.RED + "Failed to clone repo." + Style.RESET_ALL) + + return False def view_repo(name: str): @@ -148,19 +356,24 @@ def view_repo(name: str): Args: name: The name of the repo to view. """ - print(Style.BRIGHT + f'Viewing repo "{name}".' + Style.RESET_ALL) + print(Style.BRIGHT + "Viewing repo..." + Style.RESET_ALL) - repo_str = subprocess.run( - [ - "gh", - "repo", - "view", - name, - "--json=" + ",".join(["name", "url", "createdAt", "isFork"]), - ], - check=True, - stdout=subprocess.PIPE, - ).stdout.decode("utf-8") + try: + repo_str = subprocess.run( + [ + "gh", + "repo", + "view", + name, + "--json=" + ",".join(["name", "url", "createdAt", "isFork"]), + ], + check=True, + stdout=subprocess.PIPE, + ).stdout.decode("utf-8") + except CalledProcessError: + print(Style.BRIGHT + Fore.YELLOW + "Failed to view repo." + Style.RESET_ALL) + + return repo = json.loads(repo_str) print(json.dumps(repo, indent=2)) @@ -170,21 +383,34 @@ def main() -> None: """Entry point.""" colorama_init() - namespace = get_namespace() + print_intro() submodules = read_submodules() - if not namespace.skip_login: - login_to_github() + login_to_github() + + error = False + + for i, (name, submodule) in enumerate(submodules.items(), start=1): + print( + Style.DIM + + Back.GREEN + + f"Submodule ({i}/{len(submodules)}): {name}" + + Style.RESET_ALL + ) + + forked_repo = fork_repo(submodule.url) - for name, submodule in submodules.items(): - fork_repo(name, submodule.url) + cloned_repo = False + if forked_repo: + cloned_repo = clone_repo(name, submodule.path) - clone_repo(name, submodule.path) + view_repo(name) - view_repo(name) + if not error and (not forked_repo or not cloned_repo): + error = True - print(Style.BRIGHT + Fore.GREEN + "Setup completed." + Style.RESET_ALL) + print_exit(error) if __name__ == "__main__":