Skip to content

Commit 208ba8c

Browse files
add model method to Installation for retrieving accessible repos (#10)
* add model method to `Installation` for retrieving accessible repos * add missing enum member
1 parent c726dce commit 208ba8c

File tree

6 files changed

+144
-45
lines changed

6 files changed

+144
-45
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,11 @@ and this project attempts to adhere to [Semantic Versioning](https://semver.org/
2121
### Added
2222

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

2530
## [0.1.0]
2631

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,9 @@ async with AsyncGitHubAPI(installation_id=installation.installation_id) as gh:
254254
255255
##### Model methods
256256
257+
- `get_gh_client`: Get configured API client for this installation
257258
- `aget_access_token`/`get_access_token`: Generate GitHub access token for API calls
259+
- `aget_repos`/`get_repos`: Fetch installation's accessible repositories
258260
259261
#### `Repository`
260262

src/django_github_app/github.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ def __init__(self, *args: Any, **kwargs: Any) -> None:
8484

8585

8686
class GitHubAPIEndpoint(Enum):
87+
INSTALLATION_REPOS = "/installation/repositories"
8788
REPO_ISSUES = "/repos/{owner}/{repo}/issues"
8889

8990

src/django_github_app/models.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,12 @@ class Installation(models.Model):
125125
def __str__(self) -> str:
126126
return str(self.installation_id)
127127

128+
def get_gh_client(self, requester: str | None = None):
129+
return AsyncGitHubAPI( # pragma: no cover
130+
requester or self.app_slug,
131+
installation_id=self.installation_id,
132+
)
133+
128134
async def aget_access_token(self, gh: abc.GitHubAPI): # pragma: no cover
129135
data = await get_installation_access_token(
130136
gh,
@@ -137,6 +143,25 @@ async def aget_access_token(self, gh: abc.GitHubAPI): # pragma: no cover
137143
def get_access_token(self, gh: abc.GitHubAPI): # pragma: no cover
138144
return async_to_sync(self.aget_access_token)(gh)
139145

146+
async def aget_repos(self, params: dict[str, Any] | None = None):
147+
url = GitHubAPIUrl(
148+
GitHubAPIEndpoint.INSTALLATION_REPOS,
149+
params=params,
150+
)
151+
async with self.get_gh_client() as gh:
152+
repos = [
153+
repo
154+
async for repo in gh.getiter(url.full_url, iterable_key="repositories")
155+
]
156+
return repos
157+
158+
def get_repos(self, params: dict[str, Any] | None = None):
159+
return async_to_sync(self.aget_repos)(params)
160+
161+
@property
162+
def app_slug(self):
163+
return self.data.get("app_slug", app_settings.SLUG)
164+
140165

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

200225
def get_gh_client(self):
201-
return AsyncGitHubAPI( # pragma: no cover
202-
self.full_name, installation_id=self.installation.installation_id
203-
)
226+
return self.installation.get_gh_client(self.full_name) # pragma: no cover
204227

205228
async def aget_issues(self, params: dict[str, Any] | None = None):
206229
url = GitHubAPIUrl(

tests/conftest.py

Lines changed: 71 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -93,23 +93,69 @@ def repository_id():
9393

9494

9595
@pytest.fixture
96-
def installation():
97-
return baker.make("django_github_app.Installation", installation_id=seq.next())
96+
def get_mock_github_api():
97+
def _get_mock_github_api(return_data):
98+
mock_api = AsyncMock(spec=AsyncGitHubAPI)
99+
100+
async def mock_getitem(*args, **kwargs):
101+
return return_data
102+
103+
async def mock_getiter(*args, **kwargs):
104+
for data in return_data:
105+
yield data
106+
107+
mock_api.getitem = mock_getitem
108+
mock_api.getiter = mock_getiter
109+
mock_api.__aenter__.return_value = mock_api
110+
mock_api.__aexit__.return_value = None
111+
112+
return mock_api
113+
114+
return _get_mock_github_api
98115

99116

100117
@pytest.fixture
101-
async def ainstallation():
102-
return await sync_to_async(baker.make)(
118+
def installation(get_mock_github_api):
119+
installation = baker.make(
103120
"django_github_app.Installation", installation_id=seq.next()
104121
)
122+
mock_github_api = get_mock_github_api(
123+
[
124+
{"id": seq.next(), "node_id": "node1", "full_name": "owner/repo1"},
125+
{"id": seq.next(), "node_id": "node2", "full_name": "owner/repo2"},
126+
]
127+
)
128+
mock_github_api.installation_id = installation.installation_id
129+
installation.get_gh_client = MagicMock(return_value=mock_github_api)
130+
return installation
105131

106132

107133
@pytest.fixture
108-
def mock_github_api():
109-
mock_api = AsyncMock(spec=AsyncGitHubAPI)
134+
async def ainstallation(get_mock_github_api):
135+
installation = await sync_to_async(baker.make)(
136+
"django_github_app.Installation", installation_id=seq.next()
137+
)
138+
mock_github_api = get_mock_github_api(
139+
[
140+
{"id": seq.next(), "node_id": "node1", "full_name": "owner/repo1"},
141+
{"id": seq.next(), "node_id": "node2", "full_name": "owner/repo2"},
142+
]
143+
)
144+
mock_github_api.installation_id = installation.installation_id
145+
installation.get_gh_client = MagicMock(return_value=mock_github_api)
146+
return installation
147+
110148

111-
async def mock_getiter(*args, **kwargs):
112-
test_issues = [
149+
@pytest.fixture
150+
def repository(installation, get_mock_github_api):
151+
repository = baker.make(
152+
"django_github_app.Repository",
153+
repository_id=seq.next(),
154+
full_name="owner/repo",
155+
installation=installation,
156+
)
157+
mock_github_api = get_mock_github_api(
158+
[
113159
{
114160
"number": 1,
115161
"title": "Test Issue 1",
@@ -121,52 +167,35 @@ async def mock_getiter(*args, **kwargs):
121167
"state": "closed",
122168
},
123169
]
124-
for issue in test_issues:
125-
yield issue
126-
127-
mock_api.getiter = mock_getiter
128-
mock_api.__aenter__.return_value = mock_api
129-
mock_api.__aexit__.return_value = None
130-
131-
return mock_api
132-
133-
134-
@pytest.fixture
135-
def repository(installation, mock_github_api):
136-
repository = baker.make(
137-
"django_github_app.Repository",
138-
repository_id=seq.next(),
139-
full_name="owner/repo",
140-
installation=installation,
141170
)
142-
143171
mock_github_api.installation_id = repository.installation.installation_id
144-
145-
if isinstance(repository, list):
146-
for repo in repository:
147-
repo.get_gh_client = MagicMock(mock_github_api)
148-
else:
149-
repository.get_gh_client = MagicMock(return_value=mock_github_api)
150-
172+
repository.get_gh_client = MagicMock(return_value=mock_github_api)
151173
return repository
152174

153175

154176
@pytest.fixture
155-
async def arepository(ainstallation, mock_github_api):
177+
async def arepository(ainstallation, get_mock_github_api):
156178
installation = await ainstallation
157179
repository = await sync_to_async(baker.make)(
158180
"django_github_app.Repository",
159181
repository_id=seq.next(),
160182
full_name="owner/repo",
161183
installation=installation,
162184
)
163-
185+
mock_github_api = get_mock_github_api(
186+
[
187+
{
188+
"number": 1,
189+
"title": "Test Issue 1",
190+
"state": "open",
191+
},
192+
{
193+
"number": 2,
194+
"title": "Test Issue 2",
195+
"state": "closed",
196+
},
197+
]
198+
)
164199
mock_github_api.installation_id = repository.installation.installation_id
165-
166-
if isinstance(repository, list):
167-
for repo in repository:
168-
repo.get_gh_client = MagicMock(mock_github_api)
169-
else:
170-
repository.get_gh_client = MagicMock(return_value=mock_github_api)
171-
200+
repository.get_gh_client = MagicMock(return_value=mock_github_api)
172201
return repository

tests/test_models.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,45 @@ def test_from_event_invalid_action(self, create_event):
237237
InstallationStatus.from_event(event)
238238

239239

240+
class TestInstallation:
241+
def test_get_gh_client(self, installation):
242+
client = installation.get_gh_client()
243+
244+
assert isinstance(client, AsyncGitHubAPI)
245+
assert client.installation_id == installation.installation_id
246+
247+
@pytest.mark.asyncio
248+
async def test_aget_repos(self, ainstallation):
249+
installation = await ainstallation
250+
251+
repos = await installation.aget_repos()
252+
253+
assert len(repos) == 2
254+
assert repos[0]["node_id"] == "node1"
255+
assert repos[0]["full_name"] == "owner/repo1"
256+
assert repos[1]["node_id"] == "node2"
257+
assert repos[1]["full_name"] == "owner/repo2"
258+
259+
def test_get_repos(self, installation):
260+
repos = installation.get_repos()
261+
262+
assert len(repos) == 2
263+
assert repos[0]["node_id"] == "node1"
264+
assert repos[0]["full_name"] == "owner/repo1"
265+
assert repos[1]["node_id"] == "node2"
266+
assert repos[1]["full_name"] == "owner/repo2"
267+
268+
def test_app_slug(self):
269+
app_slug = "foo"
270+
installation = baker.make(
271+
"django_github_app.Installation",
272+
installation_id=seq.next(),
273+
data={"app_slug": app_slug},
274+
)
275+
276+
assert installation.app_slug == app_slug
277+
278+
240279
class TestRepositoryManager:
241280
@pytest.mark.asyncio
242281
async def test_acreate_from_gh_data_list(self, ainstallation):

0 commit comments

Comments
 (0)