|
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,19 @@ 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`` via the truthy spread ``...(this.apiUrl ? { baseUrl } |
| 127 | + # : {})`` (index.ts:201/217/...), so an empty string falls back to the |
| 128 | + # default. We have no Octokit -- our REST calls go through |
| 129 | + # ``_github_api_request`` and the installation-token exchange -- so we |
| 130 | + # normalize the override (strip a trailing slash so ``f"{base}{path}"`` |
| 131 | + # joins cleanly) and substitute it for the hardcoded |
| 132 | + # ``https://api.github.com`` at both sites. The truthy fallback means an |
| 133 | + # empty ``apiUrl`` (or env) uses the default endpoint. |
| 134 | + api_url_raw = config.get("api_url") or os.environ.get("GITHUB_API_URL") |
| 135 | + self._api_url = api_url_raw.rstrip("/") if api_url_raw else GITHUB_API_BASE_URL |
| 136 | + |
117 | 137 | # Auth configuration |
118 | 138 | self._auth_token: str | None = None |
119 | 139 | self._app_credentials: dict[str, str] | None = None |
@@ -1098,7 +1118,7 @@ async def _get_installation_token(self, installation_id: int) -> str: |
1098 | 1118 | return token |
1099 | 1119 |
|
1100 | 1120 | app_jwt = self._generate_app_jwt() |
1101 | | - url = f"https://api.github.com/app/installations/{installation_id}/access_tokens" |
| 1121 | + url = f"{self._api_url}/app/installations/{installation_id}/access_tokens" |
1102 | 1122 | headers = { |
1103 | 1123 | "Accept": "application/vnd.github+json", |
1104 | 1124 | "Authorization": f"Bearer {app_jwt}", |
@@ -1169,7 +1189,7 @@ async def _github_api_request( |
1169 | 1189 | if auth_token: |
1170 | 1190 | headers["Authorization"] = f"Bearer {auth_token}" |
1171 | 1191 |
|
1172 | | - url = f"https://api.github.com{path}" if path.startswith("/") else path |
| 1192 | + url = f"{self._api_url}{path}" if path.startswith("/") else path |
1173 | 1193 |
|
1174 | 1194 | session = await self._get_http_session() |
1175 | 1195 | kwargs: dict[str, Any] = {"headers": headers} |
@@ -1208,6 +1228,40 @@ async def _get_installation_id(self, owner: str, repo: str) -> int | None: |
1208 | 1228 | key = f"github:install:{owner}/{repo}" |
1209 | 1229 | return await self._chat.get_state().get(key) |
1210 | 1230 |
|
| 1231 | + async def get_installation_id(self, thread: Thread | str) -> int | None: |
| 1232 | + """Get the GitHub App installation ID associated with a thread. |
| 1233 | +
|
| 1234 | + Returns the fixed installation ID in single-tenant app mode, the cached |
| 1235 | + repository installation in multi-tenant mode, or ``None`` in PAT mode. |
| 1236 | +
|
| 1237 | + Faithful port of upstream ``getInstallationId`` (index.ts:458-480). |
| 1238 | + ``thread`` may be a :class:`Thread` or a raw thread-ID string. The |
| 1239 | + private :meth:`_get_installation_id` (owner/repo) is retained as the |
| 1240 | + storage-keyed helper this method delegates to in multi-tenant mode. |
| 1241 | +
|
| 1242 | + Raises: |
| 1243 | + ValidationError: In multi-tenant mode when the adapter has not been |
| 1244 | + initialized via ``chat.initialize()`` (no state store to read). |
| 1245 | + """ |
| 1246 | + # Single-tenant GitHub App mode: a fixed installation was configured. |
| 1247 | + if self._installation_id is not None: |
| 1248 | + return self._installation_id |
| 1249 | + |
| 1250 | + # PAT mode (or any non-multi-tenant config): no installation concept. |
| 1251 | + if not self.is_multi_tenant: |
| 1252 | + return None |
| 1253 | + |
| 1254 | + thread_id = thread if isinstance(thread, str) else thread.id |
| 1255 | + decoded = self.decode_thread_id(thread_id) |
| 1256 | + |
| 1257 | + if not self._chat: |
| 1258 | + raise ValidationError( |
| 1259 | + "github", |
| 1260 | + "Adapter not initialized. Ensure chat.initialize() has been called first.", |
| 1261 | + ) |
| 1262 | + |
| 1263 | + return await self._get_installation_id(decoded.owner, decoded.repo) |
| 1264 | + |
1211 | 1265 | @staticmethod |
1212 | 1266 | async def _get_request_body(request: Any) -> str: |
1213 | 1267 | """Extract body text from a request object.""" |
|
0 commit comments