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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,11 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
### Added

- Added `acreate_from_gh_data`/`create_from_gh_data` manager methods to `Installation` and `Repository` models.
- Added new methods to `Installation` model:
- `get_gh_client` for retrieving a `GitHubAPI` client preconfigured for an `Installation` instance.
- `aget_repos`/`get_repos` for retrieving all repositories accessible to an app installation.
- Added `get_gh_client` model method to `Installation` model.
- Added `aget_repos`/`get_repos` model method to `installation`

## [0.1.0]

Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -254,7 +254,9 @@ async with AsyncGitHubAPI(installation_id=installation.installation_id) as gh:

##### Model methods

- `get_gh_client`: Get configured API client for this installation
- `aget_access_token`/`get_access_token`: Generate GitHub access token for API calls
- `aget_repos`/`get_repos`: Fetch installation's accessible repositories

#### `Repository`

Expand Down
1 change: 1 addition & 0 deletions src/django_github_app/github.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:


class GitHubAPIEndpoint(Enum):
INSTALLATION_REPOS = "/installation/repositories"
REPO_ISSUES = "/repos/{owner}/{repo}/issues"


Expand Down
29 changes: 26 additions & 3 deletions src/django_github_app/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@ class Installation(models.Model):
def __str__(self) -> str:
return str(self.installation_id)

def get_gh_client(self, requester: str | None = None):
return AsyncGitHubAPI( # pragma: no cover
requester or self.app_slug,
installation_id=self.installation_id,
)

async def aget_access_token(self, gh: abc.GitHubAPI): # pragma: no cover
data = await get_installation_access_token(
gh,
Expand All @@ -137,6 +143,25 @@ async def aget_access_token(self, gh: abc.GitHubAPI): # pragma: no cover
def get_access_token(self, gh: abc.GitHubAPI): # pragma: no cover
return async_to_sync(self.aget_access_token)(gh)

async def aget_repos(self, params: dict[str, Any] | None = None):
url = GitHubAPIUrl(
GitHubAPIEndpoint.INSTALLATION_REPOS,
params=params,
)
async with self.get_gh_client() as gh:
repos = [
repo
async for repo in gh.getiter(url.full_url, iterable_key="repositories")
]
return repos

def get_repos(self, params: dict[str, Any] | None = None):
return async_to_sync(self.aget_repos)(params)

@property
def app_slug(self):
return self.data.get("app_slug", app_settings.SLUG)


class RepositoryManager(models.Manager["Repository"]):
async def acreate_from_gh_data(
Expand Down Expand Up @@ -198,9 +223,7 @@ def __str__(self) -> str:
return self.full_name

def get_gh_client(self):
return AsyncGitHubAPI( # pragma: no cover
self.full_name, installation_id=self.installation.installation_id
)
return self.installation.get_gh_client(self.full_name) # pragma: no cover

async def aget_issues(self, params: dict[str, Any] | None = None):
url = GitHubAPIUrl(
Expand Down
113 changes: 71 additions & 42 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,23 +93,69 @@ def repository_id():


@pytest.fixture
def installation():
return baker.make("django_github_app.Installation", installation_id=seq.next())
def get_mock_github_api():
def _get_mock_github_api(return_data):
mock_api = AsyncMock(spec=AsyncGitHubAPI)

async def mock_getitem(*args, **kwargs):
return return_data

async def mock_getiter(*args, **kwargs):
for data in return_data:
yield data

mock_api.getitem = mock_getitem
mock_api.getiter = mock_getiter
mock_api.__aenter__.return_value = mock_api
mock_api.__aexit__.return_value = None

return mock_api

return _get_mock_github_api


@pytest.fixture
async def ainstallation():
return await sync_to_async(baker.make)(
def installation(get_mock_github_api):
installation = baker.make(
"django_github_app.Installation", installation_id=seq.next()
)
mock_github_api = get_mock_github_api(
[
{"id": seq.next(), "node_id": "node1", "full_name": "owner/repo1"},
{"id": seq.next(), "node_id": "node2", "full_name": "owner/repo2"},
]
)
mock_github_api.installation_id = installation.installation_id
installation.get_gh_client = MagicMock(return_value=mock_github_api)
return installation


@pytest.fixture
def mock_github_api():
mock_api = AsyncMock(spec=AsyncGitHubAPI)
async def ainstallation(get_mock_github_api):
installation = await sync_to_async(baker.make)(
"django_github_app.Installation", installation_id=seq.next()
)
mock_github_api = get_mock_github_api(
[
{"id": seq.next(), "node_id": "node1", "full_name": "owner/repo1"},
{"id": seq.next(), "node_id": "node2", "full_name": "owner/repo2"},
]
)
mock_github_api.installation_id = installation.installation_id
installation.get_gh_client = MagicMock(return_value=mock_github_api)
return installation


async def mock_getiter(*args, **kwargs):
test_issues = [
@pytest.fixture
def repository(installation, get_mock_github_api):
repository = baker.make(
"django_github_app.Repository",
repository_id=seq.next(),
full_name="owner/repo",
installation=installation,
)
mock_github_api = get_mock_github_api(
[
{
"number": 1,
"title": "Test Issue 1",
Expand All @@ -121,52 +167,35 @@ async def mock_getiter(*args, **kwargs):
"state": "closed",
},
]
for issue in test_issues:
yield issue

mock_api.getiter = mock_getiter
mock_api.__aenter__.return_value = mock_api
mock_api.__aexit__.return_value = None

return mock_api


@pytest.fixture
def repository(installation, mock_github_api):
repository = baker.make(
"django_github_app.Repository",
repository_id=seq.next(),
full_name="owner/repo",
installation=installation,
)

mock_github_api.installation_id = repository.installation.installation_id

if isinstance(repository, list):
for repo in repository:
repo.get_gh_client = MagicMock(mock_github_api)
else:
repository.get_gh_client = MagicMock(return_value=mock_github_api)

repository.get_gh_client = MagicMock(return_value=mock_github_api)
return repository


@pytest.fixture
async def arepository(ainstallation, mock_github_api):
async def arepository(ainstallation, get_mock_github_api):
installation = await ainstallation
repository = await sync_to_async(baker.make)(
"django_github_app.Repository",
repository_id=seq.next(),
full_name="owner/repo",
installation=installation,
)

mock_github_api = get_mock_github_api(
[
{
"number": 1,
"title": "Test Issue 1",
"state": "open",
},
{
"number": 2,
"title": "Test Issue 2",
"state": "closed",
},
]
)
mock_github_api.installation_id = repository.installation.installation_id

if isinstance(repository, list):
for repo in repository:
repo.get_gh_client = MagicMock(mock_github_api)
else:
repository.get_gh_client = MagicMock(return_value=mock_github_api)

repository.get_gh_client = MagicMock(return_value=mock_github_api)
return repository
39 changes: 39 additions & 0 deletions tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,45 @@ def test_from_event_invalid_action(self, create_event):
InstallationStatus.from_event(event)


class TestInstallation:
def test_get_gh_client(self, installation):
client = installation.get_gh_client()

assert isinstance(client, AsyncGitHubAPI)
assert client.installation_id == installation.installation_id

@pytest.mark.asyncio
async def test_aget_repos(self, ainstallation):
installation = await ainstallation

repos = await installation.aget_repos()

assert len(repos) == 2
assert repos[0]["node_id"] == "node1"
assert repos[0]["full_name"] == "owner/repo1"
assert repos[1]["node_id"] == "node2"
assert repos[1]["full_name"] == "owner/repo2"

def test_get_repos(self, installation):
repos = installation.get_repos()

assert len(repos) == 2
assert repos[0]["node_id"] == "node1"
assert repos[0]["full_name"] == "owner/repo1"
assert repos[1]["node_id"] == "node2"
assert repos[1]["full_name"] == "owner/repo2"

def test_app_slug(self):
app_slug = "foo"
installation = baker.make(
"django_github_app.Installation",
installation_id=seq.next(),
data={"app_slug": app_slug},
)

assert installation.app_slug == app_slug


class TestRepositoryManager:
@pytest.mark.asyncio
async def test_acreate_from_gh_data_list(self, ainstallation):
Expand Down