diff --git a/products/tasks/backend/exceptions.py b/products/tasks/backend/exceptions.py index edd9940b8c17..b60c9740ef0c 100644 --- a/products/tasks/backend/exceptions.py +++ b/products/tasks/backend/exceptions.py @@ -266,6 +266,18 @@ class RepositoryCloneError(ProcessTaskTransientError): pass +class RepositoryAccessError(ProcessTaskFatalError, NonReportableError): + """GitHub refused to serve the repository with the credential the sandbox has. + + The run has no token, or its token does not cover the repository. A retry reuses the same + credential, so it cannot succeed. This is an expected customer setup state, so it is not + captured to error tracking. + """ + + def __init__(self, message: str, context: dict[str, Any]): + ProcessTaskError.__init__(self, message, context, None, capture=False, non_retryable=True) + + class RetryableRepositorySetupError(ProcessTaskTransientError): """Failed to setup repository (install dependencies, etc).""" diff --git a/products/tasks/backend/temporal/process_task/activities/provision_sandbox.py b/products/tasks/backend/temporal/process_task/activities/provision_sandbox.py index 560d096baa23..e38a1cee89ca 100644 --- a/products/tasks/backend/temporal/process_task/activities/provision_sandbox.py +++ b/products/tasks/backend/temporal/process_task/activities/provision_sandbox.py @@ -37,6 +37,7 @@ OAuthTokenError, ProcessTaskError, ProcessTaskTransientError, + RepositoryAccessError, RepositoryCloneError, SandboxNetworkPolicyError, TaskInvalidStateError, @@ -1229,6 +1230,18 @@ def clone_repository_in_sandbox(input: CloneRepositoryInSandboxInput) -> CloneRe blobless=blobless_clone, ) + if _is_repository_access_denied_clone_error(clone_result): + raise RepositoryAccessError( + f"Could not clone {input.repository}: GitHub denied access. Check that the PostHog " + "GitHub app is installed on this repository's account and can read this repository.", + { + "repository": input.repository, + "sandbox_id": input.sandbox_id, + "exit_code": clone_result.exit_code, + "has_github_token": bool(input.github_token), + }, + ) + if clone_result.exit_code != 0: error_output = clone_result.stderr or clone_result.stdout or clone_result.error or "No output captured" raise RepositoryCloneError( @@ -1254,6 +1267,20 @@ def clone_repository_in_sandbox(input: CloneRepositoryInSandboxInput) -> CloneRe return CloneRepositoryInSandboxOutput(clone_ms=clone_timer.elapsed_ms) +def _is_repository_access_denied_clone_error(result: ExecutionResult) -> bool: + if result.exit_code == 0: + return False + + # Git asks for a username when GitHub answers 401, which is how GitHub hides a private + # repository from a request without a usable token. + output = f"{result.stdout}\n{result.stderr}".casefold() + return ( + "could not read username" in output + or "authentication failed for" in output + or "remote: repository not found" in output + ) + + def _is_missing_remote_branch_clone_error(result: ExecutionResult) -> bool: if result.exit_code == 0: return False diff --git a/products/tasks/backend/temporal/process_task/activities/tests/test_provision_sandbox.py b/products/tasks/backend/temporal/process_task/activities/tests/test_provision_sandbox.py index ccf6d04e303c..4d6a64e263de 100644 --- a/products/tasks/backend/temporal/process_task/activities/tests/test_provision_sandbox.py +++ b/products/tasks/backend/temporal/process_task/activities/tests/test_provision_sandbox.py @@ -13,10 +13,13 @@ from asgiref.sync import async_to_sync +from posthog.temporal.common.posthog_client import is_expected_activity_failure + from products.tasks.backend.constants import SNAPSHOT_KIND_DIRECTORY, SNAPSHOT_KIND_FILESYSTEM from products.tasks.backend.exceptions import ( ComputeBillingLimitError, OrganizationExecutionError, + RepositoryAccessError, RepositoryCloneError, SandboxCleanupError, SandboxRateLimitedError, @@ -610,6 +613,63 @@ def test_clone_failure_records_failed_latency_and_captures_command_result(mocker assert str(capture_exception.call_args.args[0]) == "clone output" +@pytest.mark.parametrize( + "stderr, expected_error", + [ + ( + "Cloning into 'repo'...\nfatal: could not read Username for 'https://github.com': No such device or address\n", + RepositoryAccessError, + ), + ( + "Cloning into 'repo'...\nremote: Repository not found.\nfatal: repository 'https://github.com/acme/repo.git/' not found\n", + RepositoryAccessError, + ), + ( + "Cloning into 'repo'...\nfatal: unable to access 'https://github.com/acme/repo.git/': Could not resolve host: github.com\n", + RepositoryCloneError, + ), + ], +) +def test_clone_failure_stops_retries_only_when_github_denies_access( + mocker, activity_environment, stderr, expected_error +): + context = TaskProcessingContext( + task_id="task-id", + run_id="run-id", + team_id=1, + team_uuid="team-uuid", + organization_id="organization-id", + github_integration_id=123, + repository="acme/repo", + distinct_id="distinct-id", + state={}, + ) + sandbox = mocker.Mock() + sandbox.clone_repository.return_value = ExecutionResult(stdout="", stderr=stderr, exit_code=128, error=None) + mocker.patch( + "products.tasks.backend.temporal.process_task.activities.provision_sandbox.get_sandbox_class_for_sandbox_id", + return_value=mocker.Mock(get_by_id=mocker.Mock(return_value=sandbox)), + ) + capture_exception = mocker.patch("products.tasks.backend.exceptions.capture_exception") + + with pytest.raises(expected_error) as error: + async_to_sync(activity_environment.run)( + clone_repository_in_sandbox, + CloneRepositoryInSandboxInput( + context=context, + sandbox_id="sandbox-id", + repository="acme/repo", + github_token="", + shallow_clone=True, + ), + ) + + access_denied = expected_error is RepositoryAccessError + assert error.value.non_retryable is access_denied + assert is_expected_activity_failure(error.value) is access_denied + assert capture_exception.called is not access_denied + + def _prepared_for_create() -> PrepareSandboxForRepositoryOutput: return PrepareSandboxForRepositoryOutput( sandbox_name="sandbox-name",