|
57 | 57 | RawMessage, |
58 | 58 | StreamChunk, |
59 | 59 | StreamOptions, |
| 60 | + Thread, |
60 | 61 | ThreadInfo, |
61 | 62 | ThreadSummary, |
62 | 63 | UserInfo, |
63 | 64 | WebhookOptions, |
64 | 65 | _parse_iso, |
65 | 66 | ) |
66 | 67 |
|
| 68 | +# Default GitHub REST API base URL. Overridable per-adapter via |
| 69 | +# ``config["api_url"]`` / ``GITHUB_API_URL`` for GitHub Enterprise Server |
| 70 | +# (mirrors upstream Octokit ``baseUrl``). Stored without a trailing slash so |
| 71 | +# ``f"{base}{path}"`` joins cleanly with leading-slash paths. |
| 72 | +GITHUB_API_BASE_URL = "https://api.github.com" |
| 73 | + |
67 | 74 | REVIEW_COMMENT_THREAD_PATTERN = re.compile(r"^([^/]+)/([^:]+):(\d+):rc:(\d+)$") |
68 | 75 | ISSUE_THREAD_PATTERN = re.compile(r"^([^/]+)/([^:]+):issue:(\d+)$") |
69 | 76 | PR_THREAD_PATTERN = re.compile(r"^([^/]+)/([^:]+):(\d+)$") |
@@ -114,6 +121,18 @@ def __init__(self, config: GitHubAdapterConfig | None = None) -> None: |
114 | 121 | self._chat: ChatInstance | None = None |
115 | 122 | self._format_converter = GitHubFormatConverter() |
116 | 123 |
|
| 124 | + # Custom GitHub API base URL (e.g. GitHub Enterprise Server). Upstream |
| 125 | + # threads ``config.apiUrl ?? process.env.GITHUB_API_URL`` into every |
| 126 | + # Octokit ``baseUrl`` (index.ts:201). We have no Octokit -- our REST |
| 127 | + # calls go through ``_github_api_request`` and the installation-token |
| 128 | + # exchange -- so we normalize the override (strip a trailing slash so |
| 129 | + # ``f"{base}{path}"`` joins cleanly) and substitute it for the hardcoded |
| 130 | + # ``https://api.github.com`` at both sites. ``is not None`` honors an |
| 131 | + # explicit empty string (CLAUDE.md truthiness-trap hazard). |
| 132 | + config_api_url = config.get("api_url") |
| 133 | + api_url_raw = config_api_url if config_api_url is not None else os.environ.get("GITHUB_API_URL") |
| 134 | + self._api_url = api_url_raw.rstrip("/") if api_url_raw else GITHUB_API_BASE_URL |
| 135 | + |
117 | 136 | # Auth configuration |
118 | 137 | self._auth_token: str | None = None |
119 | 138 | self._app_credentials: dict[str, str] | None = None |
@@ -1098,7 +1117,7 @@ async def _get_installation_token(self, installation_id: int) -> str: |
1098 | 1117 | return token |
1099 | 1118 |
|
1100 | 1119 | app_jwt = self._generate_app_jwt() |
1101 | | - url = f"https://api.github.com/app/installations/{installation_id}/access_tokens" |
| 1120 | + url = f"{self._api_url}/app/installations/{installation_id}/access_tokens" |
1102 | 1121 | headers = { |
1103 | 1122 | "Accept": "application/vnd.github+json", |
1104 | 1123 | "Authorization": f"Bearer {app_jwt}", |
@@ -1169,7 +1188,7 @@ async def _github_api_request( |
1169 | 1188 | if auth_token: |
1170 | 1189 | headers["Authorization"] = f"Bearer {auth_token}" |
1171 | 1190 |
|
1172 | | - url = f"https://api.github.com{path}" if path.startswith("/") else path |
| 1191 | + url = f"{self._api_url}{path}" if path.startswith("/") else path |
1173 | 1192 |
|
1174 | 1193 | session = await self._get_http_session() |
1175 | 1194 | kwargs: dict[str, Any] = {"headers": headers} |
@@ -1208,6 +1227,40 @@ async def _get_installation_id(self, owner: str, repo: str) -> int | None: |
1208 | 1227 | key = f"github:install:{owner}/{repo}" |
1209 | 1228 | return await self._chat.get_state().get(key) |
1210 | 1229 |
|
| 1230 | + async def get_installation_id(self, thread: Thread | str) -> int | None: |
| 1231 | + """Get the GitHub App installation ID associated with a thread. |
| 1232 | +
|
| 1233 | + Returns the fixed installation ID in single-tenant app mode, the cached |
| 1234 | + repository installation in multi-tenant mode, or ``None`` in PAT mode. |
| 1235 | +
|
| 1236 | + Faithful port of upstream ``getInstallationId`` (index.ts:458-480). |
| 1237 | + ``thread`` may be a :class:`Thread` or a raw thread-ID string. The |
| 1238 | + private :meth:`_get_installation_id` (owner/repo) is retained as the |
| 1239 | + storage-keyed helper this method delegates to in multi-tenant mode. |
| 1240 | +
|
| 1241 | + Raises: |
| 1242 | + ValidationError: In multi-tenant mode when the adapter has not been |
| 1243 | + initialized via ``chat.initialize()`` (no state store to read). |
| 1244 | + """ |
| 1245 | + # Single-tenant GitHub App mode: a fixed installation was configured. |
| 1246 | + if self._installation_id is not None: |
| 1247 | + return self._installation_id |
| 1248 | + |
| 1249 | + # PAT mode (or any non-multi-tenant config): no installation concept. |
| 1250 | + if not self.is_multi_tenant: |
| 1251 | + return None |
| 1252 | + |
| 1253 | + thread_id = thread if isinstance(thread, str) else thread.id |
| 1254 | + decoded = self.decode_thread_id(thread_id) |
| 1255 | + |
| 1256 | + if not self._chat: |
| 1257 | + raise ValidationError( |
| 1258 | + "github", |
| 1259 | + "Adapter not initialized. Ensure chat.initialize() has been called first.", |
| 1260 | + ) |
| 1261 | + |
| 1262 | + return await self._get_installation_id(decoded.owner, decoded.repo) |
| 1263 | + |
1211 | 1264 | @staticmethod |
1212 | 1265 | async def _get_request_body(request: Any) -> str: |
1213 | 1266 | """Extract body text from a request object.""" |
|
0 commit comments