diff --git a/backend/druks/core/apis/github.py b/backend/druks/core/apis/github.py index 7903b63a..22672565 100644 --- a/backend/druks/core/apis/github.py +++ b/backend/druks/core/apis/github.py @@ -220,12 +220,20 @@ async def get_mention_handle(self) -> str: async def get_bot_git_author(self) -> tuple[str, str]: """Name and email for commits made as the App's bot user — the ``+[bot]@users.noreply.github.com`` convention, the same - identity a squash-merge advertises. The id comes from the public users - endpoint, no auth needed.""" + identity a squash-merge advertises. The id comes from the users endpoint + with an installation token: GitHub limits anonymous requests to 60 an hour per IP.""" bot_name = f"{await self.get_mention_handle()}[bot]" user_id = _BOT_USER_ID_CACHE.get(self._app_id) if not user_id: - async with GitHub(base_url=GITHUB_API_URL) as github: + response = await self._app.rest.apps.async_list_installations(per_page=100) + installations = [] + for installation in response.parsed_data: + if not installation.suspended_at: + installations.append(installation) + if not installations: + raise GitHubAppNotInstalledError(bot_name) + github = await self._get_for_installation(installations[0].id) + async with github: response = await github.rest.users.async_get_by_username(bot_name) user_id = response.parsed_data.id _BOT_USER_ID_CACHE[self._app_id] = user_id diff --git a/backend/tests/test_github.py b/backend/tests/test_github.py index 8d17fc55..9b6e29e4 100644 --- a/backend/tests/test_github.py +++ b/backend/tests/test_github.py @@ -564,37 +564,48 @@ async def _for_repo(self, repo: str) -> Any: assert await client.get_file_content("clawhaven/.druks", "prompts/x.md") is None -async def test_get_bot_git_author_composes_the_public_bot_identity( +async def test_get_bot_git_author_looks_up_the_bot_with_a_live_installation( monkeypatch: pytest.MonkeyPatch, ) -> None: # The author is the App's bot user with GitHub's + noreply - # address; the id comes from one unauthenticated lookup, cached for the - # life of the process. - requested: list[str] = [] + # address; the id comes from one lookup with a live installation's token, + # cached for the life of the process. + requested: list[tuple[str, int]] = [] class _Users: + def __init__(self, installation_id: int) -> None: + self.installation_id = installation_id + async def async_get_by_username(self, username: str) -> SimpleNamespace: - requested.append(username) + requested.append((username, self.installation_id)) return SimpleNamespace(parsed_data=SimpleNamespace(id=123456789)) - class _PublicGitHub: - def __init__(self, *, base_url: str) -> None: - assert base_url == github_api.GITHUB_API_URL - self.rest = SimpleNamespace(users=_Users()) + class _InstallationGitHub: + def __init__(self, installation_id: int) -> None: + self.rest = SimpleNamespace(users=_Users(installation_id)) - async def __aenter__(self) -> "_PublicGitHub": + async def __aenter__(self) -> "_InstallationGitHub": return self async def __aexit__(self, *_args: Any) -> None: pass - monkeypatch.setattr(github_api, "GitHub", _PublicGitHub) + class _Apps: + async def async_list_installations(self, per_page: int) -> SimpleNamespace: + suspended = SimpleNamespace(id=11, suspended_at=datetime(2026, 10, 1, tzinfo=UTC)) + live = SimpleNamespace(id=22, suspended_at=None) + return SimpleNamespace(parsed_data=[suspended, live]) + monkeypatch.setattr(github_api, "_BOT_USER_ID_CACHE", {}) class _SluggedClient(GitHubClient): def __init__(self) -> None: # skip real auth self._app_id = "12345" self._slug = "example-app" + self._app = SimpleNamespace(rest=SimpleNamespace(apps=_Apps())) + + async def _get_for_installation(self, installation_id: int) -> _InstallationGitHub: + return _InstallationGitHub(installation_id) client = _SluggedClient() author = await client.get_bot_git_author() @@ -604,7 +615,7 @@ def __init__(self) -> None: # skip real auth "123456789+example-app[bot]@users.noreply.github.com", ) assert await client.get_bot_git_author() == author - assert requested == ["example-app[bot]"] + assert requested == [("example-app[bot]", 22)] def _template_generating_client(