diff --git a/docs/reference.md b/docs/reference.md index 67beb72..e43b379 100644 --- a/docs/reference.md +++ b/docs/reference.md @@ -107,6 +107,12 @@ token = "ghp_YOUR_GITHUB_TOKEN" ``` * Remotes will be automatically added to the repository when running `uvault develop`. +If you prefer not to write a token to disk, `uvault` also reads it from the +`GH_TOKEN` or `GITHUB_TOKEN` environment variables (in that order). These are +the names the GitHub CLI and GitHub Actions already set, so a `gh`-authenticated +shell and CI both work with no configuration file. A token in +`~/.config/uvault/config.toml` takes precedence over the environment. + * The github token is used for automatically forking repositories when running `uvault sync`. For this to work, you need to have the `pygithub` optional dependency installed (e.g., `uv pip install uvault[github]`). In GitHub the token needs `fork` permissions. ## CLI Commands diff --git a/src/uvault/github.py b/src/uvault/github.py index 3bac0ea..955806a 100644 --- a/src/uvault/github.py +++ b/src/uvault/github.py @@ -1,3 +1,4 @@ +import os import time from typing import TYPE_CHECKING from uvault.forge import Forge @@ -9,6 +10,21 @@ from uvault.status import PackageStatus +#: Environment variables consulted for a token, in order, when the user +#: config does not define one. These are the names the GitHub CLI and +#: GitHub Actions already set, so CI and a shell with `gh` logged in work +#: without writing a token to disk. +_TOKEN_ENV_VARS = ("GH_TOKEN", "GITHUB_TOKEN") + + +def _token_from_env() -> str | None: + for name in _TOKEN_ENV_VARS: + value = os.environ.get(name) + if value: + return value + return None + + class GitHubForge(Forge): _clients = {} @@ -18,7 +34,7 @@ def _get_client(cls, allow_anonymous: bool = True): return cls._clients[allow_anonymous] user_config = read_user_config() - token = user_config.get("github", {}).get("token") + token = user_config.get("github", {}).get("token") or _token_from_env() try: from github import Github, Auth # type: ignore @@ -29,7 +45,9 @@ def _get_client(cls, allow_anonymous: bool = True): return client elif allow_anonymous: print( - "WARNING: No GitHub token configured in ~/.config/uvault/config.toml.\n" + "WARNING: No GitHub token found in " + "~/.config/uvault/config.toml or " + f"{' / '.join(_TOKEN_ENV_VARS)}.\n" "Using unauthenticated access. You may hit rate limits." ) client = Github() diff --git a/tests/test_github.py b/tests/test_github.py index 4ff302a..419babe 100644 --- a/tests/test_github.py +++ b/tests/test_github.py @@ -170,3 +170,57 @@ def test_get_github_client_cached( assert client1 is not None assert client1 is client2 mock_github_class.assert_called_once() + + +@requires_github +@patch("uvault.github.read_user_config", return_value={}) +@patch("github.Github") +def test_token_read_from_gh_token_env( + mock_github_class, mock_read_user_config, monkeypatch +): + """CI and a `gh`-logged-in shell already export this; no file needed.""" + monkeypatch.setenv("GH_TOKEN", "env-token") + monkeypatch.delenv("GITHUB_TOKEN", raising=False) + + GitHubForge._get_client() + + from github import Auth # type: ignore + + assert mock_github_class.call_args is not None + auth = mock_github_class.call_args.kwargs.get("auth") + assert isinstance(auth, Auth.Token) + assert auth.token == "env-token" + + +@requires_github +@patch("uvault.github.read_user_config", return_value={}) +@patch("github.Github") +def test_github_token_env_is_a_fallback( + mock_github_class, mock_read_user_config, monkeypatch +): + """GITHUB_TOKEN is what Actions injects by default.""" + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.setenv("GITHUB_TOKEN", "actions-token") + + GitHubForge._get_client() + + auth = mock_github_class.call_args.kwargs.get("auth") + assert auth.token == "actions-token" + + +@requires_github +@patch( + "uvault.github.read_user_config", + return_value={"github": {"token": "file-token"}}, +) +@patch("github.Github") +def test_user_config_token_wins_over_env( + mock_github_class, mock_read_user_config, monkeypatch +): + """An explicit config entry must not be silently overridden by the shell.""" + monkeypatch.setenv("GH_TOKEN", "env-token") + + GitHubForge._get_client() + + auth = mock_github_class.call_args.kwargs.get("auth") + assert auth.token == "file-token"