From 26e8eb1f6f013343d483d4f195dd29d337df0947 Mon Sep 17 00:00:00 2001 From: Erin Drummond Date: Fri, 11 Jul 2025 00:08:49 +0000 Subject: [PATCH 1/3] Feat(cicd_bot): Allow forward-only plans based on branch suffix --- docs/integrations/github.md | 1 + sqlmesh/integrations/github/cicd/config.py | 9 ++ .../integrations/github/cicd/controller.py | 36 ++++++++ .../github/cicd/test_github_commands.py | 84 +++++++++++++++++++ 4 files changed, 130 insertions(+) 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..7a93b2857d 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="foward_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..c076f20158 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..e81b0161b9 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, + foward_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 + ) From 26ef4825fbdb35c63826f6462a2f2e2c4e1cc2be Mon Sep 17 00:00:00 2001 From: Erin Drummond Date: Sun, 13 Jul 2025 20:35:36 +0000 Subject: [PATCH 2/3] PR feedback --- sqlmesh/integrations/github/cicd/controller.py | 2 +- tests/integrations/github/cicd/test_github_commands.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sqlmesh/integrations/github/cicd/controller.py b/sqlmesh/integrations/github/cicd/controller.py index c076f20158..29de4658d3 100644 --- a/sqlmesh/integrations/github/cicd/controller.py +++ b/sqlmesh/integrations/github/cicd/controller.py @@ -510,7 +510,7 @@ def get_forward_only_plan_post_deployment_tip(self, plan: Plan) -> str: "> [!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" + 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" ) diff --git a/tests/integrations/github/cicd/test_github_commands.py b/tests/integrations/github/cicd/test_github_commands.py index e81b0161b9..aba1b035f9 100644 --- a/tests/integrations/github/cicd/test_github_commands.py +++ b/tests/integrations/github/cicd/test_github_commands.py @@ -1447,6 +1447,6 @@ def test_deploy_prod_forward_only( """> [!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`""" +> `$ sqlmesh plan --restate-model sushi.customer_revenue_by_day --start YYYY-MM-DD --end YYYY-MM-DD`""" in created_comments[0].body ) From 549036c9fed1eb312bcd4bdc9dce0fb266c388e6 Mon Sep 17 00:00:00 2001 From: Erin Drummond Date: Mon, 14 Jul 2025 21:01:02 +0000 Subject: [PATCH 3/3] Fix typo --- sqlmesh/integrations/github/cicd/config.py | 2 +- tests/integrations/github/cicd/test_github_commands.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/sqlmesh/integrations/github/cicd/config.py b/sqlmesh/integrations/github/cicd/config.py index 7a93b2857d..a287bf1af5 100644 --- a/sqlmesh/integrations/github/cicd/config.py +++ b/sqlmesh/integrations/github/cicd/config.py @@ -34,7 +34,7 @@ class GithubCICDBotConfig(BaseConfig): 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="foward_only_branch_suffix" + default=None, alias="forward_only_branch_suffix" ) @model_validator(mode="before") diff --git a/tests/integrations/github/cicd/test_github_commands.py b/tests/integrations/github/cicd/test_github_commands.py index aba1b035f9..6d82755934 100644 --- a/tests/integrations/github/cicd/test_github_commands.py +++ b/tests/integrations/github/cicd/test_github_commands.py @@ -1417,7 +1417,7 @@ def test_deploy_prod_forward_only( bot_config=GithubCICDBotConfig( merge_method=MergeMethod.SQUASH, enable_deploy_command=True, - foward_only_branch_suffix="-forward-only", + forward_only_branch_suffix="-forward-only", ), mock_out_context=False, )