Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions src/uvault/github.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import os
import time
from typing import TYPE_CHECKING
from uvault.forge import Forge
Expand All @@ -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 = {}

Expand All @@ -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

Expand All @@ -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()
Expand Down
54 changes: 54 additions & 0 deletions tests/test_github.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Loading