Skip to content

Commit 24846e0

Browse files
authored
fix(preprod): truncate max status check length (#102987)
Resolves EME-612
1 parent c368cfd commit 24846e0

File tree

2 files changed

+132
-5
lines changed

2 files changed

+132
-5
lines changed

src/sentry/preprod/vcs/status_checks/size/tasks.py

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ def create_preprod_status_check_task(preprod_artifact_id: int) -> None:
8787
client,
8888
commit_comparison.provider,
8989
preprod_artifact.project.organization_id,
90+
preprod_artifact.project.organization.slug,
9091
repository.integration_id,
9192
)
9293
if not provider:
@@ -247,10 +248,16 @@ def _get_status_check_client(
247248

248249

249250
def _get_status_check_provider(
250-
client: StatusCheckClient, provider: str | None, organization_id: int, integration_id: int
251+
client: StatusCheckClient,
252+
provider: str | None,
253+
organization_id: int,
254+
organization_slug: str,
255+
integration_id: int,
251256
) -> _StatusCheckProvider | None:
252257
if provider == IntegrationProviderSlug.GITHUB:
253-
return _GitHubStatusCheckProvider(client, provider, organization_id, integration_id)
258+
return _GitHubStatusCheckProvider(
259+
client, provider, organization_id, organization_slug, integration_id
260+
)
254261
else:
255262
return None
256263

@@ -266,11 +273,13 @@ def __init__(
266273
client: StatusCheckClient,
267274
provider_key: str,
268275
organization_id: int,
276+
organization_slug: str,
269277
integration_id: int,
270278
):
271279
self.client = client
272280
self.provider_key = provider_key
273281
self.organization_id = organization_id
282+
self.organization_slug = organization_slug
274283
self.integration_id = integration_id
275284

276285
def _create_scm_interaction_event(self) -> SCMIntegrationInteractionEvent:
@@ -322,19 +331,44 @@ def create_status_check(
322331
)
323332
return None
324333

334+
truncated_text = _truncate_to_byte_limit(text, GITHUB_MAX_TEXT_FIELD_LENGTH)
335+
truncated_summary = _truncate_to_byte_limit(summary, GITHUB_MAX_SUMMARY_FIELD_LENGTH)
336+
337+
if text and truncated_text and len(truncated_text) != len(text):
338+
logger.warning(
339+
"preprod.status_checks.create.text_truncated",
340+
extra={
341+
"original_bytes": len(text.encode("utf-8")),
342+
"truncated_bytes": len(truncated_text.encode("utf-8")),
343+
"organization_id": self.organization_id,
344+
"organization_slug": self.organization_slug,
345+
},
346+
)
347+
348+
if summary and truncated_summary and len(truncated_summary) != len(summary):
349+
logger.warning(
350+
"preprod.status_checks.create.summary_truncated",
351+
extra={
352+
"original_bytes": len(summary.encode("utf-8")),
353+
"truncated_bytes": len(truncated_summary.encode("utf-8")),
354+
"organization_id": self.organization_id,
355+
"organization_slug": self.organization_slug,
356+
},
357+
)
358+
325359
check_data: dict[str, Any] = {
326360
"name": title,
327361
"head_sha": sha,
328362
"external_id": external_id,
329363
"output": {
330364
"title": subtitle,
331-
"summary": summary,
365+
"summary": truncated_summary,
332366
},
333367
"status": mapped_status.value,
334368
}
335369

336-
if text:
337-
check_data["output"]["text"] = text
370+
if truncated_text:
371+
check_data["output"]["text"] = truncated_text
338372

339373
if mapped_conclusion:
340374
check_data["conclusion"] = mapped_conclusion.value
@@ -395,6 +429,35 @@ def create_status_check(
395429
raise
396430

397431

432+
# See: https://docs.github.com/en/rest/checks/runs?apiVersion=2022-11-28#create-a-check-run
433+
GITHUB_MAX_SUMMARY_FIELD_LENGTH = 65535
434+
GITHUB_MAX_TEXT_FIELD_LENGTH = 65535
435+
436+
437+
def _truncate_to_byte_limit(text: str | None, byte_limit: int) -> str | None:
438+
"""Truncate text to fit within byte limit while ensuring valid UTF-8."""
439+
if not text:
440+
return text
441+
442+
TRUNCATE_AMOUNT = 10
443+
444+
encoded = text.encode("utf-8")
445+
if len(encoded) <= byte_limit:
446+
return text
447+
448+
if byte_limit <= TRUNCATE_AMOUNT:
449+
# This shouldn't happen, but just in case.
450+
truncated = encoded[:byte_limit].decode("utf-8", errors="ignore")
451+
return truncated
452+
453+
# Truncate to byte_limit - 10 (a bit of wiggle room) to make room for "..."
454+
# Note: this can break formatting you have and is more of a catch-all,
455+
# broken formatting is better than silently erroring for the user.
456+
# Templating logic itself should try to more contextually trim the content if possible.
457+
truncated = encoded[: byte_limit - TRUNCATE_AMOUNT].decode("utf-8", errors="ignore")
458+
return truncated + "..."
459+
460+
398461
GITHUB_STATUS_CHECK_STATUS_MAPPING: dict[StatusCheckStatus, GitHubCheckStatus] = {
399462
StatusCheckStatus.ACTION_REQUIRED: GitHubCheckStatus.COMPLETED,
400463
StatusCheckStatus.IN_PROGRESS: GitHubCheckStatus.IN_PROGRESS,

tests/sentry/preprod/vcs/status_checks/size/test_status_checks_tasks.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from sentry.shared_integrations.exceptions import IntegrationConfigurationError
1414
from sentry.testutils.cases import TestCase
1515
from sentry.testutils.silo import region_silo_test
16+
from sentry.utils import json
1617

1718

1819
@region_silo_test
@@ -663,3 +664,66 @@ def test_create_preprod_status_check_task_github_429_error(self):
663664
assert "rate limit" in str(e).lower()
664665

665666
assert len(responses.calls) == 1
667+
668+
@responses.activate
669+
def test_create_preprod_status_check_task_truncates_long_summary(self):
670+
"""Test task truncates summary when it exceeds GitHub's byte limit."""
671+
commit_comparison = CommitComparison.objects.create(
672+
organization_id=self.organization.id,
673+
head_sha="a" * 40,
674+
base_sha="b" * 40,
675+
provider="github",
676+
head_repo_name="owner/repo",
677+
base_repo_name="owner/repo",
678+
head_ref="feature/test",
679+
base_ref="main",
680+
)
681+
682+
artifacts = []
683+
for i in range(150):
684+
long_app_id = f"com.example.very.long.app.identifier.number.{i}" + "x" * 200
685+
artifact = PreprodArtifact.objects.create(
686+
project=self.project,
687+
state=PreprodArtifact.ArtifactState.FAILED,
688+
app_id=long_app_id,
689+
error_message=f"This is a very long error message that will contribute to the summary size. Error #{i}: "
690+
+ "y" * 500,
691+
commit_comparison=commit_comparison,
692+
)
693+
artifacts.append(artifact)
694+
695+
integration = self.create_integration(
696+
organization=self.organization,
697+
external_id="test-truncation",
698+
provider="github",
699+
metadata={"access_token": "test_token", "expires_at": "2099-01-01T00:00:00Z"},
700+
)
701+
702+
Repository.objects.create(
703+
organization_id=self.organization.id,
704+
name="owner/repo",
705+
provider="integrations:github",
706+
integration_id=integration.id,
707+
)
708+
709+
responses.add(
710+
responses.POST,
711+
"https://api.github.com/repos/owner/repo/check-runs",
712+
status=201,
713+
json={"id": 12345, "status": "completed"},
714+
)
715+
716+
with self.tasks():
717+
create_preprod_status_check_task(artifacts[0].id)
718+
719+
assert len(responses.calls) == 1
720+
request_body = responses.calls[0].request.body
721+
722+
payload = json.loads(request_body)
723+
summary = payload["output"]["summary"]
724+
725+
assert summary is not None
726+
summary_bytes = len(summary.encode("utf-8"))
727+
728+
assert summary_bytes <= 65535, f"Summary has {summary_bytes} bytes, exceeds GitHub limit"
729+
assert summary.endswith("..."), "Truncated summary should end with '...'"

0 commit comments

Comments
 (0)