From 14f8018c7479734087307e011d0b082b9c8ebb5d Mon Sep 17 00:00:00 2001 From: mday-io Date: Thu, 9 Jul 2026 08:48:22 -0700 Subject: [PATCH] feat: link Tobiko Cloud plans from GitHub CICD bot Signed-off-by: mday-io --- docs/integrations/github.md | 2 + .../integrations/github/cicd/controller.py | 66 ++- .../github/cicd/test_github_controller.py | 385 +++++++++++++++++- 3 files changed, 446 insertions(+), 7 deletions(-) diff --git a/docs/integrations/github.md b/docs/integrations/github.md index 328acd0d17..9dff4c5207 100644 --- a/docs/integrations/github.md +++ b/docs/integrations/github.md @@ -59,6 +59,8 @@ jobs: Next checkout the [Core Bot Behavior Configuration Guide](#core-bot-behavior-configuration-guide) to see how to configure the bot's core behavior. Then checkout [Bot Configuration](#bot-configuration) to see how to configure the bot's behavior in general. Finally, checkout [Custom Workflow Configuration](#custom-workflow-configuration) to see the full set of customizations available to the bot. +If the workflow runs SQLMesh through Tobiko Cloud, set `TCLOUD_URL` or `TCLOUD_BASE_URL` in the workflow environment. When one of these variables is present, the bot adds or updates pull request comments with links to Tobiko Cloud plans for PR environments and production deploys. + ## Core Bot Behavior Configuration Guide There are two fundamental ways the bot can be configured: synchronized or desynchronized production code and data. diff --git a/sqlmesh/integrations/github/cicd/controller.py b/sqlmesh/integrations/github/cicd/controller.py index c693d09560..ae7c035b01 100644 --- a/sqlmesh/integrations/github/cicd/controller.py +++ b/sqlmesh/integrations/github/cicd/controller.py @@ -56,6 +56,9 @@ logger = logging.getLogger(__name__) +PR_ENVIRONMENT_PLAN_LINK_COMMENT_MARKER = "" +PROD_PLAN_LINK_COMMENT_MARKER = "" + class TestFailure(Exception): pass @@ -678,16 +681,19 @@ def run_linter(self) -> None: self._context.lint_models() def _get_or_create_comment(self, header: str = BOT_HEADER_MSG) -> IssueComment: - comment = seq_get( - [comment for comment in self._issue.get_comments() if header in comment.body], - 0, - ) + comment = self._get_comment(header=header) if not comment: logger.debug(f"Did not find comment so creating one with header: {header}") return self._issue.create_comment(header) logger.debug(f"Found comment with header: {header}") return comment + def _get_comment(self, header: str) -> t.Optional[IssueComment]: + return seq_get( + [comment for comment in self._issue.get_comments() if header in comment.body], + 0, + ) + def _get_merge_state_status(self) -> MergeStateStatus: """ This feature is currently in preview and therefore not available in the python module. @@ -741,13 +747,59 @@ def update_sqlmesh_comment_info( comment.edit(body=body) return True, comment + def update_pr_environment_plan_comment(self, plan: Plan) -> None: + self._update_tobiko_cloud_plan_comment( + plan=plan, + environment=self.pr_environment_name, + marker=PR_ENVIRONMENT_PLAN_LINK_COMMENT_MARKER, + description="SQLMesh PR environment plan progress.", + link_label=f"Tobiko Cloud / environments / {self.pr_environment_name} / plans / {plan.plan_id}", + ) + + def update_prod_plan_comment(self, plan: Plan) -> None: + self._update_tobiko_cloud_plan_comment( + plan=plan, + environment=c.PROD, + marker=PROD_PLAN_LINK_COMMENT_MARKER, + description="SQLMesh production deploy plan.", + link_label=f"Tobiko Cloud / environments / {c.PROD} / plans / {plan.plan_id}", + ) + + def _update_tobiko_cloud_plan_comment( + self, + *, + plan: Plan, + environment: str, + marker: str, + description: str, + link_label: str, + ) -> None: + cloud_url = os.environ.get("TCLOUD_URL") or os.environ.get("TCLOUD_BASE_URL") + if not cloud_url: + return + if ( + not plan.context_diff.has_changes + and not plan.requires_backfill + and not plan.has_unmodified_unpromoted + ): + if comment := self._get_comment(header=marker): + comment.edit(body=f"{marker}\n\nNo current SQLMesh plan.") + return + + plan_url = f"{cloud_url.rstrip('/')}/environments/{environment}/plans/{plan.plan_id}" + body = f"{marker}\n\n{description}\n\nPlan: [{link_label}]({plan_url})" + comment = self._get_or_create_comment(header=marker) + comment.edit(body=body) + def update_pr_environment(self) -> None: """ Creates a PR environment from the logic present in the PR. If the PR contains changes that are uncategorized, then an error will be raised. """ self._console.consume_captured_output() # clear output buffer - self._context.apply(self.pr_plan) # will raise if PR environment creation fails + plan = self.pr_plan + self._context.apply(plan) # will raise if PR environment creation fails + self.update_pr_environment_plan_comment(plan) # update PR info comment vde_title = "- :eyes: To **review** this PR's changes, use virtual data environment:" @@ -800,7 +852,9 @@ def deploy_to_prod(self) -> None: value=plan_summary, dedup_regex=None, ) - self._context.apply(self.prod_plan) + plan = self.prod_plan + self.update_prod_plan_comment(plan) + self._context.apply(plan) def try_invalidate_pr_environment(self) -> None: """ diff --git a/tests/integrations/github/cicd/test_github_controller.py b/tests/integrations/github/cicd/test_github_controller.py index 96895a5960..45756f7bd6 100644 --- a/tests/integrations/github/cicd/test_github_controller.py +++ b/tests/integrations/github/cicd/test_github_controller.py @@ -509,6 +509,210 @@ def test_update_sqlmesh_comment_info( assert created_comments[0].body == create_comment +def test_update_pr_environment_posts_tobiko_cloud_plan_link( + github_client, + make_mock_issue_comment, + make_controller: t.Callable[..., GithubController], + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, +): + mock_issue = github_client.get_repo().get_issue() + created_comments: t.List[MockIssueComment] = [] + mock_issue.get_comments = mocker.MagicMock(side_effect=lambda: created_comments) + mock_issue.create_comment = mocker.MagicMock( + side_effect=lambda body: make_mock_issue_comment(body, created_comments) + ) + monkeypatch.setenv("TCLOUD_URL", "https://cloud.tobikodata.com/org/project/") + + controller = make_controller( + "tests/fixtures/github/pull_request_synchronized.json", + github_client, + mock_out_context=False, + ) + plan_id = controller.pr_plan.plan_id + + _update_pr_environment(controller) + + assert len(created_comments) == 2 + plan_comment = created_comments[0].body + assert "" in plan_comment + assert "SQLMesh PR environment plan progress." in plan_comment + assert ( + f"https://cloud.tobikodata.com/org/project/environments/hello_world_2/plans/{plan_id}" + in plan_comment + ) + + +def test_update_pr_environment_does_not_post_tobiko_cloud_plan_link_without_cloud_url( + github_client, + make_mock_issue_comment, + make_controller: t.Callable[..., GithubController], + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, +): + mock_issue = github_client.get_repo().get_issue() + created_comments: t.List[MockIssueComment] = [] + mock_issue.get_comments = mocker.MagicMock(side_effect=lambda: created_comments) + mock_issue.create_comment = mocker.MagicMock( + side_effect=lambda body: make_mock_issue_comment(body, created_comments) + ) + monkeypatch.delenv("TCLOUD_URL", raising=False) + monkeypatch.delenv("TCLOUD_BASE_URL", raising=False) + + controller = make_controller( + "tests/fixtures/github/pull_request_synchronized.json", + github_client, + mock_out_context=False, + ) + + _update_pr_environment(controller) + + assert len(created_comments) == 1 + assert "" not in created_comments[0].body + + +def test_update_pr_environment_does_not_post_tobiko_cloud_plan_link_for_noop_plan( + github_client, + make_mock_issue_comment, + make_controller: t.Callable[..., GithubController], + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, +): + mock_issue = github_client.get_repo().get_issue() + created_comments: t.List[MockIssueComment] = [] + mock_issue.get_comments = mocker.MagicMock(side_effect=lambda: created_comments) + mock_issue.create_comment = mocker.MagicMock( + side_effect=lambda body: make_mock_issue_comment(body, created_comments) + ) + monkeypatch.setenv("TCLOUD_URL", "https://cloud.tobikodata.com/org/project/") + mocker.patch( + "sqlmesh.core.context_diff.ContextDiff.has_changes", + PropertyMock(return_value=False), + ) + mocker.patch( + "sqlmesh.core.plan.definition.Plan.requires_backfill", + PropertyMock(return_value=False), + ) + mocker.patch( + "sqlmesh.core.plan.definition.Plan.has_unmodified_unpromoted", + PropertyMock(return_value=False), + ) + + controller = make_controller( + "tests/fixtures/github/pull_request_synchronized.json", + github_client, + mock_out_context=False, + ) + + _update_pr_environment(controller) + + assert len(created_comments) == 1 + assert "" not in created_comments[0].body + + +def test_update_pr_environment_clears_existing_tobiko_cloud_plan_link_for_noop_plan( + github_client, + make_mock_issue_comment, + make_controller: t.Callable[..., GithubController], + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, +): + existing_plan_comment = MockIssueComment(body="\n\nOld link") + existing_comments = [existing_plan_comment] + created_comments: t.List[MockIssueComment] = [] + mock_issue = github_client.get_repo().get_issue() + mock_issue.get_comments = mocker.MagicMock(side_effect=lambda: existing_comments) + mock_issue.create_comment = mocker.MagicMock( + side_effect=lambda body: make_mock_issue_comment(body, created_comments) + ) + monkeypatch.setenv("TCLOUD_URL", "https://cloud.tobikodata.com/org/project/") + mocker.patch( + "sqlmesh.core.context_diff.ContextDiff.has_changes", + PropertyMock(return_value=False), + ) + mocker.patch( + "sqlmesh.core.plan.definition.Plan.requires_backfill", + PropertyMock(return_value=False), + ) + mocker.patch( + "sqlmesh.core.plan.definition.Plan.has_unmodified_unpromoted", + PropertyMock(return_value=False), + ) + + controller = make_controller( + "tests/fixtures/github/pull_request_synchronized.json", + github_client, + mock_out_context=False, + ) + + _update_pr_environment(controller) + + assert len(created_comments) == 1 + assert existing_plan_comment.body == "\n\nNo current SQLMesh plan." + + +def test_update_pr_environment_plan_link_uses_tcloud_base_url( + github_client, + make_mock_issue_comment, + make_controller: t.Callable[..., GithubController], + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, +): + mock_issue = github_client.get_repo().get_issue() + created_comments: t.List[MockIssueComment] = [] + mock_issue.get_comments = mocker.MagicMock(side_effect=lambda: created_comments) + mock_issue.create_comment = mocker.MagicMock( + side_effect=lambda body: make_mock_issue_comment(body, created_comments) + ) + monkeypatch.delenv("TCLOUD_URL", raising=False) + monkeypatch.setenv("TCLOUD_BASE_URL", "https://cloud.tobikodata.com/base/project/") + + controller = make_controller( + "tests/fixtures/github/pull_request_synchronized.json", + github_client, + mock_out_context=False, + ) + plan_id = controller.pr_plan.plan_id + + _update_pr_environment(controller) + + assert ( + f"https://cloud.tobikodata.com/base/project/environments/hello_world_2/plans/{plan_id}" + in created_comments[0].body + ) + + +def test_update_pr_environment_plan_link_updates_existing_comment( + github_client, + make_mock_issue_comment, + make_controller: t.Callable[..., GithubController], + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, +): + existing_plan_comment = MockIssueComment(body="\n\nOld link") + existing_comments = [existing_plan_comment] + created_comments: t.List[MockIssueComment] = [] + mock_issue = github_client.get_repo().get_issue() + mock_issue.get_comments = mocker.MagicMock(side_effect=lambda: existing_comments) + mock_issue.create_comment = mocker.MagicMock( + side_effect=lambda body: make_mock_issue_comment(body, created_comments) + ) + monkeypatch.setenv("TCLOUD_URL", "https://cloud.tobikodata.com/org/project/") + + controller = make_controller( + "tests/fixtures/github/pull_request_synchronized.json", + github_client, + mock_out_context=False, + ) + plan_id = controller.pr_plan.plan_id + + _update_pr_environment(controller) + + assert len(created_comments) == 1 + assert f"/environments/hello_world_2/plans/{plan_id}" in existing_plan_comment.body + assert "Old link" not in existing_plan_comment.body + + def test_deploy_to_prod_merge_error(github_client, make_controller): mock_pull_request = github_client.get_repo().get_pull() mock_pull_request.merged = True @@ -549,6 +753,178 @@ def test_deploy_to_prod_not_blocked_pr_if_config_set(github_client, make_control controller.deploy_to_prod() +def test_deploy_to_prod_posts_tobiko_cloud_plan_link( + github_client, + make_mock_issue_comment, + make_controller: t.Callable[..., GithubController], + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, +): + mock_pull_request = github_client.get_repo().get_pull() + mock_pull_request.merged = False + mock_issue = github_client.get_repo().get_issue() + created_comments: t.List[MockIssueComment] = [] + mock_issue.get_comments = mocker.MagicMock(side_effect=lambda: created_comments) + mock_issue.create_comment = mocker.MagicMock( + side_effect=lambda body: make_mock_issue_comment(body, created_comments) + ) + monkeypatch.setenv("TCLOUD_URL", "https://cloud.tobikodata.com/org/project/") + + controller = make_controller( + "tests/fixtures/github/pull_request_synchronized.json", github_client + ) + plan_id = controller.prod_plan.plan_id + + controller.deploy_to_prod() + + assert len(created_comments) == 2 + prod_plan_comment = created_comments[1].body + assert "" in prod_plan_comment + assert "SQLMesh production deploy plan." in prod_plan_comment + assert ( + f"https://cloud.tobikodata.com/org/project/environments/prod/plans/{plan_id}" + in prod_plan_comment + ) + + +def test_deploy_to_prod_does_not_post_tobiko_cloud_plan_link_without_cloud_url( + github_client, + make_mock_issue_comment, + make_controller: t.Callable[..., GithubController], + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, +): + mock_pull_request = github_client.get_repo().get_pull() + mock_pull_request.merged = False + mock_issue = github_client.get_repo().get_issue() + created_comments: t.List[MockIssueComment] = [] + mock_issue.get_comments = mocker.MagicMock(side_effect=lambda: created_comments) + mock_issue.create_comment = mocker.MagicMock( + side_effect=lambda body: make_mock_issue_comment(body, created_comments) + ) + monkeypatch.delenv("TCLOUD_URL", raising=False) + monkeypatch.delenv("TCLOUD_BASE_URL", raising=False) + + controller = make_controller( + "tests/fixtures/github/pull_request_synchronized.json", github_client + ) + + controller.deploy_to_prod() + + assert len(created_comments) == 1 + assert "" not in created_comments[0].body + + +def test_deploy_to_prod_posts_tobiko_cloud_plan_link_if_apply_fails( + github_client, + make_mock_issue_comment, + make_controller: t.Callable[..., GithubController], + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, +): + mock_pull_request = github_client.get_repo().get_pull() + mock_pull_request.merged = False + mock_issue = github_client.get_repo().get_issue() + created_comments: t.List[MockIssueComment] = [] + mock_issue.get_comments = mocker.MagicMock(side_effect=lambda: created_comments) + mock_issue.create_comment = mocker.MagicMock( + side_effect=lambda body: make_mock_issue_comment(body, created_comments) + ) + monkeypatch.setenv("TCLOUD_URL", "https://cloud.tobikodata.com/org/project/") + + controller = make_controller( + "tests/fixtures/github/pull_request_synchronized.json", github_client + ) + plan_id = controller.prod_plan.plan_id + controller._context.apply = mocker.MagicMock(side_effect=SQLMeshError("apply failed")) + + with pytest.raises(SQLMeshError, match="apply failed"): + controller.deploy_to_prod() + + assert len(created_comments) == 2 + assert "" in created_comments[1].body + assert f"/environments/prod/plans/{plan_id}" in created_comments[1].body + + +def test_deploy_to_prod_updates_existing_tobiko_cloud_plan_link( + github_client, + make_mock_issue_comment, + make_controller: t.Callable[..., GithubController], + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, +): + mock_pull_request = github_client.get_repo().get_pull() + mock_pull_request.merged = False + existing_prod_plan_comment = MockIssueComment( + body="\n\nOld link" + ) + existing_comments = [existing_prod_plan_comment] + created_comments: t.List[MockIssueComment] = [] + mock_issue = github_client.get_repo().get_issue() + mock_issue.get_comments = mocker.MagicMock(side_effect=lambda: existing_comments) + mock_issue.create_comment = mocker.MagicMock( + side_effect=lambda body: make_mock_issue_comment(body, created_comments) + ) + monkeypatch.setenv("TCLOUD_URL", "https://cloud.tobikodata.com/org/project/") + + controller = make_controller( + "tests/fixtures/github/pull_request_synchronized.json", github_client + ) + plan_id = controller.prod_plan.plan_id + + controller.deploy_to_prod() + + assert len(created_comments) == 1 + assert f"/environments/prod/plans/{plan_id}" in existing_prod_plan_comment.body + assert "Old link" not in existing_prod_plan_comment.body + + +def test_deploy_to_prod_clears_existing_tobiko_cloud_plan_link_for_noop_plan( + github_client, + make_mock_issue_comment, + make_controller: t.Callable[..., GithubController], + mocker: MockerFixture, + monkeypatch: pytest.MonkeyPatch, +): + mock_pull_request = github_client.get_repo().get_pull() + mock_pull_request.merged = False + existing_prod_plan_comment = MockIssueComment( + body="\n\nOld link" + ) + existing_comments = [existing_prod_plan_comment] + created_comments: t.List[MockIssueComment] = [] + mock_issue = github_client.get_repo().get_issue() + mock_issue.get_comments = mocker.MagicMock(side_effect=lambda: existing_comments) + mock_issue.create_comment = mocker.MagicMock( + side_effect=lambda body: make_mock_issue_comment(body, created_comments) + ) + monkeypatch.setenv("TCLOUD_URL", "https://cloud.tobikodata.com/org/project/") + mocker.patch( + "sqlmesh.core.context_diff.ContextDiff.has_changes", + PropertyMock(return_value=False), + ) + mocker.patch( + "sqlmesh.core.plan.definition.Plan.requires_backfill", + PropertyMock(return_value=False), + ) + mocker.patch( + "sqlmesh.core.plan.definition.Plan.has_unmodified_unpromoted", + PropertyMock(return_value=False), + ) + + controller = make_controller( + "tests/fixtures/github/pull_request_synchronized.json", github_client + ) + + controller.deploy_to_prod() + + assert len(created_comments) == 1 + assert ( + existing_prod_plan_comment.body + == "\n\nNo current SQLMesh plan." + ) + + def test_deploy_to_prod_dirty_pr(github_client, make_controller): mock_pull_request = github_client.get_repo().get_pull() mock_pull_request.merged = False @@ -709,9 +1085,16 @@ def test_uncategorized( github_output_file = tmp_path / "github_output.txt" - with mock.patch.dict(os.environ, {"GITHUB_OUTPUT": str(github_output_file)}): + with mock.patch.dict( + os.environ, + { + "GITHUB_OUTPUT": str(github_output_file), + "TCLOUD_URL": "https://cloud.tobikodata.com/org/project/", + }, + ): _update_pr_environment(controller) + assert not created_comments assert "SQLMesh - PR Environment Synced" in controller._check_run_mapping pr_environment_check_run = controller._check_run_mapping[ "SQLMesh - PR Environment Synced"