Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
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
99 changes: 93 additions & 6 deletions src/sentry/preprod/vcs/status_checks/size/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
from sentry.preprod.models import PreprodArtifact, PreprodArtifactSizeMetrics
from sentry.preprod.url_utils import get_preprod_artifact_url
from sentry.preprod.vcs.status_checks.size.templates import format_status_check_messages
from sentry.shared_integrations.exceptions import ApiError, IntegrationConfigurationError
from sentry.silo.base import SiloMode
from sentry.tasks.base import instrumented_task
from sentry.taskworker.namespaces import integrations_tasks
Expand All @@ -36,7 +37,7 @@
name="sentry.preprod.tasks.create_preprod_status_check",
namespace=integrations_tasks,
processing_deadline_duration=30,
retry=Retry(times=3),
retry=Retry(times=3, ignore=(IntegrationConfigurationError,)),
silo_mode=SiloMode.REGION,
)
def create_preprod_status_check_task(preprod_artifact_id: int) -> None:
Expand Down Expand Up @@ -321,19 +322,44 @@ def create_status_check(
)
return None

truncated_text = _truncate_to_byte_limit(text, GITHUB_MAX_TEXT_FIELD_LENGTH)
truncated_summary = _truncate_to_byte_limit(summary, GITHUB_MAX_SUMMARY_FIELD_LENGTH)

if text and len(truncated_text) != len(text):
logger.warning(
"preprod.status_checks.create.text_truncated",
extra={
"original_bytes": len(text.encode("utf-8")),
"truncated_bytes": len(truncated_text.encode("utf-8")),
"organization_id": self.organization_id,
"organization_slug": self.organization_slug,
},
)

if summary and len(truncated_summary) != len(summary):
logger.warning(
"preprod.status_checks.create.summary_truncated",
extra={
"original_bytes": len(summary.encode("utf-8")),
"truncated_bytes": len(truncated_summary.encode("utf-8")),
"organization_id": self.organization_id,
"organization_slug": self.organization_slug,
},
)

check_data: dict[str, Any] = {
"name": title,
"head_sha": sha,
"external_id": external_id,
"output": {
"title": subtitle,
"summary": summary,
"summary": truncated_summary,
},
"status": mapped_status.value,
}

if text:
check_data["output"]["text"] = text
if truncated_text:
check_data["output"]["text"] = truncated_text

if mapped_conclusion:
check_data["conclusion"] = mapped_conclusion.value
Expand All @@ -351,9 +377,70 @@ def create_status_check(
response = self.client.create_check_run(repo=repo, data=check_data)
check_id = response.get("id")
return str(check_id) if check_id else None
except Exception as e:
except ApiError as e:
lifecycle.record_failure(e)
return None

# 4xx client errors (except 429) are not transient (our fault or configuration issues)
# Convert them to IntegrationConfigurationError to prevent retries
# 429 rate limits, 5xx server errors, and other transient issues will bubble up and be retriable.
if e.code and 400 <= e.code < 500 and e.code != 429:
if e.code == 403:
error_message = str(e).lower()
if (
"resource not accessible" in error_message
or "insufficient" in error_message
or "permission" in error_message
):
logger.exception(
"preprod.status_checks.create.insufficient_permissions",
extra={
"organization_id": self.organization_id,
"integration_id": self.integration_id,
"repo": repo,
},
)
raise IntegrationConfigurationError(
"GitHub App lacks permissions to create check runs. "
"Please ensure the app has the required permissions and that "
"the organization has accepted any updated permissions."
) from e

logger.exception(
"preprod.status_checks.create.client_error",
extra={
"organization_id": self.organization_id,
"integration_id": self.integration_id,
"repo": repo,
"status_code": e.code,
},
)
raise IntegrationConfigurationError(
f"GitHub API returned {e.code} client error when creating check run"
) from e

# For 5xx or other errors, re-raise to allow retries
raise


GITHUB_MAX_SUMMARY_FIELD_LENGTH = 65535
Copy link
Member

@rbro112 rbro112 Nov 10, 2025

Choose a reason for hiding this comment

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

Where'd you find references to these limits? Would rec commenting the links to their docs if there is one.

Copy link
Member Author

Choose a reason for hiding this comment

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

good idea, added

GITHUB_MAX_TEXT_FIELD_LENGTH = 65535


def _truncate_to_byte_limit(text: str | None, byte_limit: int) -> str | None:
"""Truncate text to fit within byte limit while ensuring valid UTF-8."""
if not text:
return text

encoded = text.encode("utf-8")
if len(encoded) <= byte_limit:
return text

# Truncate to byte_limit - 10 (a bit of wiggle room) to make room for "..."
# Note: this can break formatting you have and is more of a catch-all,
# broken formatting is better than silently erroring for the user.
# Templating logic itself should try to more contextually trim the content if possible.
truncated = encoded[: byte_limit - 10].decode("utf-8", errors="ignore")
return truncated + "..."


GITHUB_STATUS_CHECK_STATUS_MAPPING: dict[StatusCheckStatus, GitHubCheckStatus] = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,14 @@
import uuid
from unittest.mock import Mock, patch

import responses

from sentry.integrations.source_code_management.status_check import StatusCheckStatus
from sentry.models.commitcomparison import CommitComparison
from sentry.models.repository import Repository
from sentry.preprod.models import PreprodArtifact, PreprodArtifactSizeMetrics
from sentry.preprod.vcs.status_checks.size.tasks import create_preprod_status_check_task
from sentry.shared_integrations.exceptions import ApiForbiddenError, IntegrationConfigurationError
from sentry.testutils.cases import TestCase
from sentry.testutils.silo import region_silo_test

Expand Down Expand Up @@ -445,3 +448,111 @@ def test_create_preprod_status_check_task_mixed_states_monorepo(self):
assert "com.example.uploading" in summary
assert "com.example.failed" in summary
assert "Upload timeout" in summary

@responses.activate
def test_create_preprod_status_check_task_github_permission_error(self):
"""Test task handles GitHub permission errors without retrying."""
preprod_artifact = self._create_preprod_artifact(
state=PreprodArtifact.ArtifactState.PROCESSED
)

PreprodArtifactSizeMetrics.objects.create(
preprod_artifact=preprod_artifact,
metrics_artifact_type=PreprodArtifactSizeMetrics.MetricsArtifactType.MAIN_ARTIFACT,
state=PreprodArtifactSizeMetrics.SizeAnalysisState.COMPLETED,
min_download_size=1024 * 1024,
max_download_size=1024 * 1024,
min_install_size=2 * 1024 * 1024,
max_install_size=2 * 1024 * 1024,
)

integration = self.create_integration(
organization=self.organization,
external_id="123",
provider="github",
metadata={"access_token": "test_token", "expires_at": "2099-01-01T00:00:00Z"},
)

Repository.objects.create(
organization_id=self.organization.id,
name="owner/repo",
provider="integrations:github",
integration_id=integration.id,
)

responses.add(
responses.POST,
"https://api.github.com/repos/owner/repo/check-runs",
status=403,
json={
"message": "Resource not accessible by integration",
"documentation_url": "https://docs.github.com/rest/checks/runs#create-a-check-run",
},
)

with self.tasks():
try:
create_preprod_status_check_task(preprod_artifact.id)
assert False, "Expected IntegrationConfigurationError to be raised"
except IntegrationConfigurationError as e:
assert "GitHub App lacks permissions" in str(e)
assert "required permissions" in str(e)

# Verify no retries due to ignore policy
assert len(responses.calls) == 1
assert (
responses.calls[0].request.url == "https://api.github.com/repos/owner/repo/check-runs"
)

@responses.activate
def test_create_preprod_status_check_task_github_non_permission_403(self):
"""Test task re-raises non-permission 403 errors (for potential retry)."""
preprod_artifact = self._create_preprod_artifact(
state=PreprodArtifact.ArtifactState.PROCESSED
)

PreprodArtifactSizeMetrics.objects.create(
preprod_artifact=preprod_artifact,
metrics_artifact_type=PreprodArtifactSizeMetrics.MetricsArtifactType.MAIN_ARTIFACT,
state=PreprodArtifactSizeMetrics.SizeAnalysisState.COMPLETED,
min_download_size=1024 * 1024,
max_download_size=1024 * 1024,
min_install_size=2 * 1024 * 1024,
max_install_size=2 * 1024 * 1024,
)

integration = self.create_integration(
organization=self.organization,
external_id="456",
provider="github",
metadata={"access_token": "test_token", "expires_at": "2099-01-01T00:00:00Z"},
)

Repository.objects.create(
organization_id=self.organization.id,
name="owner/repo",
provider="integrations:github",
integration_id=integration.id,
)

# 403 error that's NOT permission-related (should be re-raised, not converted)
responses.add(
responses.POST,
"https://api.github.com/repos/owner/repo/check-runs",
status=403,
json={
"message": "Repository is temporarily unavailable",
},
)

with self.tasks():
try:
create_preprod_status_check_task(preprod_artifact.id)
assert False, "Expected ApiForbiddenError to be raised"
except ApiForbiddenError as e:
assert "temporarily unavailable" in str(e)

assert len(responses.calls) == 1
assert (
responses.calls[0].request.url == "https://api.github.com/repos/owner/repo/check-runs"
)
Loading