Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/integrations/github.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
9 changes: 9 additions & 0 deletions sqlmesh/integrations/github/cicd/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand All @@ -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",
}
36 changes: 36 additions & 0 deletions sqlmesh/integrations/github/cicd/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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()
Expand All @@ -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()
Expand All @@ -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:
"""
Expand All @@ -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 = "<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
Expand Down Expand Up @@ -754,6 +784,11 @@ def deploy_to_prod(self) -> None:
</details>

"""
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,
Expand Down Expand Up @@ -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(
Expand Down
84 changes: 84 additions & 0 deletions tests/integrations/github/cicd/test_github_commands.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# type: ignore
import typing as t
import os
import pathlib
from unittest import TestCase, mock
Expand All @@ -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,
)
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -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
)