diff --git a/README.rst b/README.rst index 3d7d1a2..ccc8cfb 100644 --- a/README.rst +++ b/README.rst @@ -199,6 +199,8 @@ GitHub Apps GitHub Apps are ideal for organization backups in CI/CD. Tokens are scoped to specific repositories and expire after 1 hour. +For a user account (no ``--organization``), ``--as-app`` lists the repositories the installation can access, so private repositories are included. + **One-time setup:** 1. Create a GitHub App at *Settings -> Developer Settings -> GitHub Apps -> New GitHub App* diff --git a/github_backup/github_backup.py b/github_backup/github_backup.py index 9a2b7d4..a8297c9 100644 --- a/github_backup/github_backup.py +++ b/github_backup/github_backup.py @@ -1838,6 +1838,11 @@ def retrieve_repositories(args, authenticated_user): if args.user == authenticated_user["login"]: # we must use the /user/repos API to be able to access private repos template = "https://{0}/user/repos".format(get_github_api_host(args)) + elif args.as_app and not args.organization: + # /users/{user}/repos is public-only; the installation sees private repos too + template = "https://{0}/installation/repositories".format( + get_github_api_host(args) + ) else: if args.private and not args.organization: logger.warning( @@ -1868,6 +1873,10 @@ def retrieve_repositories(args, authenticated_user): logger.warning(f"Legal notice: {e.legal_url}") return [] + if template.endswith("/installation/repositories"): + # Each page is {"total_count": ..., "repositories": [...]} + repos = [repo for page in repos for repo in page["repositories"]] + return retrieve_additional_repositories(args, authenticated_user, repos) diff --git a/tests/test_app_repositories.py b/tests/test_app_repositories.py new file mode 100644 index 0000000..24d3411 --- /dev/null +++ b/tests/test_app_repositories.py @@ -0,0 +1,71 @@ +"""Tests for listing a user's repositories with a GitHub App installation token.""" + +from unittest.mock import patch + +import pytest + +from github_backup import github_backup + +NO_USER = {"login": None} + + +def page(*names): + return { + "total_count": 3, + "repositories": [{"name": name, "private": True} for name in names], + } + + +@pytest.fixture +def mock_retrieve_data(): + with patch("github_backup.github_backup.retrieve_data") as mock: + yield mock + + +def test_app_lists_user_repositories_from_installation(create_args, mock_retrieve_data): + args = create_args(as_app=True, include_repository=True) + mock_retrieve_data.return_value = [page("a", "b"), page("c")] + + repos = github_backup.retrieve_repositories(args, NO_USER) + + assert mock_retrieve_data.call_args.args[1] == ( + "https://api.github.com/installation/repositories" + ) + assert [r["name"] for r in repos] == ["a", "b", "c"] + + +def test_app_installation_listing_uses_ghes_host(create_args, mock_retrieve_data): + args = create_args( + as_app=True, include_repository=True, github_host="ghe.example.com" + ) + mock_retrieve_data.return_value = [page("a")] + + github_backup.retrieve_repositories(args, NO_USER) + + assert mock_retrieve_data.call_args.args[1] == ( + "https://ghe.example.com/api/v3/installation/repositories" + ) + + +def test_app_organization_still_lists_org_repositories(create_args, mock_retrieve_data): + args = create_args(as_app=True, include_repository=True, organization=True) + mock_retrieve_data.return_value = [{"name": "a"}] + + repos = github_backup.retrieve_repositories(args, NO_USER) + + assert mock_retrieve_data.call_args.args[1] == ( + "https://api.github.com/orgs/testuser/repos" + ) + assert repos == [{"name": "a"}] + + +def test_app_single_repository_is_fetched_directly(create_args, mock_retrieve_data): + args = create_args(as_app=True, repository="dotfiles") + mock_retrieve_data.return_value = [{"name": "dotfiles"}] + + repos = github_backup.retrieve_repositories(args, NO_USER) + + assert mock_retrieve_data.call_args.args[1] == ( + "https://api.github.com/repos/testuser/dotfiles" + ) + assert repos == [{"name": "dotfiles"}]