diff --git a/AGENTS-example.md b/AGENTS-example.md index bd323ec..f5fea9c 100644 --- a/AGENTS-example.md +++ b/AGENTS-example.md @@ -11,7 +11,8 @@ ghsudo gh issue comment 42 --body "Done!" ## Rules +- **Always** use `https://` URLs for git remotes so that `git push`/`pull` can use the `gh` credential helper. `ghsudo` itself injects `GH_TOKEN`/`GITHUB_TOKEN` for the `gh` CLI and does not depend on the remote URL scheme. If git push/pull fails over HTTPS, remind the user to run `gh auth setup-git`. - **Never** bypass `ghsudo` or ask the user for the write token directly. -- Exit code 2 (denied): stop and report to the user. -- Exit code 3 (no interactive session): inform the user approval is not possible. +- Exit code 2 (denied or timed out): stop and report to the user. +- Exit code 3 (no GUI display or no supported dialog tool): inform the user approval is not possible. - Exit code 4 (not set up): tell the user to follow . diff --git a/CLAUDE-example.md b/CLAUDE-example.md index bd323ec..f5fea9c 100644 --- a/CLAUDE-example.md +++ b/CLAUDE-example.md @@ -11,7 +11,8 @@ ghsudo gh issue comment 42 --body "Done!" ## Rules +- **Always** use `https://` URLs for git remotes so that `git push`/`pull` can use the `gh` credential helper. `ghsudo` itself injects `GH_TOKEN`/`GITHUB_TOKEN` for the `gh` CLI and does not depend on the remote URL scheme. If git push/pull fails over HTTPS, remind the user to run `gh auth setup-git`. - **Never** bypass `ghsudo` or ask the user for the write token directly. -- Exit code 2 (denied): stop and report to the user. -- Exit code 3 (no interactive session): inform the user approval is not possible. +- Exit code 2 (denied or timed out): stop and report to the user. +- Exit code 3 (no GUI display or no supported dialog tool): inform the user approval is not possible. - Exit code 4 (not set up): tell the user to follow . diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..494b52d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,69 @@ +# GitHub access — two-token model + +You have a **read-only** `GH_TOKEN`. For write operations, use `ghsudo`: + +``` +ghsudo gh pr merge 123 --merge +ghsudo gh issue comment 42 --body "Done!" +``` + +`ghsudo` shows a dialog and runs the command with elevated permissions only after user approval. + +## Rules + +- **Always** use `https://` URLs for git remotes so that `git push`/`pull` can use the `gh` credential helper. `ghsudo` itself injects `GH_TOKEN`/`GITHUB_TOKEN` for the `gh` CLI and does not depend on the remote URL scheme. If git push/pull fails over HTTPS, remind the user to run `gh auth setup-git`. +- **Never** bypass `ghsudo` or ask the user for the write token directly. +- Exit code 2 (denied or timed out): stop and report to the user. +- Exit code 3 (no GUI display or no supported dialog tool): inform the user approval is not possible. +- Exit code 4 (not set up): tell the user to follow . + +# ghsudo — Developer Guide + +## Project structure + +- `src/ghsudo/__main__.py` — CLI entry point and all logic (single-file tool) +- `src/ghsudo/__init__.py` — package init, reads version from package metadata +- `pyproject.toml` — build config and **single source of truth for version** +- `tests/` — pytest tests + +## Version management + +Version is defined **only** in `pyproject.toml`. The `__init__.py` reads it at +runtime via `importlib.metadata.version("ghsudo")`. + +When bumping the version, edit **only** `pyproject.toml`. + +## Testing + +```bash +python -m pytest tests/ -v +``` + +Only run formatting/linting right before committing: + +```bash +python -m ruff check src/ tests/ && python -m ruff format src/ tests/ +``` + +## Release process + +1. **Bump version** in `pyproject.toml` (semver: major.minor.patch) +2. **Commit**: `git commit -m "build: bump version to X.Y.Z"` +3. **Push**: `git push` +4. **Create GitHub release** (triggers PyPI publish via CI): + ```bash + ghsudo gh release create vX.Y.Z --generate-notes + ``` +5. **Verify** the publish workflow completes on GitHub Actions. + +The `publish.yml` workflow builds and publishes to PyPI automatically when a +GitHub release is created. + +## Platform support + +Only Linux is actively tested. macOS/Windows have basic support but are untested. + +## Security design + +GUI-only approval is intentional — terminal prompts are trivially auto-approvable +by AI agents. A graphical display (`DISPLAY`/`WAYLAND_DISPLAY`) is required. diff --git a/README.md b/README.md index 728b031..374cd3e 100644 --- a/README.md +++ b/README.md @@ -21,7 +21,7 @@ The naive solutions both have drawbacks: 2. **Write token** — stored encrypted on your machine. When the agent needs to perform a write operation (a `gh` command that would otherwise fail with HTTP 403), it calls `ghsudo` instead. `ghsudo` then: -- Shows you a **GUI popup** (or terminal prompt) listing the exact command to be executed. +- Shows you a **GUI popup** listing the exact command to be executed. - **Waits for your explicit approval** before proceeding. - If approved, re-runs the command with the elevated write token injected into the environment. - If denied (or timed out after 60 s), exits with a non-zero code so the agent knows it was blocked. @@ -42,8 +42,16 @@ cd ghsudo pip install . ``` +> **Note:** For `git push`/`pull` to work with `ghsudo`'s elevated token, your remotes need `https://` URLs (not SSH). `ghsudo` injects `GH_TOKEN`/`GITHUB_TOKEN` which the `gh` credential helper uses for HTTPS Git operations. (`ghsudo gh ...` commands work regardless of remote URL scheme.) +> To configure `gh` as the Git credential helper, run: +> ```bash +> gh auth setup-git +> ``` + **Requirement:** Python 3.10+ +> **Note:** Only **Linux** is actively tested. macOS and Windows have basic support (GUI dialogs, path handling) but are **not tested** — contributions welcome. + ## Quick Start ```bash @@ -181,7 +189,6 @@ GitHub Sudo — re-execute commands with per-org elevated tokens. Options: --org ORG Target org (auto-detected from -R flag or git remote) - --no-gui Skip GUI dialog, use terminal prompt only --setup ORG Store encrypted GitHub PAT for an org --verify [ORG] Verify stored token(s) --revoke [ORG] Revoke stored token(s) @@ -211,7 +218,9 @@ On **Linux**, `ghsudo` tries (in order): `xmessage`, `zenity`, `kdialog`. On **macOS**, it uses `osascript` (the built-in AppleScript runner). On **Windows**, it uses PowerShell's `MessageBox`. -If no GUI is available (e.g. headless server), it falls back to a terminal prompt. Use `--no-gui` to force terminal-only mode. +A graphical display is **required** — `ghsudo` will refuse to run without one, because a terminal prompt can be trivially auto-approved by an AI agent, defeating the purpose. If no GUI toolkit is found, `ghsudo` exits with code 3. + +> **Tip:** If you run your agent on a remote machine via SSH, use `ssh -X` (X11 forwarding) so that `ghsudo` GUI dialogs appear on your local display. The dialog auto-denies after **60 seconds** of no response to prevent the agent from hanging indefinitely. @@ -238,7 +247,7 @@ The dialog auto-denies after **60 seconds** of no response to prevent the agent | 0 | Success | | 1 | Error | | 2 | User denied the request | -| 3 | No interactive session available to ask for approval | +| 3 | No graphical display available, or no supported GUI dialog tool found | | 4 | No token stored for the target org | ## Debugging diff --git a/pyproject.toml b/pyproject.toml index 40368b6..a0c9907 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "ghsudo" -version = "0.1.0" +version = "0.2.0" description = "GitHub Sudo — re-execute commands with an elevated GitHub token after user approval" readme = "README.md" license = { file = "LICENSE" } diff --git a/src/ghsudo/__init__.py b/src/ghsudo/__init__.py index c9fd61a..5a8b87b 100644 --- a/src/ghsudo/__init__.py +++ b/src/ghsudo/__init__.py @@ -1,3 +1,8 @@ """ghsudo — GitHub Sudo: re-execute commands with an elevated GitHub token.""" -__version__ = "1.0.0" +from importlib.metadata import PackageNotFoundError, version + +try: + __version__ = version("ghsudo") +except PackageNotFoundError: + __version__ = "0.0.0+unknown" diff --git a/src/ghsudo/__main__.py b/src/ghsudo/__main__.py index 2334b92..338985d 100644 --- a/src/ghsudo/__main__.py +++ b/src/ghsudo/__main__.py @@ -146,9 +146,7 @@ def _derive_machine_key() -> bytes: raise RuntimeError("Cannot derive machine key: no stable identifiers") raw = "|".join(components).encode("utf-8") - key = hashlib.pbkdf2_hmac( - "sha256", raw, _PBKDF2_SALT, _PBKDF2_ITERATIONS - ) + key = hashlib.pbkdf2_hmac("sha256", raw, _PBKDF2_SALT, _PBKDF2_ITERATIONS) _debug("machine key derived") return key @@ -240,9 +238,7 @@ def _load_token(org: str) -> str: _err(f"Available orgs: {', '.join(orgs)}\n") _err("To set up a token, run:") _err(f" ghsudo --setup {org}\n") - _err( - "This will prompt you for a GitHub Personal Access Token with" - ) + _err("This will prompt you for a GitHub Personal Access Token with") _err("write permissions and store it encrypted on this machine.\n") _err(f"See: {_README_URL}") sys.exit(EXIT_NO_TOKEN) @@ -253,9 +249,7 @@ def _load_token(org: str) -> str: return _decrypt_token(data, key) except Exception: # noqa: BLE001 _err(f"Failed to decrypt token for org '{org}'.") - _err( - "Was it stored on a different machine, or did the hostname change?" - ) + _err("Was it stored on a different machine, or did the hostname change?") _err(f"Re-run: ghsudo --setup {org}") sys.exit(EXIT_ERROR) @@ -265,27 +259,36 @@ def _load_token(org: str) -> str: # --------------------------------------------------------------------------- -def _detect_org_from_args(cmd: list[str]) -> str | None: - """Extract org from -R/--repo owner/repo in gh command args.""" +def _parse_repo_slug(value: str) -> str | None: + """Validate and normalize an owner/repo slug. Returns None if invalid.""" + value = value.strip().lower() + parts = value.split("/") + if len(parts) == 2 and parts[0] and parts[1]: + return value + return None + + +def _detect_repo_slug_from_args(cmd: list[str]) -> str | None: + """Extract owner/repo slug from -R/--repo in gh command args.""" for i, arg in enumerate(cmd): if arg in ("-R", "--repo") and i + 1 < len(cmd): - repo_arg = cmd[i + 1] - if "/" in repo_arg: - return repo_arg.split("/")[0].lower() + slug = _parse_repo_slug(cmd[i + 1]) + if slug: + return slug # Handle --repo=owner/repo if arg.startswith("--repo="): - repo_arg = arg.split("=", 1)[1] - if "/" in repo_arg: - return repo_arg.split("/")[0].lower() + slug = _parse_repo_slug(arg.split("=", 1)[1]) + if slug: + return slug if arg.startswith("-R") and len(arg) > 2: - repo_arg = arg[2:] - if "/" in repo_arg: - return repo_arg.split("/")[0].lower() + slug = _parse_repo_slug(arg[2:]) + if slug: + return slug return None -def _detect_org_from_git_remote() -> str | None: - """Extract org from the current repo's origin remote.""" +def _detect_repo_slug_from_git_remote() -> str | None: + """Extract owner/repo slug from the current repo's origin remote.""" try: result = subprocess.run( ["git", "remote", "get-url", "origin"], @@ -300,18 +303,42 @@ def _detect_org_from_git_remote() -> str | None: return None # SSH: git@github.com:owner/repo.git - m = re.match(r"git@github\.com:([^/]+)/", url) + m = re.match(r"git@github\.com:([^/]+)/([^/]+?)(?:\.git)?$", url) if m: - return m.group(1).lower() + return f"{m.group(1).lower()}/{m.group(2).lower()}" # HTTPS: https://github.com/owner/repo.git - m = re.match(r"https?://github\.com/([^/]+)/", url) + m = re.match(r"https?://github\.com/([^/]+)/([^/]+?)(?:\.git)?$", url) if m: - return m.group(1).lower() + return f"{m.group(1).lower()}/{m.group(2).lower()}" return None +def _detect_repo_slug(cmd: list[str]) -> str | None: + """Auto-detect owner/repo slug from command args, then git remote.""" + slug = _detect_repo_slug_from_args(cmd) + if slug: + return slug + return _detect_repo_slug_from_git_remote() + + +def _detect_org_from_args(cmd: list[str]) -> str | None: + """Extract org from -R/--repo owner/repo in gh command args.""" + slug = _detect_repo_slug_from_args(cmd) + if slug and "/" in slug: + return slug.split("/")[0] + return None + + +def _detect_org_from_git_remote() -> str | None: + """Extract org from the current repo's origin remote.""" + slug = _detect_repo_slug_from_git_remote() + if slug and "/" in slug: + return slug.split("/")[0] + return None + + def _detect_org(cmd: list[str]) -> str | None: """Auto-detect org from command args, then git remote.""" org = _detect_org_from_args(cmd) @@ -334,9 +361,10 @@ def _escape_for_powershell(s: str) -> str: def _run_gui(cmd: list[str]) -> int | None: - """Run a GUI command with timeout. Returns exit code, or None on failure. + """Run a GUI command with timeout. Returns exit code, or None if not found. - Properly kills the child process on timeout (subprocess.run doesn't). + Returns 1 (denial) on timeout — the user didn't respond in time. + Returns None only when the tool is not installed (FileNotFoundError). """ _debug(f"gui: launching {cmd[0]}") try: @@ -354,68 +382,92 @@ def _run_gui(cmd: list[str]) -> int | None: _debug(f"gui: {cmd[0]} exited with {proc.returncode}") return proc.returncode except subprocess.TimeoutExpired: - _debug(f"gui: {cmd[0]} timed out after {_GUI_TIMEOUT}s, killing") + _debug(f"gui: {cmd[0]} timed out after {_GUI_TIMEOUT}s, auto-denying") proc.kill() proc.wait() - return None + _info(f"Dialog timed out after {_GUI_TIMEOUT}s — auto-denied.") + return 1 # treat as denial, not as tool-unavailable -def _format_approval_msg(cmd_str: str, org: str) -> str: +def _format_approval_msg(cmd_str: str, org: str, repo: str | None = None) -> str: + target = f"Repository: {repo}" if repo else f"Organization: {org}" return ( "A GitHub command requires elevated (write) permissions.\n\n" - f"Organization: {org}\n" + f"{target}\n" f"Command to execute:\n {cmd_str}\n\n" "Allow this command to run with elevated GitHub permissions?" ) -def _ask_xmessage(cmd_str: str, org: str) -> bool | None: +def _ask_xmessage(cmd_str: str, org: str, repo: str | None = None) -> bool | None: """Lightweight X11 dialog. Returns True=approved, False=denied, None=unavailable.""" - msg = _format_approval_msg(cmd_str, org) - rc = _run_gui([ - "xmessage", "-center", - "-xrm", "*international:true", - "-xrm", "*form.message.Scroll:WhenNeeded", - "-xrm", "*form.minimumWidth:500", - "-buttons", "Allow:0,Deny:1", - "-default", "Deny", msg, - ]) + msg = _format_approval_msg(cmd_str, org, repo) + rc = _run_gui( + [ + "xmessage", + "-center", + "-xrm", + "*international:true", + "-xrm", + "*form.message.Scroll:WhenNeeded", + "-xrm", + "*form.minimumWidth:500", + "-buttons", + "Allow:0,Deny:1", + "-default", + "Deny", + msg, + ] + ) if rc is None: return None return rc == 0 -def _ask_zenity(cmd_str: str, org: str) -> bool | None: +def _ask_zenity(cmd_str: str, org: str, repo: str | None = None) -> bool | None: """Returns True=approved, False=denied, None=unavailable.""" - msg = _format_approval_msg(cmd_str, org) - rc = _run_gui([ - "zenity", "--question", - "--title=GitHub Elevated Access (ghsudo)", - f"--text={msg}", "--width=500", - "--ok-label=Allow", "--cancel-label=Deny", - ]) + msg = _format_approval_msg(cmd_str, org, repo) + rc = _run_gui( + [ + "zenity", + "--question", + "--title=GitHub Elevated Access (ghsudo)", + f"--text={msg}", + "--width=500", + "--ok-label=Allow", + "--cancel-label=Deny", + ] + ) if rc is None: return None # not installed or timed out return rc == 0 -def _ask_kdialog(cmd_str: str, org: str) -> bool | None: +def _ask_kdialog(cmd_str: str, org: str, repo: str | None = None) -> bool | None: """Returns True=approved, False=denied, None=unavailable.""" - msg = _format_approval_msg(cmd_str, org) - rc = _run_gui([ - "kdialog", "--title", "GitHub Elevated Access (ghsudo)", - "--yesno", msg, - "--yes-label", "Allow", "--no-label", "Deny", - ]) + msg = _format_approval_msg(cmd_str, org, repo) + rc = _run_gui( + [ + "kdialog", + "--title", + "GitHub Elevated Access (ghsudo)", + "--yesno", + msg, + "--yes-label", + "Allow", + "--no-label", + "Deny", + ] + ) if rc is None: return None return rc == 0 -def _ask_osascript(cmd_str: str, org: str) -> bool | None: +def _ask_osascript(cmd_str: str, org: str, repo: str | None = None) -> bool | None: """Returns True=approved, False=denied, None=unavailable.""" escaped = _escape_for_applescript( - _format_approval_msg(cmd_str, org).replace("\n", "\\n") + _format_approval_msg(cmd_str, org, repo).replace("\n", "\\n") ) # "cancel button" makes Deny return exit code 1. script = ( @@ -430,17 +482,19 @@ def _ask_osascript(cmd_str: str, org: str) -> bool | None: return rc == 0 -def _ask_powershell(cmd_str: str, org: str) -> bool | None: +def _ask_powershell(cmd_str: str, org: str, repo: str | None = None) -> bool | None: """Returns True=approved, False=denied, None=unavailable.""" escaped = _escape_for_powershell(cmd_str) + raw_target = f"Repository: {repo}" if repo else f"Organization: {org}" + target = _escape_for_powershell(raw_target) ps = ( "Add-Type -AssemblyName System.Windows.Forms; " "$r = [System.Windows.Forms.MessageBox]::Show(" - f"\"A GitHub command requires elevated (write) permissions." - f"`n`nOrganization: {org}" + f'"A GitHub command requires elevated (write) permissions.' + f"`n`n{target}" f"`nCommand to execute:`n {escaped}`n`n" - f"Allow this command to run with elevated GitHub permissions?\"," - f"\"GitHub Elevated Access (ghsudo)\"," + f'Allow this command to run with elevated GitHub permissions?",' + f'"GitHub Elevated Access (ghsudo)",' "[System.Windows.Forms.MessageBoxButtons]::YesNo," "[System.Windows.Forms.MessageBoxIcon]::Warning); " 'if ($r -eq "Yes") { exit 0 } else { exit 1 }' @@ -451,19 +505,6 @@ def _ask_powershell(cmd_str: str, org: str) -> bool | None: return rc == 0 -def _ask_terminal(cmd_str: str, org: str) -> bool: - if not sys.stdin.isatty(): - return False - _info("GitHub elevated access required.") - _info(f"Organization: {org}") - _info(f"Command: {cmd_str}") - try: - answer = input(f"{_PREFIX} Allow? (yes/no): ").strip().lower() - except (EOFError, KeyboardInterrupt): - return False - return answer in ("yes", "y") - - def _has_display() -> bool: """Check if a graphical display is available.""" system = platform.system() @@ -475,38 +516,39 @@ def _has_display() -> bool: return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")) -def _ask_approval(cmd_str: str, org: str, *, no_gui: bool = False) -> bool: +def _ask_approval(cmd_str: str, org: str, *, repo: str | None = None) -> bool: """Ask the user to approve the command. Returns True if approved.""" system = platform.system() - _debug(f"approval: system={system}, no_gui={no_gui}, has_display={_has_display()}") + _debug(f"approval: system={system}, has_display={_has_display()}") - if not no_gui and _has_display(): + if _has_display(): gui_result = None if system == "Linux": # Try lightest first: xmessage → zenity → kdialog - gui_result = _ask_xmessage(cmd_str, org) + gui_result = _ask_xmessage(cmd_str, org, repo) if gui_result is None: - gui_result = _ask_zenity(cmd_str, org) + gui_result = _ask_zenity(cmd_str, org, repo) if gui_result is None: - gui_result = _ask_kdialog(cmd_str, org) + gui_result = _ask_kdialog(cmd_str, org, repo) elif system == "Darwin": - gui_result = _ask_osascript(cmd_str, org) + gui_result = _ask_osascript(cmd_str, org, repo) elif system == "Windows": - gui_result = _ask_powershell(cmd_str, org) + gui_result = _ask_powershell(cmd_str, org, repo) # If a GUI tool gave a definitive answer, use it (no terminal re-ask) if gui_result is not None: return gui_result - # Terminal fallback (only reached if GUI unavailable/timed out) - if _ask_terminal(cmd_str, org): - return True - - if not sys.stdin.isatty(): - _err("Cannot request approval: no display and no terminal available.") - sys.exit(EXIT_NO_INTERACTIVE) - - return False + # Cannot get user approval — either no display or no GUI toolkit found + if not _has_display(): + _err("Cannot request approval: no graphical display available.") + _err("Ensure DISPLAY or WAYLAND_DISPLAY is set (e.g. ssh -X).") + else: + _err("Cannot request approval: no supported GUI dialog tool found.") + if system == "Linux": + _err("Install one of: xmessage, zenity, kdialog.") + _err("Display is available but no toolkit could show the dialog.") + sys.exit(EXIT_NO_INTERACTIVE) # --------------------------------------------------------------------------- @@ -611,7 +653,7 @@ def cmd_setup(org: str) -> int: return EXIT_OK -def cmd_run(cmd: list[str], *, org: str | None = None, no_gui: bool = False) -> int: +def cmd_run(cmd: list[str], *, org: str | None = None) -> int: """Show approval dialog, then re-execute command with elevated token.""" if not cmd: _err("No command specified.") @@ -650,8 +692,12 @@ def cmd_run(cmd: list[str], *, org: str | None = None, no_gui: bool = False) -> cmd_str = shlex.join(cmd) + # Detect full repo slug (owner/repo) for display in approval dialog + repo_slug = _detect_repo_slug(cmd) + _debug(f"repo_slug={repo_slug}") + _debug("requesting approval") - if not _ask_approval(cmd_str, org, no_gui=no_gui): + if not _ask_approval(cmd_str, org, repo=repo_slug): _info("Permission denied by user.") return EXIT_DENIED _debug("approved, executing command") @@ -791,7 +837,6 @@ def cmd_list() -> int: Options: --org ORG Target org (auto-detected from -R flag or git remote) - --no-gui Skip GUI dialog, use terminal prompt only --setup ORG Store encrypted GitHub PAT for an org --verify [ORG] Verify stored token(s) --revoke [ORG] Revoke stored token(s) @@ -809,7 +854,6 @@ def main() -> int: # Parse -- flags, collect the rest as the command org: str | None = None - no_gui = False cmd: list[str] = [] i = 0 while i < len(argv): @@ -822,10 +866,18 @@ def main() -> int: elif arg == "--list": return cmd_list() elif arg == "--verify": - verify_org = argv[i + 1] if i + 1 < len(argv) and not argv[i + 1].startswith("--") else None + verify_org = ( + argv[i + 1] + if i + 1 < len(argv) and not argv[i + 1].startswith("--") + else None + ) return cmd_verify(verify_org) elif arg == "--revoke": - revoke_org = argv[i + 1] if i + 1 < len(argv) and not argv[i + 1].startswith("--") else None + revoke_org = ( + argv[i + 1] + if i + 1 < len(argv) and not argv[i + 1].startswith("--") + else None + ) return cmd_revoke(revoke_org) elif arg == "--org": if i + 1 >= len(argv): @@ -834,17 +886,13 @@ def main() -> int: org = argv[i + 1] i += 2 continue - elif arg == "--no-gui": - no_gui = True - i += 1 - continue else: # Everything from here on is the command cmd = argv[i:] break i += 1 - return cmd_run(cmd, org=org, no_gui=no_gui) + return cmd_run(cmd, org=org) if __name__ == "__main__": diff --git a/tests/test_detection.py b/tests/test_detection.py new file mode 100644 index 0000000..fbb0123 --- /dev/null +++ b/tests/test_detection.py @@ -0,0 +1,169 @@ +"""Tests for org and repo slug detection functions.""" + +from __future__ import annotations + +from unittest.mock import patch, MagicMock +import subprocess + + +from ghsudo.__main__ import ( + _detect_repo_slug, + _detect_repo_slug_from_args, + _detect_repo_slug_from_git_remote, + _format_approval_msg, +) + + +# --------------------------------------------------------------------------- +# _detect_repo_slug_from_args +# --------------------------------------------------------------------------- + + +class TestDetectRepoSlugFromArgs: + """Tests for extracting owner/repo from gh CLI arguments.""" + + def test_dash_R_separate(self): + assert ( + _detect_repo_slug_from_args(["gh", "pr", "-R", "acme/widget", "list"]) + == "acme/widget" + ) + + def test_dash_R_attached(self): + assert ( + _detect_repo_slug_from_args(["gh", "pr", "-Racme/widget", "list"]) + == "acme/widget" + ) + + def test_long_repo_separate(self): + assert ( + _detect_repo_slug_from_args(["gh", "issue", "--repo", "Org/Repo"]) + == "org/repo" + ) + + def test_long_repo_equals(self): + assert ( + _detect_repo_slug_from_args(["gh", "--repo=Org/Repo", "pr", "list"]) + == "org/repo" + ) + + def test_no_repo_flag(self): + assert _detect_repo_slug_from_args(["gh", "pr", "list"]) is None + + def test_dash_R_at_end_no_value(self): + assert _detect_repo_slug_from_args(["gh", "pr", "-R"]) is None + + def test_repo_without_slash(self): + """A bare name without '/' should not match.""" + assert _detect_repo_slug_from_args(["gh", "-R", "onlyorg"]) is None + + def test_whitespace_trimmed(self): + assert _detect_repo_slug_from_args(["gh", "-R", " Org/Repo "]) == "org/repo" + + def test_trailing_slash_rejected(self): + assert _detect_repo_slug_from_args(["gh", "-R", "org/"]) is None + + def test_leading_slash_rejected(self): + assert _detect_repo_slug_from_args(["gh", "-R", "/repo"]) is None + + def test_extra_segments_rejected(self): + assert _detect_repo_slug_from_args(["gh", "-R", "a/b/c"]) is None + + +# --------------------------------------------------------------------------- +# _detect_repo_slug_from_git_remote +# --------------------------------------------------------------------------- + + +def _mock_git_remote(url: str): + """Return a patch that makes `git remote get-url origin` return *url*.""" + result = MagicMock(spec=subprocess.CompletedProcess) + result.returncode = 0 + result.stdout = url + return patch("ghsudo.__main__.subprocess.run", return_value=result) + + +class TestDetectRepoSlugFromGitRemote: + """Tests for extracting owner/repo from git origin remote.""" + + def test_ssh_url(self): + with _mock_git_remote("git@github.com:lklimek/ghsudo.git\n"): + assert _detect_repo_slug_from_git_remote() == "lklimek/ghsudo" + + def test_ssh_url_no_dot_git(self): + with _mock_git_remote("git@github.com:lklimek/ghsudo\n"): + assert _detect_repo_slug_from_git_remote() == "lklimek/ghsudo" + + def test_https_url(self): + with _mock_git_remote("https://github.com/Acme/Widget.git\n"): + assert _detect_repo_slug_from_git_remote() == "acme/widget" + + def test_https_url_no_dot_git(self): + with _mock_git_remote("https://github.com/Acme/Widget\n"): + assert _detect_repo_slug_from_git_remote() == "acme/widget" + + def test_non_github_url(self): + with _mock_git_remote("git@gitlab.com:org/repo.git\n"): + assert _detect_repo_slug_from_git_remote() is None + + def test_git_failure(self): + result = MagicMock(spec=subprocess.CompletedProcess) + result.returncode = 1 + result.stdout = "" + with patch("ghsudo.__main__.subprocess.run", return_value=result): + assert _detect_repo_slug_from_git_remote() is None + + def test_git_not_found(self): + with patch( + "ghsudo.__main__.subprocess.run", + side_effect=FileNotFoundError, + ): + assert _detect_repo_slug_from_git_remote() is None + + +# --------------------------------------------------------------------------- +# _detect_repo_slug (combined) +# --------------------------------------------------------------------------- + + +class TestDetectRepoSlug: + """Args take priority over git remote.""" + + def test_args_preferred_over_remote(self): + with _mock_git_remote("git@github.com:remote/repo.git"): + slug = _detect_repo_slug(["gh", "-R", "arg/repo", "pr", "list"]) + assert slug == "arg/repo" + + def test_falls_back_to_remote(self): + with _mock_git_remote("git@github.com:remote/repo.git"): + slug = _detect_repo_slug(["gh", "pr", "list"]) + assert slug == "remote/repo" + + def test_none_when_nothing(self): + result = MagicMock(spec=subprocess.CompletedProcess) + result.returncode = 1 + result.stdout = "" + with patch("ghsudo.__main__.subprocess.run", return_value=result): + assert _detect_repo_slug(["gh", "pr", "list"]) is None + + +# --------------------------------------------------------------------------- +# _format_approval_msg +# --------------------------------------------------------------------------- + + +class TestFormatApprovalMsg: + """Approval message should show repo when available, org otherwise.""" + + def test_with_repo(self): + msg = _format_approval_msg("gh pr merge 1", "acme", repo="acme/widget") + assert "Repository: acme/widget" in msg + assert "Organization:" not in msg + + def test_without_repo(self): + msg = _format_approval_msg("gh pr merge 1", "acme") + assert "Organization: acme" in msg + assert "Repository:" not in msg + + def test_command_present(self): + msg = _format_approval_msg("gh pr merge 42 --merge", "x", repo="x/y") + assert "gh pr merge 42 --merge" in msg