Skip to content
Draft
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
50 commits
Select commit Hold shift + click to select a range
c6b5a1c
Skip project if query can't be validated
TrishGillett Sep 29, 2025
6df9a73
chore(deps): Bump hynek/build-and-inspect-python-package from 2.13.0 …
dependabot[bot] Oct 13, 2025
5a94b0d
chore(deps): Bump singer-sdk from 0.50.1 to 0.50.2 in the runtime-dep…
dependabot[bot] Oct 13, 2025
ae8823b
chore(deps-dev): Bump types-python-dateutil from 2.9.0.20250822 to 2.…
dependabot[bot] Oct 13, 2025
89476e6
fix: String to int conversion in scraping (#474)
rluvaton Oct 14, 2025
60964cd
chore(deps): Bump singer-sdk from 0.50.2 to 0.51.0 (#476)
dependabot[bot] Oct 20, 2025
1c2c39b
chore(deps): Bump cryptography from 46.0.2 to 46.0.3 in the runtime-d…
dependabot[bot] Oct 20, 2025
67ac7f2
fix: Bump Singer SDK to 0.52 (#477)
edgarrmondragon Oct 22, 2025
4a83750
chore(deps): Bump actions/download-artifact from 5.0.0 to 6.0.0 in th…
dependabot[bot] Oct 27, 2025
791270d
chore(deps): Bump singer-sdk from 0.52.1 to 0.52.2 in the runtime-dep…
dependabot[bot] Oct 27, 2025
d7b9f56
feat: Add ReviewCommentReactions stream (#480)
linbug Oct 31, 2025
0e39765
chore(deps): Bump singer-sdk from 0.52.2 to 0.52.5 in the runtime-dep…
dependabot[bot] Nov 3, 2025
7c797f2
chore: pre-commit autoupdate (#482)
pre-commit-ci[bot] Nov 3, 2025
58255df
chore(deps-dev): Bump the development-dependencies group with 2 updat…
dependabot[bot] Nov 10, 2025
50afa2a
packaging: Use PEP 621 and PEP 735 (#484)
edgarrmondragon Nov 13, 2025
9801107
prototyping changes to support org-specific auth
TrishGillett Nov 13, 2025
6a32dcd
chore: Add `lockfile-only` Dependabot strategy
edgarrmondragon Nov 13, 2025
f490f22
Revert "chore: Add `lockfile-only` Dependabot strategy"
edgarrmondragon Nov 13, 2025
14897ae
Switch authentication when switching orgs [working]
linbug Nov 17, 2025
e313568
Backwards compatibility
linbug Nov 17, 2025
0240023
Fix get_next_auth_token rotation
linbug Nov 17, 2025
2bfa07a
Update tests
linbug Nov 17, 2025
52522d9
Clean up authenticator
linbug Nov 18, 2025
150c400
Clean up logging
linbug Nov 18, 2025
4a2828e
fix: Bump Singer SDK to 0.53 (#485)
edgarrmondragon Nov 19, 2025
241e02f
Add fallback to using tokens from any org
linbug Nov 20, 2025
8d14937
Update tests
linbug Nov 20, 2025
b2d56ec
Override parent method to set organization-specific authentication
linbug Nov 24, 2025
baa75ea
chore(deps): Bump the actions group with 2 updates (#486)
dependabot[bot] Nov 24, 2025
37f14a6
chore(deps): Bump singer-sdk from 0.53.2 to 0.53.3 in the runtime-dep…
dependabot[bot] Nov 27, 2025
f225770
Merge branch 'main' into multiorg-auth
linbug Nov 28, 2025
e4486a4
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Nov 28, 2025
1212498
Reduce complexity of prepare_tokens()
linbug Nov 28, 2025
31c61a0
Fix linter errors
linbug Nov 28, 2025
ee32595
Docs update
linbug Nov 28, 2025
6dfdd48
Update README
linbug Dec 1, 2025
23e1d88
chore(deps): Bump actions/setup-python from 6.0.0 to 6.1.0 in the act…
dependabot[bot] Dec 1, 2025
8c48d2e
Merge branch 'main' into multiorg-auth
linbug Dec 1, 2025
6cf53f8
chore: pre-commit autoupdate (#492)
pre-commit-ci[bot] Dec 1, 2025
ddae164
Merge branch 'main' into multiorg-auth
linbug Dec 1, 2025
2f09f79
chore(deps-dev): Bump mypy from 1.18.2 to 1.19.0 in the development-d…
dependabot[bot] Dec 1, 2025
074e87d
chore(deps): Bump the runtime-dependencies group with 2 updates (#491)
dependabot[bot] Dec 1, 2025
5adf3cc
Merge branch 'main' into multiorg-auth
edgarrmondragon Dec 1, 2025
b6df8fc
chore: Lock file maintenance (#493)
edgarrmondragon Dec 1, 2025
41ac79a
Merge branch 'main' into multiorg-auth
linbug Dec 2, 2025
0a4dfb9
Simplify else statement
linbug Dec 3, 2025
301abf8
Add org_auth_app_keys instead of combining in auth_app_keys
linbug Dec 3, 2025
9b6d3c6
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Dec 3, 2025
d98e464
nit: Update types
edgarrmondragon Dec 3, 2025
549a0d1
Remove unused organization and github_organization params
linbug Dec 4, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion meltano.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ plugins:
- name: additional_auth_tokens
kind: array
- name: auth_app_keys
kind: array
kind: object
- name: rate_limit_buffer
kind: integer
- name: expiry_time_buffer
Expand Down
162 changes: 131 additions & 31 deletions tap_github/authenticator.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import logging
import time
from collections import defaultdict
from copy import deepcopy
from datetime import datetime, timedelta, timezone
from os import environ
Expand Down Expand Up @@ -171,6 +172,7 @@ class AppTokenManager(TokenManager):
def __init__(
self,
env_key: str,
organization: str | None = None,
rate_limit_buffer: int | None = None,
expiry_time_buffer: int | None = None,
**kwargs, # noqa: ANN003
Expand All @@ -182,6 +184,7 @@ def __init__(
self.github_app_id = parts[0]
self.github_private_key = (parts[1:2] or [""])[0].replace("\\n", "\n")
self.github_installation_id: str | None = parts[2] if len(parts) >= 3 else None
self.github_organization: str | None = organization

if expiry_time_buffer is None:
expiry_time_buffer = self.DEFAULT_EXPIRY_BUFFER_MINS
Expand Down Expand Up @@ -246,7 +249,7 @@ class GitHubTokenAuthenticator(APIAuthenticatorBase):
def get_env(): # noqa: ANN205
return dict(environ)

def prepare_tokens(self) -> list[TokenManager]:
def prepare_tokens(self) -> dict[str | None, list[TokenManager]]:
"""Prep GitHub tokens"""

env_dict = self.get_env()
Expand Down Expand Up @@ -282,39 +285,66 @@ def prepare_tokens(self) -> list[TokenManager]:
logger.warning("A token was dismissed.")

# Parse App level private keys and generate tokens
# To simplify settings, we use a single env-key formatted as follows:
# "{app_id};;{-----BEGIN RSA PRIVATE KEY-----\n_YOUR_PRIVATE_KEY_\n-----END RSA PRIVATE KEY-----}" # noqa: E501
# App keys can be provided via:
# 1. Config as array (org-agnostic): ["app_id;;private_key", ...]
# 2. Config as dict (org-specific): {"org": ["app_id;;private_key", ...], ...}
# 3. Environment variable GITHUB_APP_PRIVATE_KEY: "app_id;;private_key"
# Format: "{app_id};;{-----BEGIN RSA PRIVATE KEY-----\n_YOUR_PRIVATE_KEY_\n-----END RSA PRIVATE KEY-----}" # noqa: E501

app_keys: set[str] = set()
app_keys: dict[str, list[str]] = defaultdict(list)
if self.auth_app_keys:
app_keys = app_keys.union(self.auth_app_keys)
logger.info(
"Provided %d app keys via config for authentication.",
len(app_keys),
)
# Handle backwards compatibility: detect if it's a list (old format) or dict (new format)
if isinstance(self.auth_app_keys, list):
# Old format: treat all keys as org-agnostic
for app_key in self.auth_app_keys:
app_keys[None].append(app_key)
logger.info(
"Provided %d app keys via config for authentication.",
len(self.auth_app_keys),
)
else:
# New format: org-specific keys
for org in self.auth_app_keys:
for app_key in self.auth_app_keys[org]:
app_keys[org].append(app_key)
logger.info(
"Provided %d app keys via config for authentication for organization: %s",
len(self.auth_app_keys[org]),
org,
)
elif "GITHUB_APP_PRIVATE_KEY" in env_dict:
app_keys.add(env_dict["GITHUB_APP_PRIVATE_KEY"])
app_keys[None].append(env_dict["GITHUB_APP_PRIVATE_KEY"])
logger.info("Found 1 app key via environment variable for authentication.")

app_token_managers: list[TokenManager] = []
for app_key in app_keys:
try:
app_token_manager = AppTokenManager(
app_key,
rate_limit_buffer=self.rate_limit_buffer,
expiry_time_buffer=self.expiry_time_buffer,
)
if app_token_manager.is_valid_token():
app_token_managers.append(app_token_manager)
except ValueError as e: # noqa: PERF203
logger.warning(f"An error was thrown while preparing an app token: {e}")
token_managers: dict[str | None, list[TokenManager]] = defaultdict(list)
for org in app_keys:
for app_key in app_keys[org]:
try:
app_token_manager = AppTokenManager(
app_key,
organization=org,
rate_limit_buffer=self.rate_limit_buffer,
expiry_time_buffer=self.expiry_time_buffer,
)
if app_token_manager.is_valid_token():
token_managers[org].append(app_token_manager)
except ValueError as e: # noqa: PERF203
logger.warning(
f"An error was thrown while preparing an app token: {e}"
)

logger.info(
"Tap will run with %d personal auth tokens and %d app keys.",
len(personal_token_managers),
len(app_token_managers),
sum([len(token_managers[org]) for org in token_managers]),
)
return personal_token_managers + app_token_managers

# Merge personal tokens and app tokens.
# Personal tokens are stored under None key as they are org-agnostic.
if personal_token_managers:
token_managers[None].extend(personal_token_managers)

return token_managers

def __init__(
self,
Expand Down Expand Up @@ -344,9 +374,19 @@ def __init__(
self.auth_app_keys = auth_app_keys

self.token_managers = self.prepare_tokens()
self.active_token: TokenManager | None = (
choice(self.token_managers) if self.token_managers else None
)
self.current_organization: str | None = None
if self.token_managers:
# Prefer org-specific tokens over org-agnostic (None key)
org_keys = [k for k in self.token_managers.keys() if k is not None]
if org_keys:
initial_org = min(org_keys)
else:
initial_org = None
self.logger.info(f"Setting initial organization for authenticator: {initial_org}")
self.active_token: TokenManager | None = choice(self.token_managers[initial_org])
else:
self.logger.info("Setting initial organization for authenticator: None")
self.active_token: TokenManager | None = None

@classmethod
def from_stream(cls, stream: RESTStream) -> GitHubTokenAuthenticator:
Expand All @@ -358,11 +398,69 @@ def from_stream(cls, stream: RESTStream) -> GitHubTokenAuthenticator:
auth_app_keys=stream.config.get("auth_app_keys"),
)

def set_organization(self, org: str) -> None:
"""Set the current organization and switch to an appropriate token.

Args:
org: The organization name to switch to.
"""
# If we're already using this org, no need to switch
if self.current_organization == org:
return

logger.info(f"Switching authentication context to organization: {org}")
self.current_organization = org

# Get tokens for this org (check both org-specific and None keys)
available_tokens = self.token_managers.get(org, [])
if not available_tokens and None in self.token_managers:
# Fall back to org-agnostic tokens (personal tokens or env var app keys)
available_tokens = self.token_managers[None]
logger.info(
f"No org-specific tokens found for '{org}', using org-agnostic tokens"
)

if not available_tokens:
logger.warning(
f"No authentication tokens available for organization: {org}"
)
self.active_token = None
return

# Select a token with remaining calls
for token_manager in available_tokens:
if token_manager.has_calls_remaining():
self.active_token = token_manager
logger.info(f"Selected token for organization: {org}")
return

# If no tokens have calls remaining, just pick the first one
# (it might refresh or we'll rotate later)
self.active_token = available_tokens[0]
logger.info(
f"Selected token for organization: {org} (may need rate limit refresh)"
)

def get_next_auth_token(self) -> None:
current_token = self.active_token.token if self.active_token else ""
token_managers = deepcopy(self.token_managers)
shuffle(token_managers)
for token_manager in token_managers:

# Build a list of candidate tokens for the current organization
candidates = []

# Priority 1: Other tokens for the current organization
if self.current_organization and self.current_organization in self.token_managers:
org_tokens = list(self.token_managers[self.current_organization])
shuffle(org_tokens)
candidates.extend(org_tokens)

# Priority 2: Org-agnostic tokens (stored under None key)
if None in self.token_managers:
agnostic_tokens = list(self.token_managers[None])
shuffle(agnostic_tokens)
candidates.extend(agnostic_tokens)

# Try to find a token with remaining capacity
for token_manager in candidates:
if (
token_manager.has_calls_remaining()
and current_token != token_manager.token
Expand All @@ -380,7 +478,9 @@ def update_rate_limit(
response_headers: requests.models.CaseInsensitiveDict,
) -> None:
# If no token or only one token is available, return early.
if len(self.token_managers) <= 1 or self.active_token is None:
# Count total tokens across all organizations
total_tokens = sum(len(tokens) for tokens in self.token_managers.values())
if total_tokens <= 1 or self.active_token is None:
return

self.active_token.update_rate_limit(response_headers)
Expand Down
1 change: 1 addition & 0 deletions tap_github/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,7 @@ def validate_response(self, response: requests.Response) -> None:
and "rate limit exceeded" in str(response.content).lower()
):
# Update token
self.logger.info("CONTEXT: " + repr(self.context))
self.authenticator.get_next_auth_token()
# Raise an error to force a retry with the new token.
raise RetriableAPIError(msg, response)
Expand Down
10 changes: 10 additions & 0 deletions tap_github/organization_streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,11 @@ def get_records(self, context: Context | None) -> Iterable[dict[str, Any]]:
quota when only syncing a child stream. Without this,
the API call is sent but data is discarded.
"""
# Set organization-specific authentication before fetching records
if context is not None and "org" in context:
self.logger.info(f"Setting organization within organization stream: {context['org']}")
self.authenticator.set_organization(context["org"])

if (
not self.selected
and "skip_parent_streams" in self.config
Expand Down Expand Up @@ -653,6 +658,11 @@ def request_records(self, context: Context | None) -> Iterable[dict]:
f"Context: {context}. Error: {e}"
)
return
elif "Timeout on validation of query" in error_message:

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seemed to have been a piece of logic that was in Trish's branch but was not on main

self.logger.warning(
f"Skipping project due to query validation timeout error. "
f"Context: {context}. Error: {e}"
)

raise

Expand Down
56 changes: 48 additions & 8 deletions tap_github/repository_streams.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,16 +89,21 @@ class TempStream(GitHubGraphqlStream):
th.Property("databaseId", th.IntegerType),
).to_dict()

def __init__(self, tap, repo_list) -> None: # noqa: ANN001
def __init__(self, tap, repo_list, parent_authenticator=None) -> None: # noqa: ANN001
super().__init__(tap)
self.repo_list = repo_list
# Use parent's authenticator to maintain consistent auth state and rate limits
if parent_authenticator is not None:
self._authenticator = parent_authenticator

@property
def query(self) -> str:
chunks = []
for i, repo in enumerate(self.repo_list):
org, repo_name = repo
self.logger.info(f"[TempStream.query] Building query for {org}/{repo_name} with authenticator org: {self.authenticator.current_organization}")
chunks.append(
f'repo{i}: repository(name: "{repo[1]}", owner: "{repo[0]}") '
f'repo{i}: repository(name: "{repo_name}", owner: "{org}") '
"{ nameWithOwner databaseId }"
)
return "query {" + " ".join(chunks) + " rateLimit { cost } }"
Expand All @@ -118,9 +123,20 @@ def validate_response(self, response: requests.Response) -> None:

if len(repo_list) < 1:
return []
self.logger.info(f"Getting repo ids for {len(repo_list)} repositories")

# Log parent stream authenticator info
self.logger.info(f"[get_repo_ids] Parent stream authenticator ID: {id(self.authenticator)}")
self.logger.info(f"[get_repo_ids] Parent stream current_org: {self.authenticator.current_organization}")
self.logger.info(f"[get_repo_ids] Parent stream active_token org: {getattr(self.authenticator.active_token, 'github_organization', 'N/A')}")

repos_with_ids: list = []
temp_stream = TempStream(self._tap, list(repo_list))
temp_stream = TempStream(self._tap, list(repo_list), self.authenticator)

# Log TempStream authenticator info
self.logger.info(f"[get_repo_ids] TempStream authenticator ID: {id(temp_stream.authenticator)}")
self.logger.info(f"[get_repo_ids] TempStream current_org: {temp_stream.authenticator.current_organization}")
self.logger.info(f"[get_repo_ids] TempStream active_token org: {getattr(temp_stream.authenticator.active_token, 'github_organization', 'N/A')}")
# replace manually provided org/repo values by the ones obtained
# from github api. This guarantees that case is correct in the output data.
# See https://github.com/MeltanoLabs/tap-github/issues/110
Expand All @@ -131,6 +147,7 @@ def validate_response(self, response: requests.Response) -> None:
if item == "rateLimit":
continue
try:
self.logger.info(f"Processing record within TempStream: {record[item]}")
repo_full_name = "/".join(repo_list[int(item[4:])])
name_with_owner = record[item]["nameWithOwner"]
org, repo = name_with_owner.split("/")
Expand Down Expand Up @@ -172,17 +189,35 @@ def partitions(self) -> list[dict[str, str]] | None:

if "repositories" in self.config:
split_repo_names = [s.split("/") for s in self.config["repositories"]]

# Group repositories by organization for org-specific authentication
from collections import defaultdict
repos_by_org = defaultdict(list)
self.logger.info(f"Split repository names as part of repository stream: {split_repo_names}")
for org, repo in split_repo_names:
repos_by_org[org].append((org, repo))

augmented_repo_list = []
# chunk requests to the graphql endpoint to avoid timeouts and other
# obscure errors that the api doesn't say much about. The actual limit
# seems closer to 1000, use half that to stay safe.
chunk_size = 500
list_length = len(split_repo_names)
self.logger.info(f"Filtering repository list of {list_length} repositories")
for ndx in range(0, list_length, chunk_size):
augmented_repo_list += self.get_repo_ids(
split_repo_names[ndx : ndx + chunk_size]
)
self.logger.info(f"Filtering repository list of {list_length} repositories across {len(repos_by_org)} organizations")

# Process each organization's repos separately with org-specific auth
for org, org_repos in repos_by_org.items():
self.logger.info(f"Validating {len(org_repos)} repositories for organization: {org}")
# Set organization-specific authentication before validating repos
self.logger.info(f"Setting organization within repository stream: {org}")
self.authenticator.set_organization(org)

# Process in chunks
for ndx in range(0, len(org_repos), chunk_size):
augmented_repo_list += self.get_repo_ids(
org_repos[ndx : ndx + chunk_size]
)

self.logger.info(
f"Running the tap on {len(augmented_repo_list)} repositories"
)
Expand Down Expand Up @@ -216,6 +251,11 @@ def get_records(self, context: Context | None) -> Iterable[dict[str, Any]]:
quota when only syncing a child stream. Without this,
the API call is sent but data is discarded.
"""
# Set organization-specific authentication before fetching records
if context is not None and "org" in context:
self.logger.info(f"Setting organization within repository stream: {context['org']}")
self.authenticator.set_organization(context["org"])

if (
not self.selected
and "skip_parent_streams" in self.config
Expand Down
Loading