Skip to content

Commit af86ecd

Browse files
authored
feat: Enable running with multiple app keys provided via config (#302)
Adds a way to input the keys for multiple github apps, which people might want to do in order to increase capacity and protect against rate limits. As a bonus, this also gives users the option to provide their app key via config rather than having it inferred, and the option to use environment variable names of their choice.
1 parent 53057a2 commit af86ecd

File tree

5 files changed

+91
-15
lines changed

5 files changed

+91
-15
lines changed

README.md

+7-4
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,13 @@ This tap accepts the following configuration options:
3333
4. `user_usernames`: A list of github usernames
3434
5. `user_ids`: A list of github user ids [int]
3535
- Highly recommended:
36-
- `auth_token` - GitHub token to authenticate with.
37-
- `additional_auth_tokens` - List of GitHub tokens to authenticate with. Streams will loop through them when hitting rate limits..
38-
- alternatively, you can input authentication tokens with any environment variables starting with GITHUB_TOKEN.
39-
- or authenticate as a GitHub app setting a private key in GITHUB_APP_PRIVATE_KEY. Formatted as follows: `:app_id:;;-----BEGIN RSA PRIVATE KEY-----\n_YOUR_P_KEY_\n-----END RSA PRIVATE KEY-----`. You can generate it from the `Private keys` section on https://github.com/organizations/:organization_name/settings/apps/:app_name. Read more about GitHub App quotas [here](https://docs.github.com/en/[email protected]/developers/apps/building-github-apps/rate-limits-for-github-apps#server-to-server-requests).
36+
- Personal access tokens (PATs) for authentication can be provided in 3 ways:
37+
- `auth_token` - Takes a single token.
38+
- `additional_auth_tokens` - Takes a list of tokens. Can be used together with `auth_token` or as the sole source of PATs.
39+
- Any environment variables beginning with `GITHUB_TOKEN` will be assumed to be PATs. These tokens will be used in addition to `auth_token` (if provided), but will not be used if `additional_auth_tokens` is provided.
40+
- GitHub App keys are another option for authentication, and can be used in combination with PATs if desired. App IDs and keys should be assembled into the format `:app_id:;;-----BEGIN RSA PRIVATE KEY-----\n_YOUR_P_KEY_\n-----END RSA PRIVATE KEY-----` where the key can be generated from the `Private keys` section on https://github.com/organizations/:organization_name/settings/apps/:app_name. Read more about GitHub App quotas [here](https://docs.github.com/en/[email protected]/developers/apps/building-github-apps/rate-limits-for-github-apps#server-to-server-requests). Formatted app keys can be provided in 2 ways:
41+
- `auth_app_keys` - List of GitHub App keys in the prescribed format.
42+
- If `auth_app_keys` is not provided but there is an environment variable with the name `GITHUB_APP_PRIVATE_KEY`, it will be assumed to be an App key in the prescribed format.
4043
- Optional:
4144
- `user_agent`
4245
- `start_date`

meltano.yml

+2
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ plugins:
1919
kind: password
2020
- name: additional_auth_tokens
2121
kind: array
22+
- name: auth_app_keys
23+
kind: array
2224
- name: rate_limit_buffer
2325
kind: integer
2426
- name: expiry_time_buffer

tap_github/authenticator.py

+27-11
Original file line numberDiff line numberDiff line change
@@ -270,37 +270,53 @@ def prepare_tokens(self) -> list[TokenManager]:
270270
)
271271
personal_tokens = personal_tokens.union(env_tokens)
272272

273-
token_managers: list[TokenManager] = []
273+
personal_token_managers: list[TokenManager] = []
274274
for token in personal_tokens:
275275
token_manager = PersonalTokenManager(
276276
token, rate_limit_buffer=rate_limit_buffer, logger=self.logger
277277
)
278278
if token_manager.is_valid_token():
279-
token_managers.append(token_manager)
279+
personal_token_managers.append(token_manager)
280280
else:
281281
logging.warn("A token was dismissed.")
282282

283-
# Parse App level private key and generate a token
284-
if "GITHUB_APP_PRIVATE_KEY" in env_dict:
285-
# To simplify settings, we use a single env-key formatted as follows:
286-
# "{app_id};;{-----BEGIN RSA PRIVATE KEY-----\n_YOUR_PRIVATE_KEY_\n-----END RSA PRIVATE KEY-----}" # noqa: E501
287-
env_key = env_dict["GITHUB_APP_PRIVATE_KEY"]
283+
# Parse App level private keys and generate tokens
284+
# To simplify settings, we use a single env-key formatted as follows:
285+
# "{app_id};;{-----BEGIN RSA PRIVATE KEY-----\n_YOUR_PRIVATE_KEY_\n-----END RSA PRIVATE KEY-----}" # noqa: E501
286+
287+
app_keys: set[str] = set()
288+
if "auth_app_keys" in self._config:
289+
app_keys = app_keys.union(self._config["auth_app_keys"])
290+
self.logger.info(
291+
f"Provided {len(app_keys)} app keys via config for authentication."
292+
)
293+
elif "GITHUB_APP_PRIVATE_KEY" in env_dict:
294+
app_keys.add(env_dict["GITHUB_APP_PRIVATE_KEY"])
295+
self.logger.info(
296+
"Found 1 app key via environment variable for authentication."
297+
)
298+
299+
app_token_managers: list[TokenManager] = []
300+
for app_key in app_keys:
288301
try:
289302
app_token_manager = AppTokenManager(
290-
env_key,
303+
app_key,
291304
rate_limit_buffer=rate_limit_buffer,
292305
expiry_time_buffer=expiry_time_buffer,
293306
logger=self.logger,
294307
)
295308
if app_token_manager.is_valid_token():
296-
token_managers.append(app_token_manager)
309+
app_token_managers.append(app_token_manager)
297310
except ValueError as e:
298311
self.logger.warn(
299312
f"An error was thrown while preparing an app token: {e}"
300313
)
301314

302-
self.logger.info(f"Tap will run with {len(token_managers)} auth tokens")
303-
return token_managers
315+
self.logger.info(
316+
f"Tap will run with {len(personal_token_managers)} personal auth tokens "
317+
f"and {len(app_token_managers)} app keys."
318+
)
319+
return personal_token_managers + app_token_managers
304320

305321
def __init__(self, stream: RESTStream) -> None:
306322
"""Init authenticator.

tap_github/tap.py

+18
Original file line numberDiff line numberDiff line change
@@ -47,11 +47,29 @@ def logger(cls) -> logging.Logger:
4747
th.ArrayType(th.StringType),
4848
description="List of GitHub tokens to authenticate with. Streams will loop through them when hitting rate limits.", # noqa: E501
4949
),
50+
th.Property(
51+
"auth_app_keys",
52+
th.ArrayType(th.StringType),
53+
description=(
54+
"List of GitHub App credentials to authenticate with. Each credential "
55+
"can be constructed by combining an App ID and App private key into "
56+
"the format `:app_id:;;-----BEGIN RSA PRIVATE KEY-----\n_YOUR_P_KEY_\n-----END RSA PRIVATE KEY-----`." # noqa: E501
57+
),
58+
),
5059
th.Property(
5160
"rate_limit_buffer",
5261
th.IntegerType,
5362
description="Add a buffer to avoid consuming all query points for the token at hand. Defaults to 1000.", # noqa: E501
5463
),
64+
th.Property(
65+
"expiry_time_buffer",
66+
th.IntegerType,
67+
description=(
68+
"When authenticating as a GitHub App, this buffer controls how many "
69+
"minutes before expiry the GitHub app tokens will be refreshed. "
70+
"Defaults to 10 minutes.",
71+
),
72+
),
5573
th.Property(
5674
"searches",
5775
th.ArrayType(

tap_github/tests/test_authenticator.py

+37
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,43 @@ def test_env_personal_tokens_only(self, mock_stream):
356356
assert len(token_managers) == 2
357357
assert sorted({tm.token for tm in token_managers}) == ["gt1", "gt2"]
358358

359+
def test_config_app_keys(self, mock_stream):
360+
def generate_token_mock(app_id, private_key, installation_id):
361+
return (f"installationtokenfor{app_id}", MagicMock())
362+
363+
with patch.object(TokenManager, "is_valid_token", return_value=True), patch(
364+
"tap_github.authenticator.generate_app_access_token",
365+
side_effect=generate_token_mock,
366+
):
367+
stream = mock_stream
368+
stream.config.update(
369+
{
370+
"auth_token": "gt5",
371+
"additional_auth_tokens": ["gt7", "gt8", "gt9"],
372+
"auth_app_keys": [
373+
"123;;gak1;;13",
374+
"456;;gak1;;46",
375+
"789;;gak1;;79",
376+
],
377+
}
378+
)
379+
auth = GitHubTokenAuthenticator(stream=stream)
380+
token_managers = auth.prepare_tokens()
381+
382+
assert len(token_managers) == 7
383+
384+
app_token_managers = {
385+
tm for tm in token_managers if isinstance(tm, AppTokenManager)
386+
}
387+
assert len(app_token_managers) == 3
388+
389+
app_tokens = {tm.token for tm in app_token_managers}
390+
assert app_tokens == {
391+
"installationtokenfor123",
392+
"installationtokenfor456",
393+
"installationtokenfor789",
394+
}
395+
359396
def test_env_app_key_only(self, mock_stream):
360397
with patch.object(
361398
GitHubTokenAuthenticator,

0 commit comments

Comments
 (0)