Skip to content

Commit 9030180

Browse files
committed
Feat(cicd_bot): Allow forward-only plans based on branch suffix
1 parent 606b740 commit 9030180

File tree

4 files changed

+130
-0
lines changed

4 files changed

+130
-0
lines changed

docs/integrations/github.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -300,6 +300,7 @@ Below is an example of how to define the default config for the bot in either YA
300300
| `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 |
301301
| `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 |
302302
| `prod_branch_name` | The name of the git branch associated with production. Ex: `prod`. Default: `main` or `master` is considered prod | str | N |
303+
| `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 |
303304

304305
Example with all properties defined:
305306

sqlmesh/integrations/github/cicd/config.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ class GithubCICDBotConfig(BaseConfig):
3333
pr_environment_name: t.Optional[str] = None
3434
pr_min_intervals: t.Optional[int] = None
3535
prod_branch_names_: t.Optional[str] = Field(default=None, alias="prod_branch_name")
36+
forward_only_branch_suffix_: t.Optional[str] = Field(
37+
default=None, alias="foward_only_branch_suffix"
38+
)
3639

3740
@model_validator(mode="before")
3841
@classmethod
@@ -73,6 +76,10 @@ def skip_pr_backfill(self) -> bool:
7376
return True
7477
return self.skip_pr_backfill_
7578

79+
@property
80+
def forward_only_branch_suffix(self) -> str:
81+
return self.forward_only_branch_suffix_ or "-forward-only"
82+
7683
FIELDS_FOR_ANALYTICS: t.ClassVar[t.Set[str]] = {
7784
"invalidate_environment_after_deploy",
7885
"enable_deploy_command",
@@ -83,4 +90,6 @@ def skip_pr_backfill(self) -> bool:
8390
"skip_pr_backfill",
8491
"pr_include_unmodified",
8592
"run_on_deploy_to_prod",
93+
"pr_min_intervals",
94+
"forward_only_branch_suffix",
8695
}

sqlmesh/integrations/github/cicd/controller.py

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -405,6 +405,7 @@ def pr_plan(self) -> Plan:
405405
min_intervals=self.bot_config.pr_min_intervals,
406406
skip_backfill=self.bot_config.skip_pr_backfill,
407407
include_unmodified=self.bot_config.pr_include_unmodified,
408+
forward_only=self.forward_only_plan,
408409
)
409410
assert self._pr_plan_builder
410411
return self._pr_plan_builder.build()
@@ -434,6 +435,7 @@ def prod_plan(self) -> Plan:
434435
skip_linter=True,
435436
categorizer_config=self.bot_config.auto_categorize_changes,
436437
run=self.bot_config.run_on_deploy_to_prod,
438+
forward_only=self.forward_only_plan,
437439
)
438440
assert self._prod_plan_builder
439441
return self._prod_plan_builder.build()
@@ -450,6 +452,7 @@ def prod_plan_with_gaps(self) -> Plan:
450452
skip_tests=True,
451453
skip_linter=True,
452454
run=self.bot_config.run_on_deploy_to_prod,
455+
forward_only=self.forward_only_plan,
453456
)
454457
assert self._prod_plan_with_gaps_builder
455458
return self._prod_plan_with_gaps_builder.build()
@@ -473,6 +476,13 @@ def removed_snapshots(self) -> t.Set[SnapshotId]:
473476
def pr_targets_prod_branch(self) -> bool:
474477
return self._pull_request.base.ref in self.bot_config.prod_branch_names
475478

479+
@property
480+
def forward_only_plan(self) -> bool:
481+
head_ref = self._pull_request.head.ref
482+
if isinstance(head_ref, str):
483+
return head_ref.endswith(self.bot_config.forward_only_branch_suffix)
484+
return False
485+
476486
@classmethod
477487
def _append_output(cls, key: str, value: str) -> None:
478488
"""
@@ -485,6 +495,26 @@ def _append_output(cls, key: str, value: str) -> None:
485495
with open(output_file, "a", encoding="utf-8") as fh:
486496
print(f"{key}={value}", file=fh)
487497

498+
def get_forward_only_plan_post_deployment_tip(self, plan: Plan) -> str:
499+
if not plan.forward_only:
500+
return ""
501+
502+
example_model_name = "<model name>"
503+
for snapshot_id in sorted(plan.snapshots):
504+
snapshot = plan.snapshots[snapshot_id]
505+
if snapshot.is_incremental:
506+
example_model_name = snapshot.node.name
507+
break
508+
509+
return (
510+
"> [!TIP]\n"
511+
"> 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"
512+
"> \n"
513+
f"> `$ sqlmesh plan --restate-model {example_model_name} --start YYYY-MM-dd --end YYYY-MM-DD`\n"
514+
">\n"
515+
"> Learn more: https://sqlmesh.readthedocs.io/en/stable/concepts/plans/?h=restate#restatement-plans"
516+
)
517+
488518
def get_plan_summary(self, plan: Plan) -> str:
489519
# use Verbosity.VERY_VERBOSE to prevent the list of models from being truncated
490520
# this is particularly important for the "Models needing backfill" list because
@@ -753,6 +783,11 @@ def deploy_to_prod(self) -> None:
753783
</details>
754784
755785
"""
786+
if self.forward_only_plan:
787+
plan_summary = (
788+
f"{self.get_forward_only_plan_post_deployment_tip(self.prod_plan)}\n{plan_summary}"
789+
)
790+
756791
self.update_sqlmesh_comment_info(
757792
value=plan_summary,
758793
dedup_regex=None,
@@ -1095,6 +1130,7 @@ def conclusion_handler(
10951130
summary = "Got an action required conclusion but no plan error was provided. This is unexpected."
10961131
else:
10971132
summary = "**Generated Prod Plan**\n" + self.get_plan_summary(self.prod_plan)
1133+
10981134
return conclusion, title, summary
10991135

11001136
self._update_check_handler(

tests/integrations/github/cicd/test_github_commands.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
# type: ignore
2+
import typing as t
23
import os
34
import pathlib
45
from unittest import TestCase, mock
@@ -14,6 +15,7 @@
1415
from sqlmesh.integrations.github.cicd import command
1516
from sqlmesh.integrations.github.cicd.config import GithubCICDBotConfig, MergeMethod
1617
from sqlmesh.integrations.github.cicd.controller import (
18+
GithubController,
1719
GithubCheckConclusion,
1820
GithubCheckStatus,
1921
)
@@ -1152,6 +1154,7 @@ def test_comment_command_deploy_prod(
11521154
User(username="test", github_username="test_github", roles=[UserRole.REQUIRED_APPROVER])
11531155
]
11541156
controller._context.invalidate_environment = mocker.MagicMock()
1157+
assert not controller.forward_only_plan
11551158

11561159
github_output_file = tmp_path / "github_output.txt"
11571160

@@ -1366,3 +1369,84 @@ def test_comment_command_deploy_prod_no_deploy_detected_yet(
13661369

13671370
# required approvers are irrelevant because /deploy command is enabled
13681371
assert "SQLMesh - Has Required Approval" not in controller._check_run_mapping
1372+
1373+
1374+
def test_deploy_prod_forward_only(
1375+
github_client,
1376+
make_controller: t.Callable[..., GithubController],
1377+
make_mock_check_run,
1378+
make_mock_issue_comment,
1379+
tmp_path: pathlib.Path,
1380+
mocker: MockerFixture,
1381+
):
1382+
"""
1383+
Scenario:
1384+
- PR is created with a branch name indicating that plans should be forward-only
1385+
- PR is not merged
1386+
- Tests passed
1387+
- PR Merge Method defined
1388+
- Deploy command has been triggered
1389+
1390+
Outcome:
1391+
- "Prod Environment Synced" step should show a tip explaining how to retroactively apply forward-only changes to old data
1392+
- Bot Comment should show the same tip
1393+
"""
1394+
mock_repo = github_client.get_repo()
1395+
mock_repo.create_check_run = mocker.MagicMock(
1396+
side_effect=lambda **kwargs: make_mock_check_run(**kwargs)
1397+
)
1398+
1399+
created_comments = []
1400+
mock_issue = mock_repo.get_issue()
1401+
mock_issue.create_comment = mocker.MagicMock(
1402+
side_effect=lambda comment: make_mock_issue_comment(
1403+
comment=comment, created_comments=created_comments
1404+
)
1405+
)
1406+
mock_issue.get_comments = mocker.MagicMock(side_effect=lambda: created_comments)
1407+
1408+
mock_pull_request = mock_repo.get_pull()
1409+
mock_pull_request.get_reviews = mocker.MagicMock(lambda: [])
1410+
mock_pull_request.merged = False
1411+
mock_pull_request.merge = mocker.MagicMock()
1412+
mock_pull_request.head.ref = "unit-test-forward-only"
1413+
1414+
controller = make_controller(
1415+
"tests/fixtures/github/pull_request_synchronized.json",
1416+
github_client,
1417+
bot_config=GithubCICDBotConfig(
1418+
merge_method=MergeMethod.SQUASH,
1419+
enable_deploy_command=True,
1420+
foward_only_branch_suffix="-forward-only",
1421+
),
1422+
mock_out_context=False,
1423+
)
1424+
1425+
# create existing prod to apply against
1426+
controller._context.plan(auto_apply=True)
1427+
1428+
github_output_file = tmp_path / "github_output.txt"
1429+
1430+
# then, run a deploy with forward_only set
1431+
assert controller.forward_only_plan
1432+
with mock.patch.dict(os.environ, {"GITHUB_OUTPUT": str(github_output_file)}):
1433+
command._deploy_production(controller)
1434+
1435+
# Prod Environment Synced step should be successful
1436+
assert "SQLMesh - Prod Environment Synced" in controller._check_run_mapping
1437+
prod_checks_runs = controller._check_run_mapping["SQLMesh - Prod Environment Synced"].all_kwargs
1438+
assert len(prod_checks_runs) == 2
1439+
assert GithubCheckStatus(prod_checks_runs[0]["status"]).is_in_progress
1440+
assert GithubCheckStatus(prod_checks_runs[1]["status"]).is_completed
1441+
assert prod_checks_runs[1]["output"]["title"] == "Deployed to Prod"
1442+
assert GithubCheckConclusion(prod_checks_runs[1]["conclusion"]).is_success
1443+
1444+
# PR comment should be updated with forward-only tip
1445+
assert len(created_comments) == 1
1446+
assert (
1447+
"""> [!TIP]
1448+
> 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:
1449+
>
1450+
> `$ sqlmesh plan --restate-model sushi.customer_revenue_by_day --start YYYY-MM-dd --end YYYY-MM-DD`"""
1451+
in created_comments[0].body
1452+
)

0 commit comments

Comments
 (0)