Skip to content

Commit c0cf0af

Browse files
authored
Feat: Add CICD bot check for linting (#4027)
1 parent 3f3a67a commit c0cf0af

11 files changed

Lines changed: 229 additions & 23 deletions

File tree

docs/integrations/github.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
The GitHub Actions CI/CD Bot enables teams to automate their SQLMesh projects using GitHub Actions. It can be configured to perform the following things:
66

77
* Automatically run unit tests on PRs
8+
* Automatically run the linter on PRs
89
* Automatically create PR environments that represent the code changes in the PR
910
* Automatically categorize and backfill data for models that have changed
1011
* Automatically deploy changes to production with automatic data gap prevention and merge the PR
@@ -350,6 +351,7 @@ These can be used to potentially trigger follow up steps in the workflow.
350351
These are the possible outputs (based on how the bot is configured) that are created by the bot:
351352

352353
* `run_unit_tests`
354+
* `linter`
353355
* `has_required_approval`
354356
* `pr_environment_synced`
355357
* `prod_plan_preview`
@@ -373,6 +375,8 @@ In addition, there are custom outputs listed below:
373375
* `created_pr_environment` - set to `"true"` (a string with a value of `true`) if a PR environment was created for the first time. It is absent, or considered empty string if you check for it, if it is not created for the first time
374376
* `pr_environment_name` - the name of the PR environment. It is output whenever PR environment synced check reaches a conclusion. Therefore make sure to check the status of `created_pr_environment` or `pr_environment_synced` before acting on this output
375377

378+
Note: The `linter` step will run only if it's enabled in the project's configuration (`config.yaml` / `config.py`). The step will fail if the linter finds errors, otherwise it'll output only the warnings.
379+
376380
## Custom Workflow Configuration
377381
You can configure each individual action to run as a separate step. This can allow for more complex workflows or integrating specific steps with other actions you want to trigger. Run `sqlmesh_cicd github` to see a list of commands that can be supplied and their potential options.
378382
```bash
@@ -460,6 +464,10 @@ jobs:
460464
## Example Screenshots
461465
### Automated Unit Tests with Error Summary
462466
![Automated Unit Tests with Error Summary](github/github_test_summary.png)
467+
### Automated Linting with Error Summary
468+
![Automated Linting with Error Summary](github/linter_errors.png)
469+
### Automated Linting with Warning Summary
470+
![Automated Linting with Warning Summary](github/linter_warnings.png)
463471
### Automatically create PR Environments that represent the code changes in the PR
464472
![Environment Summary](github/github_env_summary.png)
465473
### Enforce that certain reviewers have approved of the PR before it can be merged
336 KB
Loading
279 KB
Loading

sqlmesh/core/console.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2241,7 +2241,6 @@ def _show_categorized_snapshots(self, plan: Plan, default_catalog: t.Optional[st
22412241
def log_test_results(
22422242
self, result: unittest.result.TestResult, output: t.Optional[str], target_dialect: str
22432243
) -> None:
2244-
# import ipywidgets as widgets
22452244
if result.wasSuccessful():
22462245
self._print(
22472246
f"**Successfully Ran `{str(result.testsRun)}` Tests Against `{target_dialect}`**\n\n"

sqlmesh/core/context.py

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2394,7 +2394,10 @@ def _get_models_for_interval_end(
23942394
)
23952395
return models_for_interval_end
23962396

2397-
def lint_models(self, models: t.Optional[t.Iterable[t.Union[str, Model]]] = None) -> None:
2397+
def lint_models(
2398+
self,
2399+
models: t.Optional[t.Iterable[t.Union[str, Model]]] = None,
2400+
) -> None:
23982401
found_error = False
23992402

24002403
model_list = (
@@ -2403,7 +2406,7 @@ def lint_models(self, models: t.Optional[t.Iterable[t.Union[str, Model]]] = None
24032406
for model in model_list:
24042407
# Linter may be `None` if the context is not loaded yet
24052408
if linter := self._linters.get(model.project):
2406-
found_error = linter.lint_model(model) or found_error
2409+
found_error = linter.lint_model(model, console=self.console) or found_error
24072410

24082411
if found_error:
24092412
raise LinterError(

sqlmesh/core/linter/definition.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from sqlmesh.core.model import Model
88

99
from sqlmesh.utils.errors import raise_config_error
10-
from sqlmesh.core.console import get_console
10+
from sqlmesh.core.console import get_console, Console
1111
from sqlmesh.core.linter.rule import RuleSet
1212

1313

@@ -50,7 +50,7 @@ def from_rules(cls, all_rules: RuleSet, config: LinterConfig) -> Linter:
5050

5151
return Linter(config.enabled, all_rules, rules, warn_rules)
5252

53-
def lint_model(self, model: Model) -> bool:
53+
def lint_model(self, model: Model, console: Console = get_console()) -> bool:
5454
if not self.enabled:
5555
return False
5656

@@ -63,10 +63,10 @@ def lint_model(self, model: Model) -> bool:
6363
warn_violations = warn_rules.check_model(model)
6464

6565
if warn_violations:
66-
get_console().show_linter_violations(warn_violations, model)
66+
console.show_linter_violations(warn_violations, model)
6767

6868
if error_violations:
69-
get_console().show_linter_violations(error_violations, model, is_error=True)
69+
console.show_linter_violations(error_violations, model, is_error=True)
7070
return True
7171

7272
return False

sqlmesh/integrations/github/cicd/command.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
GithubController,
1414
TestFailure,
1515
)
16-
from sqlmesh.utils.errors import CICDBotError, ConflictingPlanError, PlanError
16+
from sqlmesh.utils.errors import CICDBotError, ConflictingPlanError, PlanError, LinterError
1717

1818
logger = logging.getLogger(__name__)
1919

@@ -80,6 +80,25 @@ def _run_tests(controller: GithubController) -> bool:
8080
return False
8181

8282

83+
def _run_linter(controller: GithubController) -> bool:
84+
controller.update_linter_check(status=GithubCheckStatus.IN_PROGRESS)
85+
try:
86+
controller.run_linter()
87+
except LinterError:
88+
controller.update_linter_check(
89+
status=GithubCheckStatus.COMPLETED,
90+
conclusion=GithubCheckConclusion.FAILURE,
91+
)
92+
return False
93+
94+
controller.update_linter_check(
95+
status=GithubCheckStatus.COMPLETED,
96+
conclusion=GithubCheckConclusion.SUCCESS,
97+
)
98+
99+
return True
100+
101+
83102
@github.command()
84103
@click.pass_context
85104
@cli_analytics
@@ -204,11 +223,13 @@ def _run_all(controller: GithubController) -> None:
204223
has_required_approval = True
205224
else:
206225
raise CICDBotError(f"Unsupported command: {command}")
226+
controller.update_linter_check(status=GithubCheckStatus.QUEUED)
207227
controller.update_pr_environment_check(status=GithubCheckStatus.QUEUED)
208228
controller.update_prod_plan_preview_check(status=GithubCheckStatus.QUEUED)
209229
controller.update_test_check(status=GithubCheckStatus.QUEUED)
210230
if is_auto_deploying_prod:
211231
controller.update_prod_environment_check(status=GithubCheckStatus.QUEUED)
232+
linter_passed = _run_linter(controller)
212233
tests_passed = _run_tests(controller)
213234
if controller.do_required_approval_check:
214235
if has_required_approval:
@@ -218,23 +239,25 @@ def _run_all(controller: GithubController) -> None:
218239
else:
219240
controller.update_required_approval_check(status=GithubCheckStatus.QUEUED)
220241
has_required_approval = _check_required_approvers(controller)
221-
if not tests_passed:
242+
if not tests_passed or not linter_passed:
222243
controller.update_pr_environment_check(
223244
status=GithubCheckStatus.COMPLETED,
224-
exception=TestFailure(),
245+
exception=LinterError("") if not linter_passed else TestFailure(),
225246
)
226247
controller.update_prod_plan_preview_check(
227248
status=GithubCheckStatus.COMPLETED,
228249
conclusion=GithubCheckConclusion.SKIPPED,
229-
summary="Unit Test(s) Failed so skipping creating prod plan",
250+
summary="Linter or Unit Test(s) failed so skipping creating prod plan",
230251
)
231252
if is_auto_deploying_prod:
232253
controller.update_prod_environment_check(
233254
status=GithubCheckStatus.COMPLETED,
234255
conclusion=GithubCheckConclusion.SKIPPED,
235-
skip_reason="Unit Test(s) Failed so skipping deploying to production",
256+
skip_reason="Linter or Unit Test(s) failed so skipping deploying to production",
236257
)
237-
raise CICDBotError("Failed to run tests. See check status for more information.")
258+
259+
raise CICDBotError("Linter or Unit Test(s) failed. See check status for more information.")
260+
238261
pr_environment_updated = _update_pr_environment(controller)
239262
prod_plan_generated = False
240263
if pr_environment_updated:

sqlmesh/integrations/github/cicd/controller.py

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
format_intervals,
3030
)
3131
from sqlmesh.core.user import User
32+
from sqlmesh.core.config import Config
3233
from sqlmesh.integrations.github.cicd.config import GithubCICDBotConfig
3334
from sqlmesh.utils import word_characters_only, Verbosity
3435
from sqlmesh.utils.concurrency import NodeExecutionFailedError
@@ -38,6 +39,7 @@
3839
NoChangesPlanError,
3940
PlanError,
4041
UncategorizedPlanError,
42+
LinterError,
4143
)
4244
from sqlmesh.utils.pydantic import PydanticModel
4345

@@ -50,8 +52,6 @@
5052
from github.PullRequestReview import PullRequestReview
5153
from github.Repository import Repository
5254

53-
from sqlmesh.core.config import Config
54-
5555
logger = logging.getLogger(__name__)
5656

5757

@@ -326,10 +326,7 @@ def __init__(
326326
if review.state.lower() == "approved"
327327
}
328328
logger.debug(f"Approvers: {', '.join(self._approvers)}")
329-
self._context: Context = Context(
330-
paths=self._paths,
331-
config=self.config,
332-
)
329+
self._context: Context = Context(paths=self._paths, config=self.config)
333330

334331
@property
335332
def deploy_command_enabled(self) -> bool:
@@ -394,6 +391,7 @@ def pr_plan(self) -> Plan:
394391
self._pr_plan_builder = self._context.plan_builder(
395392
environment=self.pr_environment_name,
396393
skip_tests=True,
394+
skip_linter=True,
397395
categorizer_config=self.bot_config.auto_categorize_changes,
398396
start=self.bot_config.default_pr_start,
399397
skip_backfill=self.bot_config.skip_pr_backfill,
@@ -409,6 +407,7 @@ def prod_plan(self) -> Plan:
409407
c.PROD,
410408
no_gaps=True,
411409
skip_tests=True,
410+
skip_linter=True,
412411
categorizer_config=self.bot_config.auto_categorize_changes,
413412
run=self.bot_config.run_on_deploy_to_prod,
414413
)
@@ -423,6 +422,7 @@ def prod_plan_with_gaps(self) -> Plan:
423422
no_gaps=False,
424423
no_auto_categorization=True,
425424
skip_tests=True,
425+
skip_linter=True,
426426
run=self.bot_config.run_on_deploy_to_prod,
427427
)
428428
assert self._prod_plan_with_gaps_builder
@@ -478,6 +478,13 @@ def run_tests(self) -> t.Tuple[unittest.result.TestResult, str]:
478478
"""
479479
return self._context._run_tests(verbosity=Verbosity.VERBOSE)
480480

481+
def run_linter(self) -> None:
482+
"""
483+
Run linter for the PR
484+
"""
485+
self._console.clear_captured_outputs()
486+
self._context.lint_models()
487+
481488
def _get_or_create_comment(self, header: str = BOT_HEADER_MSG) -> IssueComment:
482489
comment = seq_get(
483490
[comment for comment in self._issue.get_comments() if header in comment.body],
@@ -654,6 +661,37 @@ def _update_check_handler(
654661
full_summary=summary,
655662
)
656663

664+
def update_linter_check(
665+
self,
666+
status: GithubCheckStatus,
667+
conclusion: t.Optional[GithubCheckConclusion] = None,
668+
) -> None:
669+
if not self._context.config.linter.enabled:
670+
return
671+
672+
def conclusion_handler(
673+
conclusion: GithubCheckConclusion,
674+
) -> t.Tuple[GithubCheckConclusion, str, t.Optional[str]]:
675+
linter_summary = self._console.consume_captured_output() or "Linter Success"
676+
677+
title = "Linter results"
678+
679+
return conclusion, title, linter_summary
680+
681+
self._update_check_handler(
682+
check_name="SQLMesh - Linter",
683+
status=status,
684+
conclusion=conclusion,
685+
status_handler=lambda status: (
686+
{
687+
GithubCheckStatus.IN_PROGRESS: "Running linter",
688+
GithubCheckStatus.QUEUED: "Waiting to Run linter",
689+
}[status],
690+
None,
691+
),
692+
conclusion_handler=conclusion_handler,
693+
)
694+
657695
def update_test_check(
658696
self,
659697
status: GithubCheckStatus,
@@ -751,7 +789,7 @@ def update_pr_environment_check(
751789
Updates the status of the merge commit for the PR environment.
752790
"""
753791
conclusion: t.Optional[GithubCheckConclusion] = None
754-
if isinstance(exception, (NoChangesPlanError, TestFailure)):
792+
if isinstance(exception, (NoChangesPlanError, TestFailure, LinterError)):
755793
conclusion = GithubCheckConclusion.SKIPPED
756794
elif isinstance(exception, UncategorizedPlanError):
757795
conclusion = GithubCheckConclusion.ACTION_REQUIRED

tests/integrations/github/cicd/fixtures.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import pytest
44
from pytest_mock.plugin import MockerFixture
55

6+
from sqlmesh.core.config import Config
67
from sqlmesh.core.console import set_console, get_console, MarkdownConsole
78
from sqlmesh.integrations.github.cicd.config import GithubCICDBotConfig
89
from sqlmesh.integrations.github.cicd.controller import (
@@ -67,6 +68,7 @@ def _make_function(
6768
merge_state_status: MergeStateStatus = MergeStateStatus.CLEAN,
6869
bot_config: t.Optional[GithubCICDBotConfig] = None,
6970
mock_out_context: bool = True,
71+
config: t.Optional[t.Union[Config, str]] = None,
7072
) -> GithubController:
7173
if mock_out_context:
7274
mocker.patch("sqlmesh.core.context.Context.apply", mocker.MagicMock())
@@ -97,6 +99,7 @@ def _make_function(
9799
else GithubEvent.from_obj(event_path)
98100
),
99101
client=client,
102+
config=config,
100103
)
101104
finally:
102105
set_console(orig_console)

tests/integrations/github/cicd/test_github_commands.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -497,7 +497,7 @@ def test_run_all_test_failed(
497497
)
498498
assert (
499499
prod_plan_preview_checks_runs[1]["output"]["summary"]
500-
== "Unit Test(s) Failed so skipping creating prod plan"
500+
== "Linter or Unit Test(s) failed so skipping creating prod plan"
501501
)
502502

503503
assert "SQLMesh - PR Environment Synced" in controller._check_run_mapping
@@ -625,7 +625,7 @@ def test_run_all_test_exception(
625625
)
626626
assert (
627627
prod_plan_preview_checks_runs[1]["output"]["summary"]
628-
== "Unit Test(s) Failed so skipping creating prod plan"
628+
== "Linter or Unit Test(s) failed so skipping creating prod plan"
629629
)
630630

631631
assert "SQLMesh - PR Environment Synced" in controller._check_run_mapping

0 commit comments

Comments
 (0)