Skip to content

Commit 7176087

Browse files
authored
Feat!: Expand Functionality of GitHub Action CI/CD Bot (#1499)
* feat!: support sync and desync patterns github cicd bot * add youtube link
1 parent 8fae0c0 commit 7176087

16 files changed

Lines changed: 2441 additions & 391 deletions

File tree

docs/integrations/github.md

Lines changed: 307 additions & 70 deletions
Large diffs are not rendered by default.
137 KB
Loading
106 KB
Loading

sqlmesh/cicd/config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from sqlmesh.integrations.github.cicd.config import GithubCICDBotConfig
2+
3+
CICDBotConfig = GithubCICDBotConfig

sqlmesh/core/config/categorizer.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,3 +32,30 @@ class CategorizerConfig(BaseConfig):
3232
python: AutoCategorizationMode = AutoCategorizationMode.OFF
3333
sql: AutoCategorizationMode = AutoCategorizationMode.FULL
3434
seed: AutoCategorizationMode = AutoCategorizationMode.FULL
35+
36+
@classmethod
37+
def all_off(cls) -> CategorizerConfig:
38+
return cls(
39+
external=AutoCategorizationMode.OFF,
40+
python=AutoCategorizationMode.OFF,
41+
sql=AutoCategorizationMode.OFF,
42+
seed=AutoCategorizationMode.OFF,
43+
)
44+
45+
@classmethod
46+
def all_semi(cls) -> CategorizerConfig:
47+
return cls(
48+
external=AutoCategorizationMode.SEMI,
49+
python=AutoCategorizationMode.SEMI,
50+
sql=AutoCategorizationMode.SEMI,
51+
seed=AutoCategorizationMode.SEMI,
52+
)
53+
54+
@classmethod
55+
def all_full(cls) -> CategorizerConfig:
56+
return cls(
57+
external=AutoCategorizationMode.FULL,
58+
python=AutoCategorizationMode.FULL,
59+
sql=AutoCategorizationMode.FULL,
60+
seed=AutoCategorizationMode.FULL,
61+
)

sqlmesh/core/config/root.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from pydantic import Field
88
from sqlglot.helper import first
99

10+
from sqlmesh.cicd.config import CICDBotConfig
1011
from sqlmesh.core import constants as c
1112
from sqlmesh.core.config import EnvironmentSuffixTarget
1213
from sqlmesh.core.config.base import BaseConfig, UpdateStrategy
@@ -80,6 +81,7 @@ class Config(BaseConfig):
8081
default=EnvironmentSuffixTarget.default
8182
)
8283
default_target_environment: str = c.PROD
84+
cicd_bot: t.Optional[CICDBotConfig] = None
8385

8486
_FIELD_UPDATE_STRATEGY: t.ClassVar[t.Dict[str, UpdateStrategy]] = {
8587
"gateways": UpdateStrategy.KEY_UPDATE,

sqlmesh/core/context.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,12 @@
4848

4949
from sqlmesh.core import constants as c
5050
from sqlmesh.core.audit import Audit, StandaloneAudit
51-
from sqlmesh.core.config import Config, load_config_from_paths, load_config_from_yaml
51+
from sqlmesh.core.config import (
52+
CategorizerConfig,
53+
Config,
54+
load_config_from_paths,
55+
load_config_from_yaml,
56+
)
5257
from sqlmesh.core.console import Console, get_console
5358
from sqlmesh.core.context_diff import ContextDiff
5459
from sqlmesh.core.dialect import (
@@ -96,6 +101,7 @@
96101
MissingDependencyError,
97102
PlanError,
98103
SQLMeshError,
104+
UncategorizedPlanError,
99105
)
100106
from sqlmesh.utils.jinja import JinjaMacroRegistry
101107

@@ -711,6 +717,7 @@ def plan(
711717
effective_from: t.Optional[TimeLike] = None,
712718
include_unmodified: t.Optional[bool] = None,
713719
select_models: t.Optional[t.Collection[str]] = None,
720+
categorizer_config: t.Optional[CategorizerConfig] = None,
714721
) -> Plan:
715722
"""Interactively create a migration plan.
716723
@@ -743,9 +750,11 @@ def plan(
743750
no_auto_categorization: Indicates whether to disable automatic categorization of model
744751
changes (breaking / non-breaking). If not provided, then the corresponding configuration
745752
option determines the behavior.
753+
categorizer_config: The configuration for the categorizer. Uses the categorizer configuration defined in the
754+
project config by default.
746755
effective_from: The effective date from which to apply forward-only changes on production.
747756
include_unmodified: Indicates whether to include unmodified models in the target development environment.
748-
model_selections: A list of model selection strings to filter the models that should be included into this plan.
757+
select_models: A list of model selection strings to filter the models that should be included into this plan.
749758
750759
Returns:
751760
The populated Plan object.
@@ -809,7 +818,7 @@ def plan(
809818
forward_only=forward_only,
810819
environment_ttl=environment_ttl,
811820
environment_suffix_target=self.config.environment_suffix_target,
812-
categorizer_config=self.auto_categorize_changes,
821+
categorizer_config=categorizer_config or self.auto_categorize_changes,
813822
auto_categorization_enabled=not no_auto_categorization,
814823
effective_from=effective_from,
815824
include_unmodified=include_unmodified,
@@ -840,7 +849,7 @@ def apply(self, plan: Plan) -> None:
840849
):
841850
return
842851
if plan.uncategorized:
843-
raise PlanError("Can't apply a plan with uncategorized changes.")
852+
raise UncategorizedPlanError("Can't apply a plan with uncategorized changes.")
844853
self.notification_target_manager.notify(
845854
NotificationEvent.APPLY_START, environment=plan.environment.name
846855
)
Lines changed: 108 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,20 @@
11
from __future__ import annotations
22

33
import logging
4-
import typing as t
54

65
import click
76

87
from sqlmesh.integrations.github.cicd.controller import (
98
GithubCheckConclusion,
109
GithubCheckStatus,
1110
GithubController,
12-
MergeMethod,
11+
TestFailure,
1312
)
1413
from sqlmesh.utils.errors import CICDBotError, PlanError
1514

1615
logger = logging.getLogger(__name__)
1716

1817

19-
merge_method_option = click.option(
20-
"--merge-method",
21-
type=click.Choice(MergeMethod), # type: ignore
22-
help="Enables merging PR after successfully deploying to production using the provided method.",
23-
)
24-
25-
delete_option = click.option(
26-
"--delete",
27-
is_flag=True,
28-
help="Delete the PR environment after successfully deploying to production",
29-
)
30-
31-
3218
@click.group(no_args_is_help=True)
3319
@click.option(
3420
"--token",
@@ -68,13 +54,13 @@ def check_required_approvers(ctx: click.Context) -> None:
6854
def _run_tests(controller: GithubController) -> bool:
6955
controller.update_test_check(status=GithubCheckStatus.IN_PROGRESS)
7056
try:
71-
result, failed_output = controller.run_tests()
57+
result, output = controller.run_tests()
7258
controller.update_test_check(
7359
status=GithubCheckStatus.COMPLETED,
7460
# Conclusion will be updated with final status based on test results
7561
conclusion=GithubCheckConclusion.NEUTRAL,
7662
result=result,
77-
failed_output=failed_output,
63+
output=output,
7864
)
7965
return result.wasSuccessful()
8066
except Exception:
@@ -95,15 +81,17 @@ def _update_pr_environment(controller: GithubController) -> bool:
9581
controller.update_pr_environment_check(status=GithubCheckStatus.IN_PROGRESS)
9682
try:
9783
controller.update_pr_environment()
98-
controller.update_pr_environment_check(
99-
status=GithubCheckStatus.COMPLETED, conclusion=GithubCheckConclusion.SUCCESS
84+
conclusion = controller.update_pr_environment_check(status=GithubCheckStatus.COMPLETED)
85+
return conclusion is not None and conclusion.is_success
86+
except Exception as e:
87+
conclusion = controller.update_pr_environment_check(
88+
status=GithubCheckStatus.COMPLETED, exception=e
10089
)
101-
return True
102-
except PlanError:
103-
controller.update_pr_environment_check(
104-
status=GithubCheckStatus.COMPLETED, conclusion=GithubCheckConclusion.ACTION_REQUIRED
90+
return (
91+
conclusion is not None
92+
and not conclusion.is_failure
93+
and not conclusion.is_action_required
10594
)
106-
return False
10795

10896

10997
@github.command()
@@ -113,21 +101,43 @@ def update_pr_environment(ctx: click.Context) -> None:
113101
_update_pr_environment(ctx.obj["github"])
114102

115103

116-
def _deploy_production(
117-
controller: GithubController,
118-
merge_method: t.Optional[MergeMethod],
119-
delete_environment_after_deploy: bool = True,
120-
) -> bool:
104+
def _gen_prod_plan(controller: GithubController) -> bool:
105+
controller.update_prod_plan_preview_check(status=GithubCheckStatus.IN_PROGRESS)
106+
try:
107+
plan_summary = controller.get_plan_summary(controller.prod_plan)
108+
controller.update_prod_plan_preview_check(
109+
status=GithubCheckStatus.COMPLETED,
110+
conclusion=GithubCheckConclusion.SUCCESS,
111+
summary=plan_summary,
112+
)
113+
return bool(plan_summary)
114+
except Exception as e:
115+
controller.update_prod_plan_preview_check(
116+
status=GithubCheckStatus.COMPLETED,
117+
conclusion=GithubCheckConclusion.FAILURE,
118+
summary=str(e),
119+
)
120+
return False
121+
122+
123+
@github.command()
124+
@click.pass_context
125+
def gen_prod_plan(ctx: click.Context) -> None:
126+
"""Generates the production plan"""
127+
controller = ctx.obj["github"]
128+
controller.update_prod_plan_preview_check(status=GithubCheckStatus.IN_PROGRESS)
129+
_gen_prod_plan(controller)
130+
131+
132+
def _deploy_production(controller: GithubController) -> bool:
121133
controller.update_prod_environment_check(status=GithubCheckStatus.IN_PROGRESS)
122134
try:
123135
controller.deploy_to_prod()
124136
controller.update_prod_environment_check(
125137
status=GithubCheckStatus.COMPLETED, conclusion=GithubCheckConclusion.SUCCESS
126138
)
127-
if merge_method:
128-
controller.merge_pr(merge_method=merge_method)
129-
if delete_environment_after_deploy:
130-
controller.delete_pr_environment()
139+
controller.try_merge_pr()
140+
controller.try_invalidate_pr_environment()
131141
return True
132142
except PlanError:
133143
controller.update_prod_environment_check(
@@ -137,27 +147,22 @@ def _deploy_production(
137147

138148

139149
@github.command()
140-
@merge_method_option
141-
@delete_option
142150
@click.pass_context
143-
def deploy_production(
144-
ctx: click.Context, merge_method: t.Optional[MergeMethod], delete: bool
145-
) -> None:
151+
def deploy_production(ctx: click.Context) -> None:
146152
"""Deploys the production environment"""
147-
_deploy_production(
148-
ctx.obj["github"], merge_method=merge_method, delete_environment_after_deploy=delete
149-
)
153+
_deploy_production(ctx.obj["github"])
150154

151155

152-
def _run_all(
153-
controller: GithubController,
154-
merge_method: t.Optional[MergeMethod],
155-
delete: bool,
156-
command_namespace: t.Optional[str] = None,
157-
) -> None:
156+
def _run_all(controller: GithubController) -> None:
158157
has_required_approval = False
158+
is_auto_deploying_prod = (
159+
controller.deploy_command_enabled or controller.do_required_approval_check
160+
)
159161
if controller.is_comment_added:
160-
command = controller.get_command_from_comment(command_namespace)
162+
if not controller.deploy_command_enabled:
163+
# We aren't using commands so we can just return
164+
return
165+
command = controller.get_command_from_comment()
161166
if command.is_invalid:
162167
# Probably a comment unrelated to SQLMesh so we do nothing
163168
return
@@ -166,41 +171,70 @@ def _run_all(
166171
else:
167172
raise CICDBotError(f"Unsupported command: {command}")
168173
controller.update_pr_environment_check(status=GithubCheckStatus.QUEUED)
169-
controller.update_prod_environment_check(status=GithubCheckStatus.QUEUED)
174+
controller.update_prod_plan_preview_check(status=GithubCheckStatus.QUEUED)
170175
controller.update_test_check(status=GithubCheckStatus.QUEUED)
176+
if is_auto_deploying_prod:
177+
controller.update_prod_environment_check(status=GithubCheckStatus.QUEUED)
171178
tests_passed = _run_tests(controller)
172-
if not has_required_approval and controller.do_required_approval_check:
173-
controller.update_required_approval_check(status=GithubCheckStatus.QUEUED)
174-
has_required_approval = _check_required_approvers(controller)
175-
else:
176-
controller.update_required_approval_check(
177-
status=GithubCheckStatus.COMPLETED, conclusion=GithubCheckConclusion.SKIPPED
179+
if controller.do_required_approval_check:
180+
if has_required_approval:
181+
controller.update_required_approval_check(
182+
status=GithubCheckStatus.COMPLETED, conclusion=GithubCheckConclusion.SKIPPED
183+
)
184+
else:
185+
controller.update_required_approval_check(status=GithubCheckStatus.QUEUED)
186+
has_required_approval = _check_required_approvers(controller)
187+
if not tests_passed:
188+
controller.update_pr_environment_check(
189+
status=GithubCheckStatus.COMPLETED,
190+
exception=TestFailure(),
178191
)
179-
pr_environment_updated = _update_pr_environment(controller)
180-
if tests_passed and has_required_approval and pr_environment_updated:
181-
_deploy_production(
182-
controller, merge_method=merge_method, delete_environment_after_deploy=delete
192+
controller.update_prod_plan_preview_check(
193+
status=GithubCheckStatus.COMPLETED,
194+
conclusion=GithubCheckConclusion.SKIPPED,
195+
summary="Unit Test(s) Failed so skipping creating prod plan",
183196
)
197+
if is_auto_deploying_prod:
198+
controller.update_prod_environment_check(
199+
status=GithubCheckStatus.COMPLETED,
200+
conclusion=GithubCheckConclusion.SKIPPED,
201+
skip_reason="Unit Test(s) Failed so skipping deploying to production",
202+
)
203+
return
204+
pr_environment_updated = _update_pr_environment(controller)
205+
prod_plan_generated = False
206+
if pr_environment_updated:
207+
prod_plan_generated = _gen_prod_plan(controller)
184208
else:
185-
controller.update_prod_environment_check(
209+
controller.update_prod_plan_preview_check(
186210
status=GithubCheckStatus.COMPLETED, conclusion=GithubCheckConclusion.SKIPPED
187211
)
212+
if tests_passed and has_required_approval and pr_environment_updated and prod_plan_generated:
213+
_deploy_production(controller)
214+
elif is_auto_deploying_prod:
215+
if not has_required_approval:
216+
skip_reason = (
217+
"Skipped Deploying to Production because a required approver has not approved"
218+
)
219+
elif not pr_environment_updated:
220+
skip_reason = (
221+
"Skipped Deploying to Production because the PR environment was not updated"
222+
)
223+
elif not prod_plan_generated:
224+
skip_reason = (
225+
"Skipped Deploying to Production because the production plan could not be generated"
226+
)
227+
else:
228+
skip_reason = "Skipped Deploying to Production for an unknown reason"
229+
controller.update_prod_environment_check(
230+
status=GithubCheckStatus.COMPLETED,
231+
conclusion=GithubCheckConclusion.SKIPPED,
232+
skip_reason=skip_reason,
233+
)
188234

189235

190236
@github.command()
191-
@merge_method_option
192-
@delete_option
193-
@click.option(
194-
"--command_namespace",
195-
type=str,
196-
help="Namespace to use for SQLMesh commands. For example if you provide `#SQLMesh` as a value then commands will be expected in the format of `#SQLMesh/<command>`.",
197-
)
198237
@click.pass_context
199-
def run_all(
200-
ctx: click.Context,
201-
merge_method: t.Optional[MergeMethod],
202-
delete: bool,
203-
command_namespace: t.Optional[str],
204-
) -> None:
238+
def run_all(ctx: click.Context) -> None:
205239
"""Runs all the commands in the correct order."""
206-
return _run_all(ctx.obj["github"], merge_method, delete, command_namespace)
240+
return _run_all(ctx.obj["github"])

0 commit comments

Comments
 (0)