Edit on GitHub

sqlmesh.integrations.github.cicd.command

  1from __future__ import annotations
  2
  3import logging
  4import traceback
  5
  6import click
  7
  8from sqlmesh.core.analytics import cli_analytics
  9from sqlmesh.core.console import set_console, MarkdownConsole
 10from sqlmesh.integrations.github.cicd.controller import (
 11    GithubCheckConclusion,
 12    GithubCheckStatus,
 13    GithubController,
 14    TestFailure,
 15)
 16from sqlmesh.utils.errors import CICDBotError, ConflictingPlanError, PlanError, LinterError
 17
 18logger = logging.getLogger(__name__)
 19
 20
 21@click.group(no_args_is_help=True)
 22@click.option(
 23    "--token",
 24    type=str,
 25    envvar="GITHUB_TOKEN",
 26    help="The Github Token to be used. Pass in `${{ secrets.GITHUB_TOKEN }}` if you want to use the one created by Github actions",
 27)
 28@click.option(
 29    "--full-logs",
 30    is_flag=True,
 31    help="Whether to print all logs in the Github Actions output or only in their relevant GA check",
 32)
 33@click.pass_context
 34def github(ctx: click.Context, token: str, full_logs: bool = False) -> None:
 35    """Github Action CI/CD Bot. See https://sqlmesh.readthedocs.io/en/stable/integrations/github/ for details"""
 36    # set a larger width because if none is specified, it auto-detects 80 characters when running in GitHub Actions
 37    # which can result in surprise newlines when outputting dates to backfill
 38    set_console(
 39        MarkdownConsole(
 40            width=1000, warning_capture_only=not full_logs, error_capture_only=not full_logs
 41        )
 42    )
 43    ctx.obj["github"] = GithubController(
 44        paths=ctx.obj["paths"],
 45        token=token,
 46        config=ctx.obj["config"],
 47    )
 48
 49
 50def _check_required_approvers(controller: GithubController) -> bool:
 51    controller.update_required_approval_check(status=GithubCheckStatus.IN_PROGRESS)
 52    if controller.has_required_approval:
 53        controller.update_required_approval_check(
 54            status=GithubCheckStatus.COMPLETED, conclusion=GithubCheckConclusion.SUCCESS
 55        )
 56        return True
 57    controller.update_required_approval_check(
 58        status=GithubCheckStatus.COMPLETED, conclusion=GithubCheckConclusion.FAILURE
 59    )
 60    return False
 61
 62
 63@github.command()
 64@click.pass_context
 65@cli_analytics
 66def check_required_approvers(ctx: click.Context) -> None:
 67    """Checks if a required approver has provided approval on the PR."""
 68    if not _check_required_approvers(ctx.obj["github"]):
 69        raise CICDBotError(
 70            "Required approver has not approved the PR. See Pull Requests Checks for more information."
 71        )
 72
 73
 74def _run_tests(controller: GithubController) -> bool:
 75    controller.update_test_check(status=GithubCheckStatus.IN_PROGRESS)
 76    try:
 77        result, _ = controller.run_tests()
 78        controller.update_test_check(
 79            status=GithubCheckStatus.COMPLETED,
 80            # Conclusion will be updated with final status based on test results
 81            conclusion=GithubCheckConclusion.NEUTRAL,
 82            result=result,
 83        )
 84        return result.wasSuccessful()
 85    except Exception:
 86        controller.update_test_check(
 87            status=GithubCheckStatus.COMPLETED,
 88            conclusion=GithubCheckConclusion.FAILURE,
 89            traceback=traceback.format_exc(),
 90        )
 91        return False
 92
 93
 94def _run_linter(controller: GithubController) -> bool:
 95    controller.update_linter_check(status=GithubCheckStatus.IN_PROGRESS)
 96    try:
 97        controller.run_linter()
 98    except LinterError:
 99        controller.update_linter_check(
100            status=GithubCheckStatus.COMPLETED,
101            conclusion=GithubCheckConclusion.FAILURE,
102        )
103        return False
104
105    controller.update_linter_check(
106        status=GithubCheckStatus.COMPLETED,
107        conclusion=GithubCheckConclusion.SUCCESS,
108    )
109
110    return True
111
112
113@github.command()
114@click.pass_context
115@cli_analytics
116def run_tests(ctx: click.Context) -> None:
117    """Runs the unit tests"""
118    if not _run_tests(ctx.obj["github"]):
119        raise CICDBotError("Failed to run tests. See Pull Requests Checks for more information.")
120
121
122def _update_pr_environment(controller: GithubController) -> bool:
123    controller.update_pr_environment_check(status=GithubCheckStatus.IN_PROGRESS)
124    try:
125        controller.update_pr_environment()
126        conclusion = controller.update_pr_environment_check(status=GithubCheckStatus.COMPLETED)
127        return conclusion is not None and conclusion.is_success
128    except Exception as e:
129        logger.exception("Error occurred when updating PR environment")
130        conclusion = controller.update_pr_environment_check(
131            status=GithubCheckStatus.COMPLETED, exception=e
132        )
133        return (
134            conclusion is not None
135            and not conclusion.is_failure
136            and not conclusion.is_action_required
137        )
138
139
140@github.command()
141@click.pass_context
142@cli_analytics
143def update_pr_environment(ctx: click.Context) -> None:
144    """Creates or updates the PR environments"""
145    if not _update_pr_environment(ctx.obj["github"]):
146        raise CICDBotError(
147            "Failed to update PR environment. See Pull Requests Checks for more information."
148        )
149
150
151def _gen_prod_plan(controller: GithubController) -> bool:
152    controller.update_prod_plan_preview_check(status=GithubCheckStatus.IN_PROGRESS)
153    try:
154        plan_summary = controller.get_plan_summary(controller.prod_plan)
155        controller.update_prod_plan_preview_check(
156            status=GithubCheckStatus.COMPLETED,
157            conclusion=GithubCheckConclusion.SUCCESS,
158            summary=plan_summary,
159        )
160        return bool(plan_summary)
161    except Exception as e:
162        logger.exception("Error occurred generating prod plan")
163        controller.update_prod_plan_preview_check(
164            status=GithubCheckStatus.COMPLETED,
165            conclusion=GithubCheckConclusion.FAILURE,
166            summary=str(e),
167        )
168        return False
169
170
171@github.command()
172@click.pass_context
173@cli_analytics
174def gen_prod_plan(ctx: click.Context) -> None:
175    """Generates the production plan"""
176    controller = ctx.obj["github"]
177    controller.update_prod_plan_preview_check(status=GithubCheckStatus.IN_PROGRESS)
178    if not _gen_prod_plan(controller):
179        raise CICDBotError(
180            "Failed to generate production plan. See Pull Requests Checks for more information."
181        )
182
183
184def _deploy_production(controller: GithubController) -> bool:
185    controller.update_prod_environment_check(status=GithubCheckStatus.IN_PROGRESS)
186    try:
187        controller.deploy_to_prod()
188        controller.update_prod_environment_check(
189            status=GithubCheckStatus.COMPLETED, conclusion=GithubCheckConclusion.SUCCESS
190        )
191        controller.try_merge_pr()
192        controller.try_invalidate_pr_environment()
193        return True
194    except ConflictingPlanError as e:
195        controller.update_prod_environment_check(
196            status=GithubCheckStatus.COMPLETED,
197            conclusion=GithubCheckConclusion.SKIPPED,
198            skip_reason=str(e),
199        )
200        return False
201    except PlanError as e:
202        controller.update_prod_environment_check(
203            status=GithubCheckStatus.COMPLETED,
204            conclusion=GithubCheckConclusion.ACTION_REQUIRED,
205            plan_error=e,
206        )
207        return False
208    except Exception:
209        controller.update_prod_environment_check(
210            status=GithubCheckStatus.COMPLETED, conclusion=GithubCheckConclusion.FAILURE
211        )
212        return False
213
214
215@github.command()
216@click.pass_context
217@cli_analytics
218def deploy_production(ctx: click.Context) -> None:
219    """Deploys the production environment"""
220    if not _deploy_production(ctx.obj["github"]):
221        raise CICDBotError(
222            "Failed to deploy to production. See Pull Requests Checks for more information."
223        )
224
225
226def _run_all(controller: GithubController) -> None:
227    click.echo(f"SQLMesh Version: {controller.version_info}")
228
229    has_required_approval = False
230    is_auto_deploying_prod = (
231        controller.deploy_command_enabled or controller.do_required_approval_check
232    ) and controller.pr_targets_prod_branch
233    if controller.is_comment_added:
234        if not controller.deploy_command_enabled:
235            # We aren't using commands so we can just return
236            return
237        command = controller.get_command_from_comment()
238        if command.is_invalid:
239            # Probably a comment unrelated to SQLMesh so we do nothing
240            return
241        if command.is_deploy_prod:
242            has_required_approval = True
243        else:
244            raise CICDBotError(f"Unsupported command: {command}")
245    controller.update_linter_check(status=GithubCheckStatus.QUEUED)
246    controller.update_pr_environment_check(status=GithubCheckStatus.QUEUED)
247    controller.update_prod_plan_preview_check(status=GithubCheckStatus.QUEUED)
248    controller.update_test_check(status=GithubCheckStatus.QUEUED)
249    if is_auto_deploying_prod:
250        controller.update_prod_environment_check(status=GithubCheckStatus.QUEUED)
251    linter_passed = _run_linter(controller)
252    tests_passed = _run_tests(controller)
253    if controller.do_required_approval_check:
254        if has_required_approval:
255            controller.update_required_approval_check(
256                status=GithubCheckStatus.COMPLETED, conclusion=GithubCheckConclusion.SKIPPED
257            )
258        else:
259            controller.update_required_approval_check(status=GithubCheckStatus.QUEUED)
260            has_required_approval = _check_required_approvers(controller)
261    if not tests_passed or not linter_passed:
262        controller.update_pr_environment_check(
263            status=GithubCheckStatus.COMPLETED,
264            exception=LinterError("") if not linter_passed else TestFailure(),
265        )
266        controller.update_prod_plan_preview_check(
267            status=GithubCheckStatus.COMPLETED,
268            conclusion=GithubCheckConclusion.SKIPPED,
269            summary="Linter or Unit Test(s) failed so skipping creating prod plan",
270        )
271        if is_auto_deploying_prod:
272            controller.update_prod_environment_check(
273                status=GithubCheckStatus.COMPLETED,
274                conclusion=GithubCheckConclusion.SKIPPED,
275                skip_reason="Linter or Unit Test(s) failed so skipping deploying to production",
276            )
277
278        raise CICDBotError(
279            "Linter or Unit Test(s) failed. See Pull Requests Checks for more information."
280        )
281
282    pr_environment_updated = _update_pr_environment(controller)
283    prod_plan_generated = False
284    if pr_environment_updated:
285        prod_plan_generated = _gen_prod_plan(controller)
286    else:
287        controller.update_prod_plan_preview_check(
288            status=GithubCheckStatus.COMPLETED, conclusion=GithubCheckConclusion.SKIPPED
289        )
290    deployed_to_prod = False
291    if has_required_approval and prod_plan_generated and controller.pr_targets_prod_branch:
292        deployed_to_prod = _deploy_production(controller)
293    elif is_auto_deploying_prod:
294        if controller.deploy_command_enabled and not has_required_approval:
295            skip_reason = "Skipped Deploying to Production because a `/deploy` command has not been detected yet"
296        elif controller.do_required_approval_check and not has_required_approval:
297            skip_reason = (
298                "Skipped Deploying to Production because a required approver has not approved"
299            )
300        elif not pr_environment_updated:
301            skip_reason = (
302                "Skipped Deploying to Production because the PR environment was not updated"
303            )
304        elif not prod_plan_generated:
305            skip_reason = (
306                "Skipped Deploying to Production because the production plan could not be generated"
307            )
308        else:
309            skip_reason = "Skipped Deploying to Production for an unknown reason"
310        controller.update_prod_environment_check(
311            status=GithubCheckStatus.COMPLETED,
312            conclusion=GithubCheckConclusion.SKIPPED,
313            skip_reason=skip_reason,
314        )
315    if (
316        not pr_environment_updated
317        or not prod_plan_generated
318        or (has_required_approval and controller.pr_targets_prod_branch and not deployed_to_prod)
319    ):
320        raise CICDBotError(
321            "A step of the run-all check failed. See Pull Requests Checks for more information."
322        )
323
324
325@github.command()
326@click.pass_context
327@cli_analytics
328def run_all(ctx: click.Context) -> None:
329    """Runs all the commands in the correct order."""
330    return _run_all(ctx.obj["github"])
logger = <Logger sqlmesh.integrations.github.cicd.command (WARNING)>
github = <Group github>

Github Action CI/CD Bot. See https://sqlmesh.readthedocs.io/en/stable/integrations/github/ for details

check_required_approvers = <Command check-required-approvers>

Checks if a required approver has provided approval on the PR.

run_tests = <Command run-tests>

Runs the unit tests

update_pr_environment = <Command update-pr-environment>

Creates or updates the PR environments

gen_prod_plan = <Command gen-prod-plan>

Generates the production plan

deploy_production = <Command deploy-production>

Deploys the production environment

run_all = <Command run-all>

Runs all the commands in the correct order.