diff --git a/docs/integrations/github.md b/docs/integrations/github.md index 1f66ef6368..a11d90d044 100644 --- a/docs/integrations/github.md +++ b/docs/integrations/github.md @@ -300,6 +300,7 @@ Below is an example of how to define the default config for the bot in either YA | `run_on_deploy_to_prod` | Indicates whether to run latest intervals when deploying to prod. If set to false, the deployment will backfill only the changed models up to the existing latest interval in production, ignoring any missing intervals beyond this point. Default: `False` | bool | N | | `pr_environment_name` | The name of the PR environment to create for which a PR number will be appended to. Defaults to the repo name if not provided. Note: The name will be normalized to alphanumeric + underscore and lowercase. | str | N | | `prod_branch_name` | The name of the git branch associated with production. Ex: `prod`. Default: `main` or `master` is considered prod | str | N | +| `forward_only_branch_suffix` | If the git branch has this suffix, trigger a [forward-only](../concepts/plans.md#forward-only-plans) plan instead of a normal plan. Default: `-forward-only` | str | N | Example with all properties defined: diff --git a/sqlmesh/integrations/github/cicd/config.py b/sqlmesh/integrations/github/cicd/config.py index 8f84db47c8..a287bf1af5 100644 --- a/sqlmesh/integrations/github/cicd/config.py +++ b/sqlmesh/integrations/github/cicd/config.py @@ -33,6 +33,9 @@ class GithubCICDBotConfig(BaseConfig): pr_environment_name: t.Optional[str] = None pr_min_intervals: t.Optional[int] = None prod_branch_names_: t.Optional[str] = Field(default=None, alias="prod_branch_name") + forward_only_branch_suffix_: t.Optional[str] = Field( + default=None, alias="forward_only_branch_suffix" + ) @model_validator(mode="before") @classmethod @@ -73,6 +76,10 @@ def skip_pr_backfill(self) -> bool: return True return self.skip_pr_backfill_ + @property + def forward_only_branch_suffix(self) -> str: + return self.forward_only_branch_suffix_ or "-forward-only" + FIELDS_FOR_ANALYTICS: t.ClassVar[t.Set[str]] = { "invalidate_environment_after_deploy", "enable_deploy_command", @@ -83,4 +90,6 @@ def skip_pr_backfill(self) -> bool: "skip_pr_backfill", "pr_include_unmodified", "run_on_deploy_to_prod", + "pr_min_intervals", + "forward_only_branch_suffix", } diff --git a/sqlmesh/integrations/github/cicd/controller.py b/sqlmesh/integrations/github/cicd/controller.py index 56a3c1ab20..29de4658d3 100644 --- a/sqlmesh/integrations/github/cicd/controller.py +++ b/sqlmesh/integrations/github/cicd/controller.py @@ -405,6 +405,7 @@ def pr_plan(self) -> Plan: min_intervals=self.bot_config.pr_min_intervals, skip_backfill=self.bot_config.skip_pr_backfill, include_unmodified=self.bot_config.pr_include_unmodified, + forward_only=self.forward_only_plan, ) assert self._pr_plan_builder return self._pr_plan_builder.build() @@ -434,6 +435,7 @@ def prod_plan(self) -> Plan: skip_linter=True, categorizer_config=self.bot_config.auto_categorize_changes, run=self.bot_config.run_on_deploy_to_prod, + forward_only=self.forward_only_plan, ) assert self._prod_plan_builder return self._prod_plan_builder.build() @@ -450,6 +452,7 @@ def prod_plan_with_gaps(self) -> Plan: skip_tests=True, skip_linter=True, run=self.bot_config.run_on_deploy_to_prod, + forward_only=self.forward_only_plan, ) assert self._prod_plan_with_gaps_builder return self._prod_plan_with_gaps_builder.build() @@ -473,6 +476,13 @@ def removed_snapshots(self) -> t.Set[SnapshotId]: def pr_targets_prod_branch(self) -> bool: return self._pull_request.base.ref in self.bot_config.prod_branch_names + @property + def forward_only_plan(self) -> bool: + head_ref = self._pull_request.head.ref + if isinstance(head_ref, str): + return head_ref.endswith(self.bot_config.forward_only_branch_suffix) + return False + @classmethod def _append_output(cls, key: str, value: str) -> None: """ @@ -485,6 +495,26 @@ def _append_output(cls, key: str, value: str) -> None: with open(output_file, "a", encoding="utf-8") as fh: print(f"{key}={value}", file=fh) + def get_forward_only_plan_post_deployment_tip(self, plan: Plan) -> str: + if not plan.forward_only: + return "" + + example_model_name = "" + for snapshot_id in sorted(plan.snapshots): + snapshot = plan.snapshots[snapshot_id] + if snapshot.is_incremental: + example_model_name = snapshot.node.name + break + + return ( + "> [!TIP]\n" + "> In order to see this forward-only plan retroactively apply to historical intervals on the production model, run the below for date ranges in scope:\n" + "> \n" + f"> `$ sqlmesh plan --restate-model {example_model_name} --start YYYY-MM-DD --end YYYY-MM-DD`\n" + ">\n" + "> Learn more: https://sqlmesh.readthedocs.io/en/stable/concepts/plans/?h=restate#restatement-plans" + ) + def get_plan_summary(self, plan: Plan) -> str: # use Verbosity.VERY_VERBOSE to prevent the list of models from being truncated # this is particularly important for the "Models needing backfill" list because @@ -754,6 +784,11 @@ def deploy_to_prod(self) -> None: """ + if self.forward_only_plan: + plan_summary = ( + f"{self.get_forward_only_plan_post_deployment_tip(self.prod_plan)}\n{plan_summary}" + ) + self.update_sqlmesh_comment_info( value=plan_summary, dedup_regex=None, @@ -1096,6 +1131,7 @@ def conclusion_handler( summary = "Got an action required conclusion but no plan error was provided. This is unexpected." else: summary = "**Generated Prod Plan**\n" + self.get_plan_summary(self.prod_plan) + return conclusion, title, summary self._update_check_handler( diff --git a/tests/integrations/github/cicd/test_github_commands.py b/tests/integrations/github/cicd/test_github_commands.py index 6be6a4557a..6d82755934 100644 --- a/tests/integrations/github/cicd/test_github_commands.py +++ b/tests/integrations/github/cicd/test_github_commands.py @@ -1,4 +1,5 @@ # type: ignore +import typing as t import os import pathlib from unittest import TestCase, mock @@ -14,6 +15,7 @@ from sqlmesh.integrations.github.cicd import command from sqlmesh.integrations.github.cicd.config import GithubCICDBotConfig, MergeMethod from sqlmesh.integrations.github.cicd.controller import ( + GithubController, GithubCheckConclusion, GithubCheckStatus, ) @@ -1152,6 +1154,7 @@ def test_comment_command_deploy_prod( User(username="test", github_username="test_github", roles=[UserRole.REQUIRED_APPROVER]) ] controller._context.invalidate_environment = mocker.MagicMock() + assert not controller.forward_only_plan github_output_file = tmp_path / "github_output.txt" @@ -1366,3 +1369,84 @@ def test_comment_command_deploy_prod_no_deploy_detected_yet( # required approvers are irrelevant because /deploy command is enabled assert "SQLMesh - Has Required Approval" not in controller._check_run_mapping + + +def test_deploy_prod_forward_only( + github_client, + make_controller: t.Callable[..., GithubController], + make_mock_check_run, + make_mock_issue_comment, + tmp_path: pathlib.Path, + mocker: MockerFixture, +): + """ + Scenario: + - PR is created with a branch name indicating that plans should be forward-only + - PR is not merged + - Tests passed + - PR Merge Method defined + - Deploy command has been triggered + + Outcome: + - "Prod Environment Synced" step should show a tip explaining how to retroactively apply forward-only changes to old data + - Bot Comment should show the same tip + """ + mock_repo = github_client.get_repo() + mock_repo.create_check_run = mocker.MagicMock( + side_effect=lambda **kwargs: make_mock_check_run(**kwargs) + ) + + created_comments = [] + mock_issue = mock_repo.get_issue() + mock_issue.create_comment = mocker.MagicMock( + side_effect=lambda comment: make_mock_issue_comment( + comment=comment, created_comments=created_comments + ) + ) + mock_issue.get_comments = mocker.MagicMock(side_effect=lambda: created_comments) + + mock_pull_request = mock_repo.get_pull() + mock_pull_request.get_reviews = mocker.MagicMock(lambda: []) + mock_pull_request.merged = False + mock_pull_request.merge = mocker.MagicMock() + mock_pull_request.head.ref = "unit-test-forward-only" + + controller = make_controller( + "tests/fixtures/github/pull_request_synchronized.json", + github_client, + bot_config=GithubCICDBotConfig( + merge_method=MergeMethod.SQUASH, + enable_deploy_command=True, + forward_only_branch_suffix="-forward-only", + ), + mock_out_context=False, + ) + + # create existing prod to apply against + controller._context.plan(auto_apply=True) + + github_output_file = tmp_path / "github_output.txt" + + # then, run a deploy with forward_only set + assert controller.forward_only_plan + with mock.patch.dict(os.environ, {"GITHUB_OUTPUT": str(github_output_file)}): + command._deploy_production(controller) + + # Prod Environment Synced step should be successful + assert "SQLMesh - Prod Environment Synced" in controller._check_run_mapping + prod_checks_runs = controller._check_run_mapping["SQLMesh - Prod Environment Synced"].all_kwargs + assert len(prod_checks_runs) == 2 + assert GithubCheckStatus(prod_checks_runs[0]["status"]).is_in_progress + assert GithubCheckStatus(prod_checks_runs[1]["status"]).is_completed + assert prod_checks_runs[1]["output"]["title"] == "Deployed to Prod" + assert GithubCheckConclusion(prod_checks_runs[1]["conclusion"]).is_success + + # PR comment should be updated with forward-only tip + assert len(created_comments) == 1 + assert ( + """> [!TIP] +> In order to see this forward-only plan retroactively apply to historical intervals on the production model, run the below for date ranges in scope: +> +> `$ sqlmesh plan --restate-model sushi.customer_revenue_by_day --start YYYY-MM-DD --end YYYY-MM-DD`""" + in created_comments[0].body + )