diff --git a/sqlmesh/core/console.py b/sqlmesh/core/console.py
index 2f51a20a74..86de61c829 100644
--- a/sqlmesh/core/console.py
+++ b/sqlmesh/core/console.py
@@ -3049,34 +3049,47 @@ class CaptureTerminalConsole(TerminalConsole):
def __init__(self, console: t.Optional[RichConsole] = None, **kwargs: t.Any) -> None:
super().__init__(console=console, **kwargs)
self._captured_outputs: t.List[str] = []
+ self._warnings: t.List[str] = []
self._errors: t.List[str] = []
@property
def captured_output(self) -> str:
return "".join(self._captured_outputs)
+ @property
+ def captured_warnings(self) -> str:
+ return "".join(self._warnings)
+
@property
def captured_errors(self) -> str:
return "".join(self._errors)
def consume_captured_output(self) -> str:
- output = self.captured_output
- self.clear_captured_outputs()
- return output
+ try:
+ return self.captured_output
+ finally:
+ self._captured_outputs = []
- def consume_captured_errors(self) -> str:
- errors = self.captured_errors
- self.clear_captured_errors()
- return errors
+ def consume_captured_warnings(self) -> str:
+ try:
+ return self.captured_warnings
+ finally:
+ self._warnings = []
- def clear_captured_outputs(self) -> None:
- self._captured_outputs = []
+ def consume_captured_errors(self) -> str:
+ try:
+ return self.captured_errors
+ finally:
+ self._errors = []
- def clear_captured_errors(self) -> None:
- self._errors = []
+ def log_warning(self, short_message: str, long_message: t.Optional[str] = None) -> None:
+ if short_message not in self._warnings:
+ self._warnings.append(short_message)
+ super().log_warning(short_message, long_message)
def log_error(self, message: str) -> None:
- self._errors.append(message)
+ if message not in self._errors:
+ self._errors.append(message)
super().log_error(message)
def log_skipped_models(self, snapshot_names: t.Set[str]) -> None:
@@ -3087,9 +3100,8 @@ def log_skipped_models(self, snapshot_names: t.Set[str]) -> None:
super().log_skipped_models(snapshot_names)
def log_failed_models(self, errors: t.List[NodeExecutionFailedError]) -> None:
- if errors:
- self._errors.append("\n".join(str(ex) for ex in errors))
- super().log_failed_models(errors)
+ self._errors.extend([str(ex) for ex in errors if str(ex) not in self._errors])
+ super().log_failed_models(errors)
def _print(self, value: t.Any, **kwargs: t.Any) -> None:
with self.console.capture() as capture:
@@ -3110,6 +3122,11 @@ class MarkdownConsole(CaptureTerminalConsole):
AUDIT_PADDING = 7
def __init__(self, **kwargs: t.Any) -> None:
+ self.alert_block_max_content_length = int(kwargs.pop("alert_block_max_content_length", 500))
+ self.alert_block_collapsible_threshold = int(
+ kwargs.pop("alert_block_collapsible_threshold", 200)
+ )
+
super().__init__(
**{**kwargs, "console": RichConsole(no_color=True, width=kwargs.pop("width", None))}
)
@@ -3434,18 +3451,40 @@ def show_linter_violations(
self._print(msg)
self._errors.append(msg)
- def log_error(self, message: str) -> None:
- super().log_error(f"```\n\\[ERROR] {message}```\n\n")
+ @property
+ def captured_warnings(self) -> str:
+ return self._render_alert_block("WARNING", self._warnings)
- def log_warning(self, short_message: str, long_message: t.Optional[str] = None) -> None:
- logger.warning(long_message or short_message)
+ @property
+ def captured_errors(self) -> str:
+ return self._render_alert_block("CAUTION", self._errors)
- if not short_message.endswith("\n"):
- short_message += (
- "\n" # so that the closing ``` ends up on a newline which is important for GitHub
- )
+ def _render_alert_block(self, block_type: str, items: t.List[str]) -> str:
+ # GitHub Markdown alert syntax, https://docs.github.com/en/get-started/writing-on-github/getting-started-with-writing-and-formatting-on-github/basic-writing-and-formatting-syntax#alerts
+ if items:
+ item_contents = ""
+ list_indicator = "- " if len(items) > 1 else ""
+
+ for item in items:
+ item = item.replace("\n", "\n> ")
+ item_contents += f">\n> {list_indicator}{item}\n"
+
+ if len(item_contents) > self.alert_block_max_content_length:
+ truncation_msg = (
+ "...\n>\n> Truncated. Please check the console for full information.\n"
+ )
+ item_contents = item_contents[
+ 0 : self.alert_block_max_content_length - len(truncation_msg)
+ ]
+ item_contents += truncation_msg
+ break
+
+ if len(item_contents) > self.alert_block_collapsible_threshold:
+ item_contents = f"> \n{item_contents}> "
+
+ return f"> [!{block_type}]\n{item_contents}\n"
- self._print(f"```\n\\[WARNING] {short_message}```\n\n")
+ return ""
def _print(self, value: t.Any, **kwargs: t.Any) -> None:
self.console.print(value, **kwargs)
@@ -3472,7 +3511,7 @@ def _print(self, value: t.Any, **kwargs: t.Any) -> None:
super()._print(value, **kwargs)
for captured_output in self._captured_outputs:
print(captured_output)
- self.clear_captured_outputs()
+ self.consume_captured_output()
def _prompt(self, message: str, **kwargs: t.Any) -> t.Any:
self._print(message)
diff --git a/sqlmesh/integrations/github/cicd/command.py b/sqlmesh/integrations/github/cicd/command.py
index 04f5757b50..1a4982e9e1 100644
--- a/sqlmesh/integrations/github/cicd/command.py
+++ b/sqlmesh/integrations/github/cicd/command.py
@@ -119,10 +119,7 @@ def _update_pr_environment(controller: GithubController) -> bool:
except Exception as e:
logger.exception("Error occurred when updating PR environment")
conclusion = controller.update_pr_environment_check(
- status=GithubCheckStatus.COMPLETED,
- exception=e,
- plan=controller.pr_plan_or_none,
- plan_flags=controller.pr_plan_flags,
+ status=GithubCheckStatus.COMPLETED, exception=e
)
return (
conclusion is not None
diff --git a/sqlmesh/integrations/github/cicd/controller.py b/sqlmesh/integrations/github/cicd/controller.py
index 19c494a979..7b03868243 100644
--- a/sqlmesh/integrations/github/cicd/controller.py
+++ b/sqlmesh/integrations/github/cicd/controller.py
@@ -494,7 +494,7 @@ def get_plan_summary(self, plan: Plan) -> str:
try:
# Clear out any output that might exist from prior steps
- self._console.clear_captured_outputs()
+ self._console.consume_captured_output()
if plan.restatements:
self._console._print("\n**Restating models**\n")
else:
@@ -522,13 +522,115 @@ def get_plan_summary(self, plan: Plan) -> str:
if not difference_summary and not missing_dates:
return f"No changes to apply.{plan_flags_section}"
- return f"{difference_summary}\n{missing_dates}{plan_flags_section}"
+ warnings_block = self._console.consume_captured_warnings()
+ errors_block = self._console.consume_captured_errors()
+
+ return f"{warnings_block}{errors_block}{difference_summary}\n{missing_dates}{plan_flags_section}"
except PlanError as e:
logger.exception("Plan failed to generate")
return f"Plan failed to generate. Check for pending or unresolved changes. Error: {e}"
finally:
self._console.verbosity = orig_verbosity
+ def get_pr_environment_summary(
+ self, conclusion: GithubCheckConclusion, exception: t.Optional[Exception] = None
+ ) -> str:
+ heading = ""
+ summary = ""
+
+ if conclusion.is_success:
+ summary = self._get_pr_environment_summary_success()
+ elif conclusion.is_action_required:
+ heading = f":warning: Action Required to create or update PR Environment `{self.pr_environment_name}` :warning:"
+ summary = self._get_pr_environment_summary_action_required(exception)
+ elif conclusion.is_failure:
+ heading = (
+ f":x: Failed to create or update PR Environment `{self.pr_environment_name}` :x:"
+ )
+ summary = self._get_pr_environment_summary_failure(exception)
+ elif conclusion.is_skipped:
+ heading = f":next_track_button: Skipped creating or updating PR Environment `{self.pr_environment_name}` :next_track_button:"
+ summary = self._get_pr_environment_summary_skipped(exception)
+ else:
+ heading = f":interrobang: Got an unexpected conclusion: {conclusion.value}"
+
+ # note: we just add warnings here, errors will be covered by the "failure" conclusion
+ if warnings := self._console.consume_captured_warnings():
+ summary = f"{warnings}\n{summary}"
+
+ return f"{heading}\n\n{summary}".strip()
+
+ def _get_pr_environment_summary_success(self) -> str:
+ prod_plan = self.prod_plan_with_gaps
+
+ if not prod_plan.has_changes:
+ summary = "No models were modified in this PR.\n"
+ else:
+ intro = self._generate_pr_environment_summary_intro()
+ summary = intro + self._generate_pr_environment_summary_list(prod_plan)
+
+ if prod_plan.user_provided_flags:
+ summary += self._generate_plan_flags_section(prod_plan.user_provided_flags)
+
+ return summary
+
+ def _get_pr_environment_summary_skipped(self, exception: t.Optional[Exception] = None) -> str:
+ if isinstance(exception, NoChangesPlanError):
+ skip_reason = "No changes were detected compared to the prod environment."
+ elif isinstance(exception, TestFailure):
+ skip_reason = "Unit Test(s) Failed so skipping PR creation"
+ else:
+ skip_reason = "A prior stage failed resulting in skipping PR creation."
+
+ return skip_reason
+
+ def _get_pr_environment_summary_action_required(
+ self, exception: t.Optional[Exception] = None
+ ) -> str:
+ plan = self.pr_plan_or_none
+ if isinstance(exception, UncategorizedPlanError) and plan:
+ failure_msg = f"The following models could not be categorized automatically:\n"
+ for snapshot in plan.uncategorized:
+ failure_msg += f"- {snapshot.name}\n"
+ failure_msg += (
+ f"\nRun `sqlmesh plan {self.pr_environment_name}` locally to apply these changes.\n\n"
+ "If you would like the bot to automatically categorize changes, check the [documentation](https://sqlmesh.readthedocs.io/en/stable/integrations/github/) for more information."
+ )
+ else:
+ failure_msg = "Please check the Actions Workflow logs for more information."
+
+ return failure_msg
+
+ def _get_pr_environment_summary_failure(self, exception: t.Optional[Exception] = None) -> str:
+ console_output = self._console.consume_captured_output()
+
+ if isinstance(exception, PlanError):
+ failure_msg = f"Plan application failed.\n"
+ if exception.args and (msg := exception.args[0]) and isinstance(msg, str):
+ failure_msg += f"\n{msg}\n"
+ if console_output:
+ failure_msg += f"\n{console_output}"
+ elif isinstance(exception, (SQLMeshError, SqlglotError, ValueError)):
+ # this logic is taken from the global error handler attached to the CLI, which uses `click.echo()` to output the message
+ # so cant be re-used here because it bypasses the Console
+ failure_msg = f"**Error:** {str(exception)}"
+ elif exception:
+ logger.debug(
+ "Got unexpected error. Error Type: "
+ + str(type(exception))
+ + " Stack trace: "
+ + traceback.format_exc()
+ )
+ failure_msg = f"This is an unexpected error.\n\n**Exception:**\n```\n{traceback.format_exc()}\n```"
+
+ if captured_errors := self._console.consume_captured_errors():
+ failure_msg = f"{captured_errors}\n{failure_msg}"
+
+ if plan_flags := self.pr_plan_flags:
+ failure_msg += f"\n\n{self._generate_plan_flags_section(plan_flags)}"
+
+ return failure_msg
+
def run_tests(self) -> t.Tuple[ModelTextTestResult, str]:
"""
Run tests for the PR
@@ -539,7 +641,7 @@ def run_linter(self) -> None:
"""
Run linter for the PR
"""
- self._console.clear_captured_outputs()
+ self._console.consume_captured_output()
self._context.lint_models()
def _get_or_create_comment(self, header: str = BOT_HEADER_MSG) -> IssueComment:
@@ -611,7 +713,22 @@ def update_pr_environment(self) -> None:
Creates a PR environment from the logic present in the PR. If the PR contains changes that are
uncategorized, then an error will be raised.
"""
- self._context.apply(self.pr_plan)
+ self._context.apply(self.pr_plan) # will raise if PR environment creation fails
+
+ # update PR info comment
+ vde_title = "- :eyes: To **review** this PR's changes, use virtual data environment:"
+ comment_value = f"{vde_title}\n - `{self.pr_environment_name}`"
+ if self.bot_config.enable_deploy_command:
+ comment_value += (
+ "\n- :arrow_forward: To **apply** this PR's plan to prod, comment:\n - `/deploy`"
+ )
+ dedup_regex = vde_title.replace("*", r"\*") + r".*"
+ updated_comment, _ = self.update_sqlmesh_comment_info(
+ value=comment_value,
+ dedup_regex=dedup_regex,
+ )
+ if updated_comment:
+ self._append_output("created_pr_environment", "true")
def deploy_to_prod(self) -> None:
"""
@@ -855,13 +972,7 @@ def conclusion_handler(
)
def update_pr_environment_check(
- self,
- status: GithubCheckStatus,
- exception: t.Optional[Exception] = None,
- plan: t.Optional[Plan] = None,
- plan_flags: t.Optional[
- t.Dict[str, UserProvidedFlags]
- ] = None, # note: the plan flags are passed separately in case the plan fails to build
+ self, status: GithubCheckStatus, exception: t.Optional[Exception] = None
) -> t.Optional[GithubCheckConclusion]:
"""
Updates the status of the merge commit for the PR environment.
@@ -882,87 +993,7 @@ def update_pr_environment_check(
def conclusion_handler(
conclusion: GithubCheckConclusion, exception: t.Optional[Exception]
) -> t.Tuple[GithubCheckConclusion, str, t.Optional[str]]:
- if conclusion.is_success:
- prod_plan = self.prod_plan_with_gaps
-
- if not prod_plan.has_changes:
- summary = "No models were modified in this PR.\n"
- else:
- intro = self._generate_pr_environment_summary_intro()
- summary = intro + self._generate_pr_environment_summary_list(prod_plan)
- if prod_plan.user_provided_flags:
- summary += self._generate_plan_flags_section(prod_plan.user_provided_flags)
-
- vde_title = (
- "- :eyes: To **review** this PR's changes, use virtual data environment:"
- )
- comment_value = f"{vde_title}\n - `{self.pr_environment_name}`"
- if self.bot_config.enable_deploy_command:
- comment_value += "\n- :arrow_forward: To **apply** this PR's plan to prod, comment:\n - `/deploy`"
- dedup_regex = vde_title.replace("*", r"\*") + r".*"
- updated_comment, _ = self.update_sqlmesh_comment_info(
- value=comment_value,
- dedup_regex=dedup_regex,
- )
- if updated_comment:
- self._append_output("created_pr_environment", "true")
- else:
- if isinstance(exception, NoChangesPlanError):
- skip_reason = "No changes were detected compared to the prod environment."
- elif isinstance(exception, TestFailure):
- skip_reason = "Unit Test(s) Failed so skipping PR creation"
- else:
- skip_reason = "A prior stage failed resulting in skipping PR creation."
-
- if not skip_reason and exception:
- logger.debug(
- f"Got {type(exception).__name__}. Stack trace: " + traceback.format_exc()
- )
-
- captured_errors = self._console.consume_captured_errors()
- if captured_errors:
- logger.debug(f"Captured errors: {captured_errors}")
- failure_msg = f"**Errors:**\n{captured_errors}\n"
- elif isinstance(exception, UncategorizedPlanError) and plan:
- failure_msg = f"The following models could not be categorized automatically:\n"
- for snapshot in plan.uncategorized:
- failure_msg += f"- {snapshot.name}\n"
- failure_msg += (
- f"\nRun `sqlmesh plan {self.pr_environment_name}` locally to apply these changes.\n\n"
- "If you would like the bot to automatically categorize changes, check the [documentation](https://sqlmesh.readthedocs.io/en/stable/integrations/github/) for more information."
- )
- elif isinstance(exception, PlanError):
- failure_msg = f"Plan application failed.\n"
- if exception.args and (msg := exception.args[0]) and isinstance(msg, str):
- failure_msg += f"\n{msg}\n"
- if self._console.captured_output:
- failure_msg += f"\n{self._console.captured_output}"
- elif isinstance(exception, (SQLMeshError, SqlglotError, ValueError)):
- # this logic is taken from the global error handler attached to the CLI, which uses `click.echo()` to output the message
- # so cant be re-used here because it bypasses the Console
- failure_msg = f"**Error:** {str(exception)}"
- else:
- logger.debug(
- "Got unexpected error. Error Type: "
- + str(type(exception))
- + " Stack trace: "
- + traceback.format_exc()
- )
- failure_msg = f"This is an unexpected error.\n\n**Exception:**\n```\n{traceback.format_exc()}\n```"
-
- conclusion_to_summary = {
- GithubCheckConclusion.SKIPPED: f":next_track_button: Skipped creating or updating PR Environment `{self.pr_environment_name}`. {skip_reason}",
- GithubCheckConclusion.FAILURE: f":x: Failed to create or update PR Environment `{self.pr_environment_name}` :x:\n\n{failure_msg}",
- GithubCheckConclusion.CANCELLED: f":stop_sign: Cancelled creating or updating PR Environment `{self.pr_environment_name}`",
- GithubCheckConclusion.ACTION_REQUIRED: f":warning: Action Required to create or update PR Environment `{self.pr_environment_name}` :warning:\n\n{failure_msg}",
- }
- summary = conclusion_to_summary.get(
- conclusion, f":interrobang: Got an unexpected conclusion: {conclusion.value}"
- )
- if plan_flags:
- plan_flags_section = self._generate_plan_flags_section(plan_flags)
- summary += f"\n\n{plan_flags_section}"
-
+ summary = self.get_pr_environment_summary(conclusion, exception)
self._append_output("pr_environment_name", self.pr_environment_name)
return conclusion, check_title, summary
diff --git a/tests/core/test_console.py b/tests/core/test_console.py
new file mode 100644
index 0000000000..f899713235
--- /dev/null
+++ b/tests/core/test_console.py
@@ -0,0 +1,131 @@
+from sqlmesh.core.console import MarkdownConsole
+
+
+def test_markdown_console_warning_block():
+ console = MarkdownConsole(
+ alert_block_max_content_length=100, alert_block_collapsible_threshold=45
+ )
+ assert console.consume_captured_warnings() == ""
+
+ # single warning, within threshold
+ console.log_warning("First warning")
+ assert console.consume_captured_warnings() == "> [!WARNING]\n>\n> First warning\n\n"
+
+ # multiple warnings, within threshold (list syntax)
+ console.log_warning("First warning")
+ console.log_warning("Second warning")
+ assert (
+ console.consume_captured_warnings()
+ == "> [!WARNING]\n>\n> - First warning\n>\n> - Second warning\n\n"
+ )
+
+ # single warning, within max threshold but over collapsible section threshold
+ warning = "The snowflake engine is not recommended for storing SQLMesh state in production deployments"
+ assert len(warning) > console.alert_block_collapsible_threshold
+ assert len(warning) < console.alert_block_max_content_length
+ console.log_warning(warning)
+ assert (
+ console.consume_captured_warnings()
+ == "> [!WARNING]\n> \n>\n> The snowflake engine is not recommended for storing SQLMesh state in production deployments\n> \n"
+ )
+
+ # single warning, over max threshold
+ warning = "The snowflake engine is not recommended for storing SQLMesh state in production deployments. Please see for a list of recommended engines and more information."
+ assert len(warning) > console.alert_block_collapsible_threshold
+ assert len(warning) > console.alert_block_max_content_length
+ console.log_warning(warning)
+ assert (
+ console.consume_captured_warnings()
+ == "> [!WARNING]\n> \n>\n> The snowflake engine is not re...\n>\n> Truncated. Please check the console for full information.\n> \n"
+ )
+
+ # multiple warnings, within max threshold but over collapsible section threshold
+ warning_1 = "This is the first warning"
+ warning_2 = "This is the second warning"
+ assert (len(warning_1) + len(warning_2)) > console.alert_block_collapsible_threshold
+ assert (len(warning_1) + len(warning_2)) < console.alert_block_max_content_length
+ console.log_warning(warning_1)
+ console.log_warning(warning_2)
+ assert (
+ console.consume_captured_warnings()
+ == "> [!WARNING]\n> \n>\n> - This is the first warning\n>\n> - This is the second warning\n> \n"
+ )
+
+ # multiple warnings, over max threshold
+ warning_1 = "This is the first warning and its really really long"
+ warning_2 = "This is the second warning and its also really really long"
+ assert (len(warning_1) + len(warning_2)) > console.alert_block_collapsible_threshold
+ assert (len(warning_1) + len(warning_2)) > console.alert_block_max_content_length
+ console.log_warning(warning_1)
+ console.log_warning(warning_2)
+ assert (
+ console.consume_captured_warnings()
+ == "> [!WARNING]\n> \n>\n> - This is the first warning an...\n>\n> Truncated. Please check the console for full information.\n> \n"
+ )
+
+ assert console.consume_captured_warnings() == ""
+
+
+def test_markdown_console_error_block():
+ console = MarkdownConsole(
+ alert_block_max_content_length=100, alert_block_collapsible_threshold=40
+ )
+ assert console.consume_captured_errors() == ""
+
+ # single error, within threshold
+ console.log_error("First error")
+ assert console.consume_captured_errors() == "> [!CAUTION]\n>\n> First error\n\n"
+
+ # multiple errors, within threshold (list syntax)
+ console.log_error("First error")
+ console.log_error("Second error")
+ assert (
+ console.consume_captured_errors()
+ == "> [!CAUTION]\n>\n> - First error\n>\n> - Second error\n\n"
+ )
+
+ # single error, within max threshold but over collapsible section threshold
+ error = "The snowflake engine is not recommended for storing SQLMesh state in production deployments"
+ assert len(error) > console.alert_block_collapsible_threshold
+ assert len(error) < console.alert_block_max_content_length
+ console.log_error(error)
+ assert (
+ console.consume_captured_errors()
+ == "> [!CAUTION]\n> \n>\n> The snowflake engine is not recommended for storing SQLMesh state in production deployments\n> \n"
+ )
+
+ # single error, over max threshold
+ error = "The snowflake engine is not recommended for storing SQLMesh state in production deployments. Please see for a list of recommended engines and more information."
+ assert len(error) > console.alert_block_collapsible_threshold
+ assert len(error) > console.alert_block_max_content_length
+ console.log_error(error)
+ assert (
+ console.consume_captured_errors()
+ == "> [!CAUTION]\n> \n>\n> The snowflake engine is not re...\n>\n> Truncated. Please check the console for full information.\n> \n"
+ )
+
+ # multiple errors, within max threshold but over collapsible section threshold
+ error_1 = "This is the first error"
+ error_2 = "This is the second error"
+ assert (len(error_1) + len(error_2)) > console.alert_block_collapsible_threshold
+ assert (len(error_1) + len(error_2)) < console.alert_block_max_content_length
+ console.log_error(error_1)
+ console.log_error(error_2)
+ assert (
+ console.consume_captured_errors()
+ == "> [!CAUTION]\n> \n>\n> - This is the first error\n>\n> - This is the second error\n> \n"
+ )
+
+ # multiple errors, over max threshold
+ error_1 = "This is the first error and its really really long"
+ error_2 = "This is the second error and its also really really long"
+ assert (len(error_1) + len(error_2)) > console.alert_block_collapsible_threshold
+ assert (len(error_1) + len(error_2)) > console.alert_block_max_content_length
+ console.log_error(error_1)
+ console.log_error(error_2)
+ assert (
+ console.consume_captured_errors()
+ == "> [!CAUTION]\n> \n>\n> - This is the first error and ...\n>\n> Truncated. Please check the console for full information.\n> \n"
+ )
+
+ assert console.consume_captured_errors() == ""
diff --git a/tests/integrations/github/cicd/test_github_controller.py b/tests/integrations/github/cicd/test_github_controller.py
index fafc6f6291..099ed6d9ef 100644
--- a/tests/integrations/github/cicd/test_github_controller.py
+++ b/tests/integrations/github/cicd/test_github_controller.py
@@ -19,11 +19,13 @@
from sqlmesh.integrations.github.cicd.controller import (
BotCommand,
MergeStateStatus,
+ GithubCheckConclusion,
)
from sqlmesh.integrations.github.cicd.controller import GithubController
from sqlmesh.integrations.github.cicd.command import _update_pr_environment
from sqlmesh.utils.date import to_datetime, now
from tests.integrations.github.cicd.conftest import MockIssueComment
+from sqlmesh.utils.errors import SQLMeshError
pytestmark = pytest.mark.github
@@ -644,3 +646,54 @@ def test_get_plan_summary_doesnt_truncate_backfill_list(
* `memory.sushi.waiter_revenue_by_day`: [2025-06-30 - 2025-07-06]"""
in summary
)
+
+
+def test_get_plan_summary_includes_warnings_and_errors(
+ github_client, make_controller: t.Callable[..., GithubController]
+):
+ controller = make_controller(
+ "tests/fixtures/github/pull_request_synchronized.json",
+ github_client,
+ mock_out_context=False,
+ )
+
+ controller._console.log_warning("Warning 1\nWith multiline")
+ controller._console.log_warning("Warning 2")
+ controller._console.log_error("Error 1")
+
+ summary = controller.get_plan_summary(controller.prod_plan)
+
+ assert ("> [!WARNING]\n>\n> - Warning 1\n> With multiline\n>\n> - Warning 2\n\n") in summary
+
+ assert ("> [!CAUTION]\n>\n> Error 1\n\n") in summary
+
+
+def test_get_pr_environment_summary_includes_warnings_and_errors(
+ github_client, make_controller: t.Callable[..., GithubController]
+):
+ controller = make_controller(
+ "tests/fixtures/github/pull_request_synchronized.json",
+ github_client,
+ mock_out_context=False,
+ )
+
+ controller._console.log_warning("Warning 1")
+ controller._console.log_error("Error 1")
+
+ # completed with no exception triggers a SUCCESS conclusion and only shows warnings
+ success_summary = controller.get_pr_environment_summary(
+ conclusion=GithubCheckConclusion.SUCCESS
+ )
+ assert "> [!WARNING]\n>\n> Warning 1\n" in success_summary
+ assert "> [!CAUTION]\n>\n> Error 1" not in success_summary
+
+ # since they got consumed in the previous call
+ controller._console.log_warning("Warning 1")
+ controller._console.log_error("Error 1")
+
+ # completed with an exception triggers a FAILED conclusion and shows errors
+ error_summary = controller.get_pr_environment_summary(
+ conclusion=GithubCheckConclusion.FAILURE, exception=SQLMeshError("Something broke")
+ )
+ assert "> [!WARNING]\n>\n> Warning 1\n" in error_summary
+ assert "> [!CAUTION]\n>\n> Error 1" in error_summary
diff --git a/tests/integrations/github/cicd/test_integration.py b/tests/integrations/github/cicd/test_integration.py
index ff9856993a..17e495fbc3 100644
--- a/tests/integrations/github/cicd/test_integration.py
+++ b/tests/integrations/github/cicd/test_integration.py
@@ -846,7 +846,7 @@ def test_merge_pr_has_no_changes(
assert GithubCheckConclusion(pr_checks_runs[2]["conclusion"]).is_skipped
assert pr_checks_runs[2]["output"]["title"] == "PR Virtual Data Environment: hello_world_2"
assert (
- ":next_track_button: Skipped creating or updating PR Environment `hello_world_2`. No changes were detected compared to the prod environment."
+ ":next_track_button: Skipped creating or updating PR Environment `hello_world_2` :next_track_button:\n\nNo changes were detected compared to the prod environment."
in pr_checks_runs[2]["output"]["summary"]
)