Skip to content

Commit 0962bfa

Browse files
committed
Fix(cicd): Better handle model failures in GitHub check output
1 parent aa0c50f commit 0962bfa

9 files changed

Lines changed: 161 additions & 36 deletions

File tree

sqlmesh/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,10 @@ def configure_logging(
197197
level = logging.DEBUG if debug else logging.INFO
198198
logger.setLevel(level)
199199

200+
if debug:
201+
# Remove noisy snowflake connector logs that are not useful for users
202+
logging.getLogger("snowflake.connector").setLevel(logging.INFO)
203+
200204
if write_to_stdout:
201205
stdout_handler = logging.StreamHandler(sys.stdout)
202206
stdout_handler.setFormatter(CustomFormatter())

sqlmesh/core/console.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3184,6 +3184,12 @@ def log_error(self, message: str) -> None:
31843184

31853185
def log_warning(self, short_message: str, long_message: t.Optional[str] = None) -> None:
31863186
logger.warning(long_message or short_message)
3187+
3188+
if not short_message.endswith("\n"):
3189+
short_message += (
3190+
"\n" # so that the closing ``` ends up on a newline which is important for GitHub
3191+
)
3192+
31873193
self._print(f"```\n\\[WARNING] {short_message}```\n\n")
31883194

31893195
def _print(self, value: t.Any, **kwargs: t.Any) -> None:

sqlmesh/integrations/github/cicd/command.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
@click.option(
2323
"--token",
2424
type=str,
25+
envvar="GITHUB_TOKEN",
2526
help="The Github Token to be used. Pass in `${{ secrets.GITHUB_TOKEN }}` if you want to use the one created by Github actions",
2627
)
2728
@click.pass_context

sqlmesh/integrations/github/cicd/controller.py

Lines changed: 57 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import unittest
1212
from enum import Enum
1313
from typing import List
14+
from pathlib import Path
1415

1516
import requests
1617
from hyperscript import Element, h
@@ -28,18 +29,19 @@
2829
SnapshotTableInfo,
2930
format_intervals,
3031
)
32+
from sqlglot.errors import SqlglotError
3133
from sqlmesh.core.user import User
3234
from sqlmesh.core.config import Config
3335
from sqlmesh.integrations.github.cicd.config import GithubCICDBotConfig
3436
from sqlmesh.utils import word_characters_only, Verbosity
35-
from sqlmesh.utils.concurrency import NodeExecutionFailedError
3637
from sqlmesh.utils.date import now
3738
from sqlmesh.utils.errors import (
3839
CICDBotError,
3940
NoChangesPlanError,
4041
PlanError,
4142
UncategorizedPlanError,
4243
LinterError,
44+
SQLMeshError,
4345
)
4446
from sqlmesh.utils.pydantic import PydanticModel
4547

@@ -283,7 +285,7 @@ class GithubController:
283285

284286
def __init__(
285287
self,
286-
paths: t.Union[str, t.Iterable[str]],
288+
paths: t.Union[Path, t.Iterable[Path]],
287289
token: str,
288290
config: t.Optional[t.Union[Config, str]] = None,
289291
event: t.Optional[GithubEvent] = None,
@@ -307,10 +309,13 @@ def __init__(
307309
raise CICDBotError("Console must be a markdown console.")
308310
self._console = t.cast(MarkdownConsole, get_console())
309311

312+
from github.Consts import DEFAULT_BASE_URL
313+
from github.Auth import Token
314+
310315
self._client: Github = client or Github(
311-
base_url=os.environ["GITHUB_API_URL"],
312-
login_or_token=self._token,
316+
base_url=os.environ.get("GITHUB_API_URL", DEFAULT_BASE_URL), auth=Token(self._token)
313317
)
318+
314319
self._repo: Repository = self._client.get_repo(
315320
self._event.pull_request_info.full_repo_path, lazy=True
316321
)
@@ -328,6 +333,9 @@ def __init__(
328333
logger.debug(f"Approvers: {', '.join(self._approvers)}")
329334
self._context: Context = Context(paths=self._paths, config=self.config)
330335

336+
# Bot config needs the context to be initialized
337+
logger.debug(f"Bot config: {self.bot_config.json(indent=2)}")
338+
331339
@property
332340
def deploy_command_enabled(self) -> bool:
333341
return self.bot_config.enable_deploy_command
@@ -433,7 +441,6 @@ def bot_config(self) -> GithubCICDBotConfig:
433441
bot_config = self._context.config.cicd_bot or GithubCICDBotConfig(
434442
auto_categorize_changes=self._context.auto_categorize_changes
435443
)
436-
logger.debug(f"Bot config: {bot_config.json(indent=2)}")
437444
return bot_config
438445

439446
@property
@@ -454,8 +461,11 @@ def _append_output(cls, key: str, value: str) -> None:
454461
Appends the given key/value to output so they can be read by following steps
455462
"""
456463
logger.debug(f"Setting output. Key: {key}, Value: {value}")
457-
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as fh:
458-
print(f"{key}={value}", file=fh)
464+
465+
# GitHub Actions sets this environment variable
466+
if output_file := os.environ.get("GITHUB_OUTPUT"):
467+
with open(output_file, "a", encoding="utf-8") as fh:
468+
print(f"{key}={value}", file=fh)
459469

460470
def get_plan_summary(self, plan: Plan) -> str:
461471
try:
@@ -637,15 +647,31 @@ def _update_check(
637647
if text:
638648
kwargs["output"]["text"] = text
639649
logger.debug(f"Updating check with kwargs: {kwargs}")
640-
if name in self._check_run_mapping:
641-
logger.debug(f"Found check run in mapping so updating it. Name: {name}")
642-
check_run = self._check_run_mapping[name]
643-
check_run.edit(
644-
**{k: v for k, v in kwargs.items() if k not in ("name", "head_sha", "started_at")}
645-
)
650+
651+
if self.running_in_github_actions:
652+
# Only make the API call to update the checks if we are running within GitHub Actions
653+
# One very annoying limitation of the Pull Request Checks API is that its only available to GitHub Apps
654+
# and not personal access tokens, which makes it unable to be utilized during local development
655+
if name in self._check_run_mapping:
656+
logger.debug(f"Found check run in mapping so updating it. Name: {name}")
657+
check_run = self._check_run_mapping[name]
658+
check_run.edit(
659+
**{
660+
k: v
661+
for k, v in kwargs.items()
662+
if k not in ("name", "head_sha", "started_at")
663+
}
664+
)
665+
else:
666+
logger.debug(f"Did not find check run in mapping so creating it. Name: {name}")
667+
self._check_run_mapping[name] = self._repo.create_check_run(**kwargs)
646668
else:
647-
logger.debug(f"Did not find check run in mapping so creating it. Name: {name}")
648-
self._check_run_mapping[name] = self._repo.create_check_run(**kwargs)
669+
# Output the summary using print() so the newlines are resolved and the result can easily
670+
# be disambiguated from the rest of the console output and copy+pasted into a Markdown renderer
671+
print(
672+
f"---CHECK OUTPUT START: {kwargs['output']['title']} ---\n{kwargs['output']['summary']}\n---CHECK OUTPUT END---\n"
673+
)
674+
649675
if conclusion:
650676
self._append_output(
651677
word_characters_only(name.replace("SQLMesh - ", "").lower()), conclusion.value
@@ -890,15 +916,21 @@ def conclusion_handler(
890916
else:
891917
skip_reason = "A prior stage failed resulting in skipping PR creation."
892918

919+
if not skip_reason and exception:
920+
logger.debug(
921+
f"Got {type(exception).__name__}. Stack trace: " + traceback.format_exc()
922+
)
923+
893924
captured_errors = self._console.consume_captured_errors()
894925
if captured_errors:
895926
logger.debug(f"Captured errors: {captured_errors}")
896927
failure_msg = f"**Errors:**\n{captured_errors}\n"
897-
elif isinstance(exception, NodeExecutionFailedError):
898-
logger.debug(
899-
"Got Node Execution Failed Error. Stack trace: " + traceback.format_exc()
900-
)
901-
failure_msg = f"Node `{exception.node.name}` failed to apply.\n\n**Stack Trace:**\n```\n{traceback.format_exc()}\n```"
928+
elif isinstance(exception, PlanError):
929+
failure_msg = f"Plan application failed.\n\n{self._console.captured_output}"
930+
elif isinstance(exception, (SQLMeshError, SqlglotError, ValueError)):
931+
# this logic is taken from the global error handler attached to the CLI, which uses `click.echo()` to output the message
932+
# so cant be re-used here because it bypasses the Console
933+
failure_msg = f"**Error:** {str(exception)}"
902934
else:
903935
logger.debug(
904936
"Got unexpected error. Error Type: "
@@ -909,7 +941,7 @@ def conclusion_handler(
909941
failure_msg = f"This is an unexpected error.\n\n**Exception:**\n```\n{traceback.format_exc()}\n```"
910942
conclusion_to_summary = {
911943
GithubCheckConclusion.SKIPPED: f":next_track_button: Skipped creating or updating PR Environment `{self.pr_environment_name}`. {skip_reason}",
912-
GithubCheckConclusion.FAILURE: f":x: Failed to create or update PR Environment `{self.pr_environment_name}`.\n{failure_msg}",
944+
GithubCheckConclusion.FAILURE: f":x: Failed to create or update PR Environment `{self.pr_environment_name}`.\n\n{failure_msg}",
913945
GithubCheckConclusion.CANCELLED: f":stop_sign: Cancelled creating or updating PR Environment `{self.pr_environment_name}`",
914946
GithubCheckConclusion.ACTION_REQUIRED: f":warning: Action Required to create or update PR Environment `{self.pr_environment_name}`. There are likely uncateogrized changes. Run `plan` locally to apply these changes. If you want the bot to automatically categorize changes, then check documentation (https://sqlmesh.readthedocs.io/en/stable/integrations/github/) for more information.",
915947
}
@@ -1054,3 +1086,7 @@ def _chunk_up_api_message(self, message: str) -> t.List[str]:
10541086
message_encoded[i : i + self.MAX_BYTE_LENGTH].decode("utf-8", "ignore")
10551087
for i in range(0, len(message_encoded), self.MAX_BYTE_LENGTH)
10561088
]
1089+
1090+
@property
1091+
def running_in_github_actions(self) -> bool:
1092+
return os.environ.get("GITHUB_ACTIONS", None) == "true"

tests/integrations/github/cicd/fixtures.py renamed to tests/integrations/github/cicd/conftest.py

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import pytest
44
from pytest_mock.plugin import MockerFixture
5+
from pathlib import Path
56

67
from sqlmesh.core.config import Config
78
from sqlmesh.core.console import set_console, get_console, MarkdownConsole
@@ -13,6 +14,7 @@
1314
PullRequestInfo,
1415
)
1516
from sqlmesh.utils import AttributeDict
17+
from sqlglot.helper import ensure_list
1618

1719

1820
@pytest.fixture
@@ -59,17 +61,28 @@ def _make_function(username: str, state: str, **kwargs) -> PullRequestReview:
5961

6062

6163
@pytest.fixture
62-
def make_controller(mocker: MockerFixture, copy_to_temp_path: t.Callable) -> t.Callable:
64+
def sqlmesh_repo_root_path() -> Path:
65+
return next(p for p in Path(__file__).parents if str(p).endswith("/tests")).parent
66+
67+
68+
@pytest.fixture
69+
def make_controller(
70+
mocker: MockerFixture,
71+
copy_to_temp_path: t.Callable,
72+
monkeypatch: pytest.MonkeyPatch,
73+
sqlmesh_repo_root_path: Path,
74+
) -> t.Callable:
6375
from github import Github
6476

6577
def _make_function(
66-
event_path: t.Union[str, t.Dict],
78+
event_path: t.Union[str, Path, t.Dict],
6779
client: Github,
6880
*,
6981
merge_state_status: MergeStateStatus = MergeStateStatus.CLEAN,
7082
bot_config: t.Optional[GithubCICDBotConfig] = None,
7183
mock_out_context: bool = True,
7284
config: t.Optional[t.Union[Config, str]] = None,
85+
paths: t.Optional[t.Union[Path, t.List[Path]]] = None,
7386
) -> GithubController:
7487
if mock_out_context:
7588
mocker.patch("sqlmesh.core.context.Context.apply", mocker.MagicMock())
@@ -85,7 +98,23 @@ def _make_function(
8598
bot_config,
8699
)
87100

88-
paths = copy_to_temp_path("examples/sushi")
101+
if paths is None:
102+
paths = copy_to_temp_path(sqlmesh_repo_root_path / "examples" / "sushi")
103+
104+
paths = ensure_list(paths)
105+
106+
if isinstance(event_path, str):
107+
# resolve relative event_path references to absolute so they dont get affected by chdir() below
108+
as_path = Path(event_path)
109+
if not as_path.is_absolute():
110+
event_path = sqlmesh_repo_root_path / as_path
111+
112+
# set the current working directory to the temp path so that config references to eg duckdb "db.db"
113+
# get created in the temp path and not in the SQLMesh repo root path that the tests are triggered from
114+
monkeypatch.chdir(paths[0])
115+
116+
# make the tests think they are running in GitHub Actions
117+
monkeypatch.setenv("GITHUB_ACTIONS", "true")
89118

90119
orig_console = get_console()
91120
try:
@@ -96,12 +125,13 @@ def _make_function(
96125
token="abc",
97126
event=(
98127
GithubEvent.from_path(event_path)
99-
if isinstance(event_path, str)
128+
if isinstance(event_path, (str, Path))
100129
else GithubEvent.from_obj(event_path)
101130
),
102131
client=client,
103132
config=config,
104133
)
134+
105135
finally:
106136
set_console(orig_console)
107137

tests/integrations/github/cicd/test_github_commands.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
)
1919
from sqlmesh.utils.errors import ConflictingPlanError, PlanError, TestError, CICDBotError
2020

21-
pytest_plugins = ["tests.integrations.github.cicd.fixtures"]
2221
pytestmark = [
2322
pytest.mark.github,
2423
pytest.mark.slow,

tests/integrations/github/cicd/test_github_controller.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,8 @@
1919
GithubCheckStatus,
2020
MergeStateStatus,
2121
)
22-
from tests.integrations.github.cicd.fixtures import MockIssueComment
22+
from tests.integrations.github.cicd.conftest import MockIssueComment
2323

24-
pytest_plugins = ["tests.integrations.github.cicd.fixtures"]
2524
pytestmark = pytest.mark.github
2625

2726
github_controller_approvers_params = [

tests/integrations/github/cicd/test_github_event.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import pytest
22

3-
pytest_plugins = ["tests.integrations.github.cicd.fixtures"]
43
pytestmark = pytest.mark.github
54

65

tests/integrations/github/cicd/test_integration.py

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,9 @@
2323
GithubCheckStatus,
2424
GithubController,
2525
)
26-
from sqlmesh.utils.errors import CICDBotError
27-
from tests.integrations.github.cicd.fixtures import MockIssueComment
26+
from sqlmesh.utils.errors import CICDBotError, SQLMeshError
27+
from tests.integrations.github.cicd.conftest import MockIssueComment
2828

29-
pytest_plugins = ["tests.integrations.github.cicd.fixtures"]
3029
pytestmark = [
3130
pytest.mark.slow,
3231
pytest.mark.github,
@@ -1502,10 +1501,9 @@ def test_error_msg_when_applying_plan_with_bug(
15021501
assert GithubCheckStatus(pr_checks_runs[2]["status"]).is_completed
15031502
assert GithubCheckConclusion(pr_checks_runs[2]["conclusion"]).is_failure
15041503
assert pr_checks_runs[2]["output"]["title"] == "PR Virtual Data Environment: hello_world_2"
1505-
assert (
1506-
'Binder Error: Referenced column "non_existing_col" not found in FROM clause!'
1507-
in pr_checks_runs[2]["output"]["summary"]
1508-
)
1504+
summary = pr_checks_runs[2]["output"]["summary"].replace("\n", "")
1505+
assert 'Failed models **"memory"."sushi"."waiter_revenue_by_day"**' in summary
1506+
assert 'Binder Error: Referenced column "non_existing_col" not found in FROM clause!' in summary
15091507

15101508
assert "SQLMesh - Prod Plan Preview" in controller._check_run_mapping
15111509
prod_plan_preview_checks_runs = controller._check_run_mapping[
@@ -2129,3 +2127,56 @@ def test_has_required_approval_but_not_base_branch(
21292127
output
21302128
== "run_unit_tests=success\nhas_required_approval=success\ncreated_pr_environment=true\npr_environment_name=hello_world_2\npr_environment_synced=success\nprod_plan_preview=success\n"
21312129
)
2130+
2131+
2132+
def test_unexpected_error_is_handled(
2133+
github_client,
2134+
make_controller,
2135+
make_mock_check_run,
2136+
make_mock_issue_comment,
2137+
mocker: MockerFixture,
2138+
):
2139+
"""
2140+
Scenario:
2141+
- Plan throws a SQLMeshError due to a migration version mismatch
2142+
- Outcome should be a nice error like the CLI gives and not a stack trace
2143+
"""
2144+
2145+
mock_repo = github_client.get_repo()
2146+
mock_repo.create_check_run = mocker.MagicMock(
2147+
side_effect=lambda **kwargs: make_mock_check_run(**kwargs)
2148+
)
2149+
2150+
created_comments: t.List[MockIssueComment] = []
2151+
mock_issue = mock_repo.get_issue()
2152+
mock_issue.create_comment = mocker.MagicMock(
2153+
side_effect=lambda comment: make_mock_issue_comment(
2154+
comment=comment, created_comments=created_comments
2155+
)
2156+
)
2157+
2158+
controller = make_controller(
2159+
"tests/fixtures/github/pull_request_synchronized.json",
2160+
github_client,
2161+
bot_config=GithubCICDBotConfig(),
2162+
mock_out_context=True,
2163+
)
2164+
assert isinstance(controller, GithubController)
2165+
2166+
assert isinstance(controller._context.apply, mocker.MagicMock)
2167+
controller._context.apply.side_effect = SQLMeshError(
2168+
"SQLGlot (local) is using version 'X' which is ahead of 'Y' (remote). Please run a migration"
2169+
)
2170+
2171+
command._update_pr_environment(controller)
2172+
2173+
assert "SQLMesh - PR Environment Synced" in controller._check_run_mapping
2174+
pr_checks_runs = controller._check_run_mapping["SQLMesh - PR Environment Synced"].all_kwargs # type: ignore
2175+
assert pr_checks_runs[1]["output"]["title"] == "PR Virtual Data Environment: hello_world_2"
2176+
summary = pr_checks_runs[1]["output"]["summary"]
2177+
assert (
2178+
"**Error:** SQLGlot (local) is using version 'X' which is ahead of 'Y' (remote). Please run a migration"
2179+
in pr_checks_runs[1]["output"]["summary"]
2180+
)
2181+
assert "SQLMeshError" not in summary
2182+
assert "Traceback (most recent call last)" not in summary

0 commit comments

Comments
 (0)