diff --git a/.gitignore b/.gitignore index 9a5b8dc..70dbdd0 100644 --- a/.gitignore +++ b/.gitignore @@ -24,8 +24,6 @@ venv.bak/ Thumbs.db *.txt -.chronotab/ todo/ .coverage -gitgo-simulation/ -simulate_gitgo.py \ No newline at end of file +src/pygitgo/commands/tag.py \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 1200c60..7800a6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,8 +8,13 @@ Versions follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added +- **Project Scaffolding:** Added `gitgo init` to handle local project setup, gitignore fetching, and GitHub template downloads. +- **Remote Repo Creation:** Added `gitgo new` to create remote GitHub repositories without leaving the terminal. + ### Changed - **Documentation:** Added `docs/login-guide.md`, a step-by-step login guide with screenshots covering the full `gitgo user login` flow for starters. +- **Internal Utils:** Centralized browser opening logic and updated the auth manager to support repo creation tokens. --- diff --git a/src/pygitgo/auth/account.py b/src/pygitgo/auth/account.py index 81ced47..07640b8 100644 --- a/src/pygitgo/auth/account.py +++ b/src/pygitgo/auth/account.py @@ -21,7 +21,6 @@ def get_user(): return name, email - def set_user(name, email): run_command(["git", "config", "--global", "user.name", name]) run_command(["git", "config", "--global", "user.email", email]) @@ -66,4 +65,3 @@ def ensure_user_configure(default_email=None, default_username=None): error("Invalid configuration. Name and Email are required.") return False - diff --git a/src/pygitgo/auth/manager.py b/src/pygitgo/auth/manager.py index 5a2a34e..3ca1558 100644 --- a/src/pygitgo/auth/manager.py +++ b/src/pygitgo/auth/manager.py @@ -1,6 +1,7 @@ from pygitgo.utils.colors import info, success, warning, error from pygitgo.utils.executor import run_command from pygitgo.exceptions import GitCommandError +from pygitgo.utils.platform import open_url from . import ssh_utils import os @@ -39,7 +40,7 @@ def login(): info(" 2. Once as 'Signing Key' (so your commits show as Verified)") info("Both entries use the exact same key text.") - ssh_utils.open_github_settings() + open_url("https://github.com/settings/ssh/new") input( "After adding both keys on GitHub,\n" @@ -65,6 +66,7 @@ def login(): info(" 1. The key was not pasted on GitHub") info(" 2. SSH agent is not running (try: eval $(ssh-agent) && ssh-add)") info(" 3. Network or firewall is blocking SSH connections") + info("Need help? Full guide: https://github.com/Huerte/GitGo/blob/main/docs/login-guide.md") return False def logout(): diff --git a/src/pygitgo/auth/ssh_utils.py b/src/pygitgo/auth/ssh_utils.py index e2b0673..d5098ba 100644 --- a/src/pygitgo/auth/ssh_utils.py +++ b/src/pygitgo/auth/ssh_utils.py @@ -1,11 +1,8 @@ from pygitgo.exceptions import GitCommandError, GitGoError from pygitgo.utils.colors import info, success, warning from pygitgo.utils.executor import run_command -from pygitgo.utils import platform as platform_utils from pathlib import Path -import webbrowser import subprocess -import shutil import os import re @@ -103,27 +100,6 @@ def generate_ssh_key(email): return key_path -def open_github_settings(): - url = "https://github.com/settings/ssh/new" - opened = False - - try: - if platform_utils.is_termux(): - if shutil.which("termux-open"): - subprocess.run(["termux-open", url], check=False) - opened = True - else: - opened = webbrowser.open(url) - except Exception: - opened = False - - if not opened: - warning("Could not open browser automatically.") - - info("\nIf the browser did not open, visit this URL manually:") - print(f"\n {url}\n") - - def convert_https_to_ssh(url): pattern = r'^https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?/?$' match = re.match(pattern, url.strip()) diff --git a/src/pygitgo/commands/init.py b/src/pygitgo/commands/init.py new file mode 100644 index 0000000..e69de29 diff --git a/src/pygitgo/commands/new.py b/src/pygitgo/commands/new.py new file mode 100644 index 0000000..ea544db --- /dev/null +++ b/src/pygitgo/commands/new.py @@ -0,0 +1,104 @@ +from pygitgo.utils.colors import info, success +from pygitgo.utils.platform import open_url +from pygitgo.exceptions import GitGoError +import subprocess +import urllib +import json +import os + +GITHUB_API = "https://api.github.com" + +def _get_github_token(): + + token = os.environ.get("GITHUB_TOKEN", "").strip() + if token: + return token + + try: + result = subprocess.run( + ["gh", "auth", "token"], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + token = result.stdout.strip() + if token: + return token + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + + info("GitGo needs a GitHub token to create repositories.") + info("Required scope: repo") + open_url("https://github.com/settings/tokens/new?scopes=repo") + + token = input( + "After creating the token on GitHub,\n" + "come back here and paste it: " + ).strip() + + if not token: + raise GitGoError("Cancelled. No repository was created.") + + return token + + +def create_github_repo(name, private=False, description="", token=None): + + if not token: + token = _get_github_token() + + payload = json.dumps({ + "name": name, + "private": private, + "description": description, + }).encode("utf-8") + + req = urllib.request.Request( + f"{GITHUB_API}/user/repos", + data=payload, + headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "Content-Type": "application/json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "GitGo-CLI" + }, method="POST", + ) + + try: + with urllib.request.urlopen(req, timeout=15) as resp: + return json.loads(resp.read().decode("utf-8")) + except urllib.error.HTTPError as e: + if e.code == 422: + raise GitGoError(f"Repository '{name}' already exists on GitHub.") + elif e.code == 401: + raise GitGoError("Unauthorized. Your token might be expired. Try logging in again.") + + body = e.read().decode("utf-8", errors="replace") + try: + msg = json.loads(body).get("message", body) + except Exception: + msg = body + raise GitGoError(f"GitHub API error {e.code}: {msg}") + except urllib.error.URLError as e: + raise GitGoError(f"Network error creating repo: {e.reason}") + + +def new_operation(args): + + if args.name: + repo_name = args.name + else: + repo_name = os.path.basename(os.path.abspath(".")) + info(f"No name given: using current directory name: '{repo_name}'") + + token = _get_github_token() + info(f"Creating GitHub repository '{repo_name}'...") + repo_ = create_github_repo( + name=repo_name, + private=args.private, + description=args.description or "", + token=token + ) + repo_url = repo_.get("clone_url") + success(f"Successfully created remote repository: {repo_url}") + info(f"\nTo connect and push your local code, run:\n gitgo link {repo_url}") diff --git a/src/pygitgo/main.py b/src/pygitgo/main.py index dba7579..c0fb450 100644 --- a/src/pygitgo/main.py +++ b/src/pygitgo/main.py @@ -9,6 +9,7 @@ from pygitgo.commands.link import link_operation from pygitgo.commands.push import push_operation from pygitgo.commands.user import user_operation +from pygitgo.commands.new import new_operation from pygitgo.exceptions import GitGoError import argparse import sys @@ -146,6 +147,37 @@ def main(): ) pull_parser.add_argument("branch", nargs="?", default=None, help="The branch to pull from (default is your current branch)") + new_parser = subparsers.add_parser( + "new", + help="Create a GitHub repo and link the current project to it", + epilog=( + "Examples:\n" + " gitgo new Use current directory name as repo name\n" + " gitgo new my-app Create repo 'my-app' and push\n" + " gitgo new my-app --private Create a private repo\n" + ), + formatter_class=argparse.RawDescriptionHelpFormatter + ) + new_parser.add_argument( + "name", + default=None, + nargs="?", + metavar="NAME", + help="Repository name. Defaults to current directory name." + ) + new_parser.add_argument( + "-p", "--private", + default=False, + action="store_true", + help="Create a private repository." + ) + new_parser.add_argument( + "-d", "--description", + default="", + metavar="TEXT", + help="Short repository description shown on GitHub." + ) + args = parser.parse_args() if getattr(args, 'version', False): @@ -184,6 +216,8 @@ def main(): undo_operation(args) elif args.command == "pull": pull_operation(args) + elif args.command == "new": + new_operation(args) except GitGoError as e: error(f"{e}") sys.exit(1) diff --git a/src/pygitgo/utils/platform.py b/src/pygitgo/utils/platform.py index 04eac8f..b32f4b0 100644 --- a/src/pygitgo/utils/platform.py +++ b/src/pygitgo/utils/platform.py @@ -1,4 +1,8 @@ +from pygitgo.utils.colors import warning, info +import webbrowser +import subprocess import platform +import shutil import os @@ -25,3 +29,22 @@ def is_termux(): return True return False + + +def open_url(url: str): + opened = False + try: + if is_termux(): + if shutil.which("termux-open"): + subprocess.run(["termux-open", url], check=False) + opened = True + else: + opened = webbrowser.open(url) + except Exception: + opened = False + + if not opened: + warning("Could not open browser automatically.") + info("\nIf the browser did not open, visit this URL manually:") + print(f"\n {url}\n") + diff --git a/tests/test_manager.py b/tests/test_manager.py index 6b438a5..c4a4c6d 100644 --- a/tests/test_manager.py +++ b/tests/test_manager.py @@ -1,12 +1,11 @@ -import pytest -from pathlib import Path -from pygitgo.exceptions import GitCommandError from pygitgo.auth.manager import login, logout +from pathlib import Path def test_login_already_logged_in(mocker): fake_check = mocker.patch("pygitgo.auth.manager.ssh_utils.check_connection", return_value=True) fake_success = mocker.patch("pygitgo.auth.manager.success") + mocker.patch("pygitgo.auth.manager.open_url") result = login() @@ -19,13 +18,13 @@ def test_login_new_user_success(mocker): # check_connection returns False first, then True on verification fake_check = mocker.patch("pygitgo.auth.manager.ssh_utils.check_connection", side_effect=[False, True]) mocker.patch("pygitgo.auth.manager.info") + mocker.patch("pygitgo.auth.manager.open_url") mocker.patch("pygitgo.auth.manager.success") mocker.patch("builtins.input", side_effect=["test@example.com", ""]) mocker.patch("pygitgo.auth.manager.ssh_utils.generate_ssh_key", return_value=Path("mock_key")) # Mock reading public key mocker.patch("builtins.open", mocker.mock_open(read_data="ssh-rsa AAAAB3NzaC1yc2E...")) - mocker.patch("pygitgo.auth.manager.ssh_utils.open_github_settings") mocker.patch("pygitgo.auth.manager.ssh_utils.get_github_username", return_value="GithubUser") fake_ensure = mocker.patch("pygitgo.auth.account.ensure_user_configure", return_value=True) @@ -42,8 +41,9 @@ def test_login_new_user_verification_fails(mocker): mocker.patch("pygitgo.auth.manager.error") mocker.patch("builtins.input", side_effect=["test@example.com", ""]) mocker.patch("pygitgo.auth.manager.ssh_utils.generate_ssh_key", return_value=Path("mock_key")) + mocker.patch("pygitgo.auth.manager.open_url") mocker.patch("builtins.open", mocker.mock_open(read_data="ssh-rsa AAAAB3NzaC1yc2E...")) - mocker.patch("pygitgo.auth.manager.ssh_utils.open_github_settings") + result = login() diff --git a/tests/test_new.py b/tests/test_new.py new file mode 100644 index 0000000..50442c9 --- /dev/null +++ b/tests/test_new.py @@ -0,0 +1,66 @@ +import pytest +from unittest.mock import patch, MagicMock +from pygitgo.commands.new import ( + _get_github_token, + create_github_repo, + new_operation, +) +from pygitgo.exceptions import GitGoError + + +@patch.dict("os.environ", {"GITHUB_TOKEN": "test-token"}) +def test_get_github_token_env(): + assert _get_github_token() == "test-token" + + +@patch.dict("os.environ", {}, clear=True) +@patch("subprocess.run") +def test_get_github_token_gh_cli(mock_run): + mock_run.return_value = MagicMock(returncode=0, stdout=" cli-token ") + assert _get_github_token() == "cli-token" + + +@patch.dict("os.environ", {}, clear=True) +@patch("subprocess.run") +@patch("pygitgo.commands.new.open_url") +@patch("builtins.input", return_value="user-pasted-token") +def test_get_github_token_guided(mock_input, mock_open_url, mock_run): + mock_run.side_effect = FileNotFoundError() + assert _get_github_token() == "user-pasted-token" + mock_open_url.assert_called_once_with("https://github.com/settings/tokens/new?scopes=repo") + + +@patch.dict("os.environ", {}, clear=True) +@patch("subprocess.run") +@patch("pygitgo.commands.new.open_url") +@patch("builtins.input", return_value="") +def test_get_github_token_cancelled(mock_input, mock_open_url, mock_run): + mock_run.side_effect = FileNotFoundError() + with pytest.raises(GitGoError, match="Cancelled"): + _get_github_token() + + +@patch("urllib.request.urlopen") +def test_create_github_repo_success(mock_urlopen): + mock_response = MagicMock() + mock_response.read.return_value = b'{"clone_url": "https://github.com/user/repo.git"}' + mock_urlopen.return_value.__enter__.return_value = mock_response + + res = create_github_repo("repo", token="token") + assert res["clone_url"] == "https://github.com/user/repo.git" + + +@patch("pygitgo.commands.new._get_github_token", return_value="token") +@patch("pygitgo.commands.new.create_github_repo") +def test_new_operation(mock_create, mock_token, capsys): + mock_create.return_value = {"clone_url": "https://github.com/user/repo.git"} + args = MagicMock() + args.name = "repo" + args.private = False + args.description = "desc" + + new_operation(args) + + captured = capsys.readouterr() + assert "Successfully created remote repository" in captured.out + assert "gitgo link" in captured.out \ No newline at end of file diff --git a/tests/test_platform.py b/tests/test_platform.py index cb51eda..473e33f 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -1,30 +1,62 @@ -from pygitgo.utils.platform import get_platform - -def test_get_platform_on_windows(mocker): - mocker.patch('platform.system', return_value='Windows') - - result = get_platform() - assert result == 'windows' - - -def test_get_platform_on_mac(mocker): - mocker.patch('platform.system', return_value='Darwin') - - result = get_platform() - assert result == 'macos' - - -def test_get_platform_on_linux(mocker): - mocker.patch('platform.system', return_value='Linux') - mocker.patch('pygitgo.utils.platform.is_termux', return_value=False) - - result = get_platform() - assert result == 'linux' - - -def test_get_platform_on_termux(mocker): - mocker.patch('platform.system', return_value='Linux') - mocker.patch('pygitgo.utils.platform.is_termux', return_value=True) - - result = get_platform() - assert result == 'termux' \ No newline at end of file +from pygitgo.utils.platform import get_platform + +def test_get_platform_on_windows(mocker): + mocker.patch('platform.system', return_value='Windows') + + result = get_platform() + assert result == 'windows' + + +def test_get_platform_on_mac(mocker): + mocker.patch('platform.system', return_value='Darwin') + + result = get_platform() + assert result == 'macos' + + +def test_get_platform_on_linux(mocker): + mocker.patch('platform.system', return_value='Linux') + mocker.patch('pygitgo.utils.platform.is_termux', return_value=False) + + result = get_platform() + assert result == 'linux' + + +def test_get_platform_on_termux(mocker): + mocker.patch('platform.system', return_value='Linux') + mocker.patch('pygitgo.utils.platform.is_termux', return_value=True) + + result = get_platform() + assert result == 'termux' + + +def test_open_url_browser(mocker): + mocker.patch("pygitgo.utils.platform.is_termux", return_value=False) + mock_open = mocker.patch("webbrowser.open", return_value=True) + mocker.patch("pygitgo.utils.platform.info") + + from pygitgo.utils.platform import open_url + open_url("https://example.com") + mock_open.assert_called_once_with("https://example.com") + + +def test_open_url_termux(mocker): + mocker.patch("pygitgo.utils.platform.is_termux", return_value=True) + mocker.patch("shutil.which", return_value="/usr/bin/termux-open") + mock_run = mocker.patch("subprocess.run") + mocker.patch("pygitgo.utils.platform.info") + + from pygitgo.utils.platform import open_url + open_url("https://example.com") + mock_run.assert_called_once_with(["termux-open", "https://example.com"], check=False) + + +def test_open_url_failure(mocker): + mocker.patch("pygitgo.utils.platform.is_termux", return_value=False) + mocker.patch("webbrowser.open", side_effect=Exception("browser error")) + mock_warning = mocker.patch("pygitgo.utils.platform.warning") + mocker.patch("pygitgo.utils.platform.info") + + from pygitgo.utils.platform import open_url + open_url("https://example.com") + mock_warning.assert_called_once_with("Could not open browser automatically.") \ No newline at end of file diff --git a/tests/test_ssh_utils.py b/tests/test_ssh_utils.py index 3ce9de7..455031d 100644 --- a/tests/test_ssh_utils.py +++ b/tests/test_ssh_utils.py @@ -1,11 +1,10 @@ -import pytest -from pathlib import Path -from pygitgo.exceptions import GitCommandError, GitGoError from pygitgo.auth.ssh_utils import ( ensure_github_known_host, check_connection, get_github_username, - get_ssh_key_path, generate_ssh_key, open_github_settings, - convert_https_to_ssh, is_ssh_url + generate_ssh_key, convert_https_to_ssh, is_ssh_url ) +from pygitgo.exceptions import GitGoError +from pathlib import Path +import pytest def test_ensure_github_known_host_already_exists(mocker):