Skip to content

Commit d4dfe0f

Browse files
authored
Fix(cicd): Better handle model failures in GitHub check output (#4648)
1 parent 8121aa9 commit d4dfe0f

10 files changed

Lines changed: 164 additions & 40 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ dev = [
7676
"psycopg2-binary",
7777
"pydantic",
7878
"PyAthena[Pandas]",
79-
"PyGithub~=2.5.0",
79+
"PyGithub",
8080
"pyperf",
8181
"pyspark~=3.5.0",
8282
"pytest",

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
}
@@ -1061,3 +1093,7 @@ def _chunk_up_api_message(self, message: str) -> t.List[str]:
10611093
message_encoded[i : i + self.MAX_BYTE_LENGTH].decode("utf-8", "ignore")
10621094
for i in range(0, len(message_encoded), self.MAX_BYTE_LENGTH)
10631095
]
1096+
1097+
@property
1098+
def running_in_github_actions(self) -> bool:
1099+
return os.environ.get("GITHUB_ACTIONS", None) == "true"

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

Lines changed: 36 additions & 7 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
@@ -39,37 +41,47 @@ def github_client(mocker: MockerFixture):
3941

4042

4143
@pytest.fixture
42-
def make_pull_request_review() -> t.Callable:
44+
def make_pull_request_review(github_client) -> t.Callable:
4345
from github.PullRequestReview import PullRequestReview
4446

4547
def _make_function(username: str, state: str, **kwargs) -> PullRequestReview:
4648
return PullRequestReview(
47-
"test", # type: ignore
49+
github_client.requester,
4850
{},
4951
{
5052
# Name is whatever they provide in their GitHub profile or login as fallback. Always use login.
5153
"user": AttributeDict(name="Unrelated", login=username),
5254
"state": state,
5355
**kwargs,
5456
},
55-
completed=False,
5657
)
5758

5859
return _make_function
5960

6061

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

6576
def _make_function(
66-
event_path: t.Union[str, t.Dict],
77+
event_path: t.Union[str, Path, t.Dict],
6778
client: Github,
6879
*,
6980
merge_state_status: MergeStateStatus = MergeStateStatus.CLEAN,
7081
bot_config: t.Optional[GithubCICDBotConfig] = None,
7182
mock_out_context: bool = True,
7283
config: t.Optional[t.Union[Config, str]] = None,
84+
paths: t.Optional[t.Union[Path, t.List[Path]]] = None,
7385
) -> GithubController:
7486
if mock_out_context:
7587
mocker.patch("sqlmesh.core.context.Context.apply", mocker.MagicMock())
@@ -85,7 +97,23 @@ def _make_function(
8597
bot_config,
8698
)
8799

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

90118
orig_console = get_console()
91119
try:
@@ -96,12 +124,13 @@ def _make_function(
96124
token="abc",
97125
event=(
98126
GithubEvent.from_path(event_path)
99-
if isinstance(event_path, str)
127+
if isinstance(event_path, (str, Path))
100128
else GithubEvent.from_obj(event_path)
101129
),
102130
client=client,
103131
config=config,
104132
)
133+
105134
finally:
106135
set_console(orig_console)
107136

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

0 commit comments

Comments
 (0)