diff --git a/sqlmesh/integrations/github/cicd/controller.py b/sqlmesh/integrations/github/cicd/controller.py index c693d09560..58b0664c2d 100644 --- a/sqlmesh/integrations/github/cicd/controller.py +++ b/sqlmesh/integrations/github/cicd/controller.py @@ -1182,13 +1182,26 @@ def get_command_from_comment(self) -> BotCommand: def _chunk_up_api_message(self, message: str) -> t.List[str]: """ - Chunks up the message into `MAX_BYTE_LENGTH` byte chunks + Chunks up the message into chunks of at most `MAX_BYTE_LENGTH` bytes when + UTF-8 encoded. + + Chunk boundaries are placed between characters rather than raw bytes so a + multibyte character that lands on a boundary is never split and dropped. """ - message_encoded = message.encode("utf-8") - return [ - message_encoded[i : i + self.MAX_BYTE_LENGTH].decode("utf-8", "ignore") - for i in range(0, len(message_encoded), self.MAX_BYTE_LENGTH) - ] + chunks: t.List[str] = [] + current = "" + current_length = 0 + for char in message: + char_length = len(char.encode("utf-8")) + if current and current_length + char_length > self.MAX_BYTE_LENGTH: + chunks.append(current) + current = "" + current_length = 0 + current += char + current_length += char_length + if current: + chunks.append(current) + return chunks @property def running_in_github_actions(self) -> bool: diff --git a/tests/integrations/github/cicd/test_github_controller.py b/tests/integrations/github/cicd/test_github_controller.py index 96895a5960..a309d56126 100644 --- a/tests/integrations/github/cicd/test_github_controller.py +++ b/tests/integrations/github/cicd/test_github_controller.py @@ -915,3 +915,22 @@ def test_forward_only_config_falls_back_to_plan_config( controller._context.config.plan.forward_only = False assert controller.forward_only_plan is False + + +def test_chunk_up_api_message_preserves_multibyte_chars( + github_client, make_event_issue_comment, make_controller +): + controller = make_controller(make_event_issue_comment("created", "test"), github_client) + + max_bytes = controller.MAX_BYTE_LENGTH + # Place a multibyte character exactly on the byte boundary between two chunks so + # that byte-oriented slicing would bisect its UTF-8 encoding and drop it. + message = ("a" * (max_bytes - 1)) + "é" + ("b" * max_bytes) + assert len(message.encode("utf-8")) > max_bytes + + chunks = controller._chunk_up_api_message(message) + + # No character is lost, so the chunks reassemble into the original message. + assert "".join(chunks) == message + # Each chunk still respects the GitHub API byte limit. + assert all(len(chunk.encode("utf-8")) <= max_bytes for chunk in chunks)