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
2 changes: 2 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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*
Expand Down
9 changes: 9 additions & 0 deletions github_backup/github_backup.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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)


Expand Down
71 changes: 71 additions & 0 deletions tests/test_app_repositories.py
Original file line number Diff line number Diff line change
@@ -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"}]