diff --git a/README.md b/README.md index 37a2b9e..0a10fbf 100644 --- a/README.md +++ b/README.md @@ -18,3 +18,9 @@ python -m venv .venv source .venv/bin/activate just install ``` + +## Usage + +![image](screenshot.png) +![image](screenshot2.png) +![image](screenshot3.png) diff --git a/django_mongodb_cli/__init__.py b/django_mongodb_cli/__init__.py index a4ab4e7..de68fed 100644 --- a/django_mongodb_cli/__init__.py +++ b/django_mongodb_cli/__init__.py @@ -14,7 +14,6 @@ dm = typer.Typer( help=help_text, - add_completion=False, context_settings={"help_option_names": ["-h", "--help"]}, ) diff --git a/django_mongodb_cli/repo.py b/django_mongodb_cli/repo.py index e1c1b8a..25ce1ad 100644 --- a/django_mongodb_cli/repo.py +++ b/django_mongodb_cli/repo.py @@ -48,14 +48,20 @@ def status( all_repos: bool = typer.Option( False, "--all-repos", "-a", help="Show status of all repos" ), + reset: bool = typer.Option( + False, "--reset", "-r", help="Reset the status of the repository" + ), ): + repo = Repo() + if reset: + repo.set_reset(reset) repo_command( all_repos, repo_name, all_msg="Showing status for all repositories...", missing_msg="Please specify a repository name or use --all-repos to show all repositories.", - single_func=lambda name: Repo().get_repo_status(name), - all_func=lambda name: Repo().get_repo_status(name), + single_func=lambda name: repo.get_repo_status(name), + all_func=lambda name: repo.get_repo_status(name), ) @@ -106,6 +112,30 @@ def clone_repo(name): ) +@repo.command() +def commit( + repo_name: str = typer.Argument(None), + all_repos: bool = typer.Option( + False, "--all-repos", "-a", help="Commit all repositories" + ), + message: str = typer.Argument(None, help="Commit message"), +): + def do_commit(name): + if not message: + typer.echo(typer.style("Commit message is required.", fg=typer.colors.RED)) + raise typer.Exit(1) + Repo().commit_repo(name, message) + + repo_command( + all_repos, + repo_name, + all_msg="Committing all repositories...", + missing_msg="Please specify a repository name or use --all-repos to commit all repositories.", + single_func=do_commit, + all_func=do_commit, + ) + + @repo.command() def delete( repo_name: str = typer.Argument(None), @@ -149,20 +179,75 @@ def install( ) +@repo.command() +def log( + repo_name: str = typer.Argument(None), + all_repos: bool = typer.Option( + False, "--all-repos", "-a", help="Show logs of all repositories" + ), +): + repo_command( + all_repos, + repo_name, + all_msg="Showing logs for all repositories...", + missing_msg="Please specify a repository name or use --all-repos to show logs of all repositories.", + single_func=lambda name: Repo().get_repo_log(repo_name), + all_func=lambda name: Repo().get_repo_log(repo_name), + ) + + +@repo.command() +def open( + repo_name: str = typer.Argument(None), + all_repos: bool = typer.Option( + False, "--all-repos", "-a", help="Open all repositories" + ), +): + repo_command( + all_repos, + repo_name, + all_msg="Opening all repositories...", + missing_msg="Please specify a repository name or use --all-repos to open all repositories.", + single_func=lambda name: Repo().open_repo(repo_name), + all_func=lambda name: Repo().open_repo(repo_name), + ) + + @repo.command() def origin( repo_name: str = typer.Argument(None), + repo_user: str = typer.Argument(None), all_repos: bool = typer.Option( False, "--all-repos", "-a", help="Show origin of all repositories" ), ): + repo = Repo() + if repo_user: + repo.set_user(repo_user) repo_command( all_repos, repo_name, all_msg="Showing origin for all repositories...", missing_msg="Please specify a repository name or use --all-repos to show origins of all repositories.", - single_func=lambda name: Repo().get_repo_origin(name), - all_func=lambda name: Repo().get_repo_origin(name), + single_func=lambda name: repo.get_repo_origin(name), + all_func=lambda name: repo.get_repo_origin(name), + ) + + +@repo.command() +def pr( + repo_name: str = typer.Argument(None), + all_repos: bool = typer.Option( + False, "--all-repos", "-a", help="Create pull requests for all repositories" + ), +): + repo_command( + all_repos, + repo_name, + all_msg="Creating pull requests for all repositories...", + missing_msg="Please specify a repository name or use --all-repos to create pull requests for all repositories.", + single_func=lambda name: Repo().create_pr(repo_name), + all_func=lambda name: Repo().create_pr(repo_name), ) @@ -183,6 +268,20 @@ def sync( ) +@repo.command() +def patch( + repo_name: str = typer.Argument(None), +): + repo_command( + False, + repo_name, + all_msg="Running evergreen...", + missing_msg="Please specify a repository name.", + single_func=lambda name: Test().patch_repo(name), + all_func=lambda name: Test().patch_repo(name), + ) + + @repo.command() def test( repo_name: str = typer.Argument(None), diff --git a/django_mongodb_cli/utils.py b/django_mongodb_cli/utils.py index 52824fb..5c27d8a 100644 --- a/django_mongodb_cli/utils.py +++ b/django_mongodb_cli/utils.py @@ -4,6 +4,7 @@ import re import shutil import subprocess +import tempfile from git import Repo as GitRepo from pathlib import Path @@ -27,6 +28,8 @@ def __init__(self, pyproject_file: Path = Path("pyproject.toml")): self.path = Path(self.config["tool"]["django_mongodb_cli"]["path"]) self.map = self.get_map() self.branch = None + self.user = None + self.reset = False def _load_config(self) -> dict: return toml.load(self.pyproject_file) @@ -99,6 +102,98 @@ def clone_repo(self, repo_name: str) -> None: ) ) + def commit_repo(self, repo_name: str, message: str = "") -> None: + """ + Commit changes to the specified repository with a commit message. + If no message is given, open editor for the commit message. + """ + typer.echo( + typer.style( + f"Committing changes to repository: {repo_name}", fg=typer.colors.CYAN + ) + ) + + path = self.get_repo_path(repo_name) + if not os.path.exists(path): + typer.echo( + typer.style( + f"Repository '{repo_name}' not found at path: {path}", + fg=typer.colors.RED, + ) + ) + return + + if not message.strip(): + # Open editor + editor = os.environ.get("EDITOR", "vi") + with tempfile.NamedTemporaryFile(suffix=".tmp") as tf: + tf.write( + b"# Enter commit message. Lines starting with '#' will be ignored.\n" + ) + tf.flush() + subprocess.call([editor, tf.name]) + tf.seek(0) + # Read and filter lines + content = [] + for line in tf: + # skip comment lines like in git + if not line.decode().startswith("#"): + content.append(line.decode()) + # Join lines, strip whitespace + message = "".join(content).strip() + if not message: + typer.echo( + typer.style( + "Aborting commit due to empty commit message.", + fg=typer.colors.YELLOW, + ) + ) + return + + repo = self.get_repo(path) + repo.git.add(A=True) + repo.git.commit(m=message) + + def create_pr(self, repo_name: str) -> None: + """ + Create a pull request for the specified repository. + """ + typer.echo( + typer.style( + f"Creating pull request for repository: {repo_name}", + fg=typer.colors.CYAN, + ) + ) + + path = self.get_repo_path(repo_name) + if not os.path.exists(path): + typer.echo( + typer.style( + f"Repository '{repo_name}' not found at path: {path}", + fg=typer.colors.RED, + ) + ) + return + repo = self.get_repo(path) + try: + repo.git.push("origin", repo.active_branch.name) + subprocess.run( + ["gh", "pr", "create"], + check=True, + cwd=path, + ) + typer.echo( + typer.style( + f"✅ Pull request created for {repo_name}.", fg=typer.colors.GREEN + ) + ) + except subprocess.CalledProcessError as e: + typer.echo( + typer.style( + f"❌ Failed to create pull request: {e}", fg=typer.colors.RED + ) + ) + def delete_repo(self, repo_name: str) -> None: """ Delete the specified repository. @@ -131,6 +226,36 @@ def delete_repo(self, repo_name: str) -> None: ) ) + def get_repo_log(self, repo_name: str) -> None: + """ + Get the commit log for the specified repository. + """ + typer.echo( + typer.style( + f"Getting commit log for repository: {repo_name}", fg=typer.colors.CYAN + ) + ) + + path = self.get_repo_path(repo_name) + if not os.path.exists(path): + typer.echo( + typer.style( + f"Repository '{repo_name}' not found at path: {path}", + fg=typer.colors.RED, + ) + ) + return + + repo = self.get_repo(path) + log_entries = repo.git.log( + "--pretty=format:%h - %an, %ar : %s", + "--abbrev-commit", + "--date=relative", + "--graph", + ).splitlines() + for entry in log_entries: + typer.echo(f" - {entry}") + def get_map(self) -> dict: """ Return a dict mapping repo_name to repo_url from repos in @@ -147,11 +272,6 @@ def get_repo_branches(self, repo_name: str) -> list: Get a list of both local and remote branches for the specified repository. Optionally, if self.branch is set, switch to it (existing) or create new (checkout -b). """ - typer.echo( - typer.style( - f"Getting branches for repository: {repo_name}", fg=typer.colors.CYAN - ) - ) path = self.get_repo_path(repo_name) if not os.path.exists(path): @@ -175,35 +295,23 @@ def get_repo_branches(self, repo_name: str) -> list: if ref.name != "origin/HEAD" ] - # Merge, deduplicate, and sort - all_branches = sorted(set(local_branches + remote_branches)) + if self.branch: + typer.echo( + typer.style(f"Checking out branch: {self.branch}", fg=typer.colors.CYAN) + ) + repo.git.checkout(self.branch) + return typer.echo( typer.style( - f"Branches in {repo_name}: {', '.join(all_branches)}", - fg=typer.colors.GREEN, + f"Getting branches for repository: {repo_name}", fg=typer.colors.CYAN ) ) - if getattr(self, "branch", None): - if self.branch in local_branches: - typer.echo( - typer.style( - f"Checking out existing branch '{self.branch}'", - fg=typer.colors.YELLOW, - ) - ) - repo.git.checkout(self.branch) - else: - typer.echo( - typer.style( - f"Branch '{self.branch}' does not exist. Creating and checking out new branch.", - fg=typer.colors.YELLOW, - ) - ) - repo.git.checkout("-b", self.branch) - - return all_branches + # Merge, deduplicate, and sort + all_branches = sorted(set(local_branches + remote_branches)) + for name in sorted(all_branches): + typer.echo(f" - {name}") def get_repo_origin(self, repo_name: str) -> str: """ @@ -227,11 +335,20 @@ def get_repo_origin(self, repo_name: str) -> str: repo = self.get_repo(path) origin_url = repo.remotes.origin.url - typer.echo( - typer.style( - f"Origin URL for {repo_name}: {origin_url}", fg=typer.colors.GREEN - ) + origin_users = ( + self.config.get("tool").get("django_mongodb_cli").get("origin", []) ) + if repo_name in origin_users and self.user: + for user in origin_users[repo_name]: + if user.get("user") == self.user: + origin_url = user.get("repo") + origin = repo.remotes.origin + origin.set_url(origin_url) + typer.echo( + typer.style( + f"Origin URL for {repo_name}: {origin_url}", fg=typer.colors.GREEN + ) + ) return origin_url def get_repo_path(self, name: str) -> Path: @@ -244,6 +361,27 @@ def get_repo_status(self, repo_name: str) -> str: """ Get the status of a repository. """ + if self.reset: + typer.echo( + typer.style(f"Resetting repository: {repo_name}", fg=typer.colors.CYAN) + ) + path = self.get_repo_path(repo_name) + if not os.path.exists(path): + typer.echo( + typer.style( + f"Repository '{repo_name}' not found at path: {path}", + fg=typer.colors.RED, + ) + ) + return + repo = self.get_repo(path) + repo.git.reset("--hard") + typer.echo( + typer.style( + f"✅ Repository {repo_name} has been reset.", fg=typer.colors.GREEN + ) + ) + return typer.echo( typer.style( f"Getting status for repository: {repo_name}", fg=typer.colors.CYAN @@ -268,6 +406,8 @@ def get_repo_status(self, repo_name: str) -> str: typer.echo( typer.style(f"On branch: {repo.active_branch}", fg=typer.colors.CYAN) ) + self.get_repo_origin(repo_name) + unstaged = repo.index.diff(None) if unstaged: typer.echo( @@ -294,6 +434,13 @@ def get_repo_status(self, repo_name: str) -> str: "\nNothing to commit, working tree clean.", fg=typer.colors.GREEN ) ) + # Diff the working tree + working_tree_diff = repo.git.diff() + if working_tree_diff: + typer.echo( + typer.style("\nWorking tree differences:", fg=typer.colors.YELLOW) + ) + typer.echo(working_tree_diff) def list_repos(self) -> None: """ @@ -330,7 +477,7 @@ def list_repos(self) -> None: if in_both: typer.echo( typer.style( - "Repositories in both self.map and filesystem:", + "Repositories in pyproject.toml and on filesystem:", fg=typer.colors.GREEN, ) ) @@ -339,14 +486,16 @@ def list_repos(self) -> None: if only_in_map: typer.echo( - typer.style("Repositories only in self.map:", fg=typer.colors.YELLOW) + typer.style( + "Repositories only in pyproject.toml:", fg=typer.colors.YELLOW + ) ) for name in sorted(only_in_map): typer.echo(f" - {name}") if only_in_fs: typer.echo( - typer.style("Repositories only in filesystem:", fg=typer.colors.MAGENTA) + typer.style("Repositories only on filesystem:", fg=typer.colors.MAGENTA) ) for name in sorted(only_in_fs): typer.echo(f" - {name}") @@ -354,6 +503,43 @@ def list_repos(self) -> None: if not (in_both or only_in_map or only_in_fs): typer.echo("No repositories found.") + def open_repo(self, repo_name: str) -> None: + """ + Open the specified repository with `gh browse` command. + """ + typer.echo( + typer.style(f"Opening repository: {repo_name}", fg=typer.colors.CYAN) + ) + + path = self.get_repo_path(repo_name) + if not os.path.exists(path): + typer.echo( + typer.style( + f"Repository '{repo_name}' not found at path: {path}", + fg=typer.colors.RED, + ) + ) + return + try: + subprocess.run( + ["gh", "browse"], + check=True, + cwd=path, + ) + typer.echo( + typer.style( + f"✅ Successfully opened {repo_name} in browser.", + fg=typer.colors.GREEN, + ) + ) + except subprocess.CalledProcessError as e: + typer.echo( + typer.style( + f"❌ Failed to open {repo_name} in browser: {e}", + fg=typer.colors.RED, + ) + ) + def run_tests(self, repo_name: str) -> None: """ Run tests for the specified repository. @@ -385,9 +571,20 @@ def run_tests(self, repo_name: str) -> None: def set_branch(self, branch: str) -> None: self.branch = branch + def set_reset(self, reset: bool) -> None: + self.reset = reset + + def set_user(self, user: str) -> None: + """ + Set the user for the repository operations. + This can be used to specify which user to use for operations like cloning. + """ + self.user = user + typer.echo(typer.style(f"User set to: {self.user}", fg=typer.colors.CYAN)) + def sync_repo(self, repo_name: str) -> None: """ - Synchronize the repository by pulling the latest changes. + Synchronize the repository by pulling the latest changes and then pushing local changes. """ typer.echo(typer.style("Synchronizing repository...", fg=typer.colors.CYAN)) path = self.get_repo_path(repo_name) @@ -409,7 +606,29 @@ def sync_repo(self, repo_name: str) -> None: repo.remotes.origin.pull() typer.echo( typer.style( - f"✅ Successfully synchronized {repo_name}.", fg=typer.colors.GREEN + f"✅ Successfully pulled latest changes for {repo_name}.", + fg=typer.colors.GREEN, + ) + ) + # Identify current branch + current_branch = repo.active_branch.name + typer.echo( + typer.style( + f"Pushing local changes to origin/{current_branch}...", + fg=typer.colors.CYAN, + ) + ) + repo.remotes.origin.push(refspec=current_branch) + typer.echo( + typer.style( + f"✅ Successfully pushed to origin/{current_branch}.", + fg=typer.colors.GREEN, + ) + ) + typer.echo( + typer.style( + f"✅ Repository {repo_name} is synchronized (pull & push complete).", + fg=typer.colors.GREEN, ) ) except Exception as e: @@ -571,6 +790,28 @@ def copy_migrations(self, repo_name: str) -> None: ) ) + def patch_repo(self, repo_name: str) -> None: + """ + Run evergreen patching operations on the specified repository. + """ + typer.echo( + typer.style( + f"Running `evergreen patch` for: {repo_name}", fg=typer.colors.CYAN + ) + ) + project_name = ( + self.config.get("tool", {}) + .get("django_mongodb_cli", {}) + .get("evergreen", {}) + .get(repo_name) + .get("project_name") + ) + subprocess.run( + ["evergreen", "patch", "-p", project_name, "-u", "--finalize"], + check=True, + cwd=self.get_repo_path(repo_name), + ) + def run_tests(self, repo_name: str) -> None: self.test_settings = ( self.config.get("tool", {}) @@ -597,21 +838,30 @@ def run_tests(self, repo_name: str) -> None: self.copy_apps(repo_name) self.copy_migrations(repo_name) self.copy_settings(repo_name) + + test_command_name = self.test_settings.get("test_command") + test_command = [test_command_name] if test_command_name else ["pytest"] test_options = self.test_settings.get("test_options") - test_command = [self.test_settings.get("test_command")] - settings_module = ( + test_settings_module = ( self.test_settings.get("settings", {}).get("module", {}).get("test") ) + + if test_command_name == "./runtests.py": + test_command.extend(["--settings", test_settings_module]) if test_options: test_command.extend(test_options) - if settings_module and test_command == "./runtests.py": - test_command.extend(["--settings", settings_module]) if self.keep_db: test_command.extend("--keepdb") if self.keyword: test_command.extend(["-k", self.keyword]) if self.modules: test_command.extend(self.modules) + typer.echo( + typer.style( + f"Running tests with command: {' '.join(test_command)}", + fg=typer.colors.CYAN, + ) + ) subprocess.run( test_command, cwd=test_dir, diff --git a/pyproject.toml b/pyproject.toml index 51034d7..4f5b2e6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -278,3 +278,14 @@ migrations = "allauth.mongo_settings" [tool.django_mongodb_cli.test.django-allauth.migrations_dir] source = "mongo_migrations" target = "src/django-allauth/allauth/mongo_migrations" + +[[tool.django_mongodb_cli.origin.django-mongodb-backend]] +user = "aclark4life" +repo = "git+ssh://git@github.com/aclark4life/django-mongodb-backend" + +[[tool.django_mongodb_cli.origin.django-mongodb-backend]] +user = "mongodb" +repo = "git+ssh://git@github.com/mongodb/django-mongodb-backend" + +[tool.django_mongodb_cli.evergreen.django-mongodb-backend] +project_name = "django-mongodb" diff --git a/screenshot.png b/screenshot.png new file mode 100644 index 0000000..dafb83e Binary files /dev/null and b/screenshot.png differ diff --git a/screenshot2.png b/screenshot2.png new file mode 100644 index 0000000..6b0e504 Binary files /dev/null and b/screenshot2.png differ diff --git a/screenshot3.png b/screenshot3.png new file mode 100644 index 0000000..77bd0be Binary files /dev/null and b/screenshot3.png differ