diff --git a/docs/integrations/github.md b/docs/integrations/github.md index 07903fce56..328acd0d17 100644 --- a/docs/integrations/github.md +++ b/docs/integrations/github.md @@ -293,8 +293,10 @@ Below is an example of how to define the default config for the bot in either YA | `enable_deploy_command` | Indicates if the `/deploy` command should be enabled in order to allowed synchronized deploys to production. Default: `False` | bool | N | | `command_namespace` | The namespace to use for SQLMesh commands. For example if you provide `#SQLMesh` as a value then commands will be expected in the format of `#SQLMesh/`. Default: `None` meaning no namespace is used. | string | N | | `auto_categorize_changes` | Auto categorization behavior to use for the bot. If not provided then the project-wide categorization behavior is used. See [Auto-categorize model changes](https://sqlmesh.readthedocs.io/en/stable/guides/configuration/#auto-categorize-model-changes) for details. | dict | N | -| `default_pr_start` | Default start when creating PR environment plans. If running in a mode where the bot automatically backfills models (based on `auto_categorize_changes` behavior) then this can be used to limit the amount of data backfilled. Defaults to `None` meaning the start date is set to the earliest model's start or to 1 day ago if [data previews](../concepts/plans.md#data-preview) need to be computed. | str | N | +| `default_pr_start` | Default start when creating PR environment plans. If running in a mode where the bot automatically backfills models (based on `auto_categorize_changes` behavior) then this can be used to limit the amount of data backfilled. Defaults to `None` meaning the start date is set to the earliest model's start. | str | N | | `pr_min_intervals` | Intended for use when `default_pr_start` is set to a relative time, eg `1 week ago`. This ensures that at least this many intervals across every model are included for backfill in the PR environment. Without this, models with an interval unit wider than `default_pr_start` (such as `@monthly` models if `default_pr_start` was set to `1 week ago`) will be excluded from backfill entirely. | int | N | +| `default_pr_preview_start` | Default start when computing [data previews](../concepts/plans.md#data-preview) for forward-only changes in PR environments. Defaults to `yesterday`, independent of `default_pr_start`, so preview data can be limited without reducing the regular PR backfill window. | str | N | +| `pr_preview_min_intervals` | Intended for use when `default_pr_preview_start` is set to a relative time. This ensures that at least this many intervals are included for forward-only previews in the PR environment. Default: `1` | int | N | | `skip_pr_backfill` | Indicates if the bot should skip backfilling models in the PR environment. Default: `True` | bool | N | | `pr_include_unmodified` | Indicates whether to include unmodified models in the PR environment. Default to the project's config value (which defaults to `False`) | bool | N | | `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 | @@ -320,6 +322,7 @@ Example with all properties defined: sql: full seed: full default_pr_start: "1 week ago" + default_pr_preview_start: "yesterday" skip_pr_backfill: false run_on_deploy_to_prod: false prod_branch_name: production @@ -344,6 +347,7 @@ Example with all properties defined: seed=AutoCategorizationMode.FULL, ), default_pr_start="1 week ago", + default_pr_preview_start="yesterday", skip_pr_backfill=False, run_on_deploy_to_prod=False, prod_branch_name="production", diff --git a/sqlmesh/core/context.py b/sqlmesh/core/context.py index 4eb0d3b40b..227374a808 100644 --- a/sqlmesh/core/context.py +++ b/sqlmesh/core/context.py @@ -1469,6 +1469,8 @@ def plan_builder( backfill_models: t.Optional[t.Collection[str]] = None, categorizer_config: t.Optional[CategorizerConfig] = None, enable_preview: t.Optional[bool] = None, + preview_start: t.Optional[TimeLike] = None, + preview_min_intervals: t.Optional[int] = None, run: t.Optional[bool] = None, diff_rendered: t.Optional[bool] = None, skip_linter: t.Optional[bool] = None, @@ -1510,6 +1512,8 @@ def plan_builder( select_models: A list of model selection strings to filter the models that should be included into this plan. backfill_models: A list of model selection strings to filter the models for which the data should be backfilled. enable_preview: Indicates whether to enable preview for forward-only models in development environments. + preview_start: The start date for forward-only previews. + preview_min_intervals: The minimum number of intervals to preview for each forward-only preview snapshot. run: Whether to run latest intervals as part of the plan application. diff_rendered: Whether the diff should compare raw vs rendered models min_intervals: Adjust the plan start date on a per-model basis in order to ensure at least this many intervals are covered @@ -1543,6 +1547,8 @@ def plan_builder( "select_models": list(select_models) if select_models is not None else None, "backfill_models": list(backfill_models) if backfill_models is not None else None, "enable_preview": enable_preview, + "preview_start": preview_start, + "preview_min_intervals": preview_min_intervals, "run": run, "diff_rendered": diff_rendered, "skip_linter": skip_linter, @@ -1744,6 +1750,8 @@ def plan_builder( enable_preview=( enable_preview if enable_preview is not None else self._plan_preview_enabled ), + preview_start=preview_start, + preview_min_intervals=preview_min_intervals or 0, end_bounded=not run, ensure_finalized_snapshots=self.config.plan.use_finalized_state, start_override_per_model=start_override_per_model, diff --git a/sqlmesh/core/plan/builder.py b/sqlmesh/core/plan/builder.py index 01834594cd..eb7ef6ff31 100644 --- a/sqlmesh/core/plan/builder.py +++ b/sqlmesh/core/plan/builder.py @@ -87,6 +87,8 @@ class PlanBuilder: default_start: The default plan start to use if not specified. default_end: The default plan end to use if not specified. enable_preview: Whether to enable preview for forward-only models in development environments. + preview_start: The start time to use for forward-only previews. Defaults to the plan start. + preview_min_intervals: The minimum number of intervals to preview for each forward-only preview snapshot. end_bounded: If set to true, the missing intervals will be bounded by the target end date, disregarding lookback, allow_partials, and other attributes that could cause the intervals to exceed the target end date. ensure_finalized_snapshots: Whether to compare against snapshots from the latest finalized @@ -125,6 +127,8 @@ def __init__( default_start: t.Optional[TimeLike] = None, default_end: t.Optional[TimeLike] = None, enable_preview: bool = False, + preview_start: t.Optional[TimeLike] = None, + preview_min_intervals: int = 0, end_bounded: bool = False, ensure_finalized_snapshots: bool = False, explain: bool = False, @@ -148,6 +152,8 @@ def __init__( allow_additive_models if allow_additive_models is not None else [] ) self._enable_preview = enable_preview + self._preview_start = preview_start + self._preview_min_intervals = preview_min_intervals self._end_bounded = end_bounded self._ensure_finalized_snapshots = ensure_finalized_snapshots self._ignore_cron = ignore_cron @@ -179,10 +185,17 @@ def __init__( self._explain = explain self._start = start - if not self._start and ( - self._forward_only_preview_needed or self._non_forward_only_preview_needed - ): - self._start = default_start or yesterday_ds() + if not self._start and self._forward_only_preview_needed: + self._preview_start = self._preview_start or default_start or yesterday_ds() + # Only use the preview fallback as the plan start for implicit selection. + # None means no explicit selection; an empty set intentionally backfills no models. + if self._backfill_models is None: + self._start = self._preview_start + + if not self._start and self._non_forward_only_preview_needed: + # Do not bind explicit non-preview backfills to the short preview range. + if self._backfill_models is None: + self._start = default_start or yesterday_ds() self._plan_id: str = random_id() self._model_fqn_to_snapshot = {s.name: s for s in self._context_diff.snapshots.values()} @@ -447,9 +460,12 @@ def _build_restatements( possible_intervals = { restatements[p.snapshot_id] for p in restating_parents if p.is_incremental } + removal_start = ( + self._forward_only_preview_start(snapshot, start, end) if is_preview else start + ) possible_intervals.add( snapshot.get_removal_interval( - start, + removal_start, end, self._execution_time, strict=False, @@ -474,6 +490,21 @@ def _build_restatements( return restatements + def _forward_only_preview_start( + self, snapshot: Snapshot, default_start: TimeLike, end: TimeLike + ) -> TimeLike: + preview_start = self._preview_start or default_start + if not self._preview_min_intervals: + return preview_start + + relative_base = to_datetime(self.execution_time) + preview_end = to_datetime(end, relative_base=relative_base) + min_start = snapshot.node.cron_floor(preview_end) + for _ in range(self._preview_min_intervals): + min_start = snapshot.node.cron_prev(min_start) + + return min(to_datetime(preview_start, relative_base=relative_base), min_start) + def _build_directly_and_indirectly_modified( self, dag: DAG[SnapshotId] ) -> t.Tuple[t.Set[SnapshotId], SnapshotMapping]: diff --git a/sqlmesh/integrations/github/cicd/config.py b/sqlmesh/integrations/github/cicd/config.py index 7fb3a0f5b6..2a2e2efa49 100644 --- a/sqlmesh/integrations/github/cicd/config.py +++ b/sqlmesh/integrations/github/cicd/config.py @@ -27,11 +27,13 @@ class GithubCICDBotConfig(BaseConfig): default=None, alias="auto_categorize_changes" ) default_pr_start: t.Optional[TimeLike] = None + default_pr_preview_start: TimeLike = "yesterday" skip_pr_backfill_: t.Optional[bool] = Field(default=None, alias="skip_pr_backfill") pr_include_unmodified_: t.Optional[bool] = Field(default=None, alias="pr_include_unmodified") run_on_deploy_to_prod: bool = False pr_environment_name: t.Optional[str] = None pr_min_intervals: t.Optional[int] = None + pr_preview_min_intervals: int = Field(default=1, ge=0) 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" @@ -88,9 +90,11 @@ def forward_only_branch_suffix(self) -> str: "command_namespace", "auto_categorize_changes", "default_pr_start", + "default_pr_preview_start", "skip_pr_backfill", "pr_include_unmodified", "run_on_deploy_to_prod", "pr_min_intervals", + "pr_preview_min_intervals", "forward_only_branch_suffix", } diff --git a/sqlmesh/integrations/github/cicd/controller.py b/sqlmesh/integrations/github/cicd/controller.py index 40102b97e8..c693d09560 100644 --- a/sqlmesh/integrations/github/cicd/controller.py +++ b/sqlmesh/integrations/github/cicd/controller.py @@ -404,6 +404,8 @@ def pr_plan(self) -> Plan: categorizer_config=self.bot_config.auto_categorize_changes, start=self.bot_config.default_pr_start, min_intervals=self.bot_config.pr_min_intervals, + preview_start=self.bot_config.default_pr_preview_start, + preview_min_intervals=self.bot_config.pr_preview_min_intervals, skip_backfill=self.bot_config.skip_pr_backfill, include_unmodified=self.bot_config.pr_include_unmodified, forward_only=self.forward_only_plan, diff --git a/tests/core/test_plan.py b/tests/core/test_plan.py index 590cda01ec..c22d824077 100644 --- a/tests/core/test_plan.py +++ b/tests/core/test_plan.py @@ -2934,6 +2934,248 @@ def test_unaligned_start_model_with_forward_only_preview(make_snapshot): assert not plan.deployability_index.is_deployable(snapshot_b) +def _make_forward_only_preview_context_diff(make_snapshot): + old_snapshot = make_snapshot( + SqlModel( + name="a", + query=parse_one("select 1, ds"), + kind=dict( + name=ModelKindName.INCREMENTAL_BY_TIME_RANGE, + forward_only=True, + time_column="ds", + ), + start="2025-01-01", + ) + ) + old_snapshot.categorize_as(SnapshotChangeCategory.BREAKING) + + new_snapshot = make_snapshot( + SqlModel( + name="a", + query=parse_one("select 2, ds"), + kind=dict( + name=ModelKindName.INCREMENTAL_BY_TIME_RANGE, + forward_only=True, + time_column="ds", + ), + start="2025-01-01", + ) + ) + new_snapshot.previous_versions = old_snapshot.all_versions + + context_diff = ContextDiff( + environment="test_environment", + is_new_environment=True, + is_unfinalized_environment=False, + normalize_environment_name=True, + create_from="prod", + create_from_env_exists=True, + added=set(), + removed_snapshots={}, + snapshots={new_snapshot.snapshot_id: new_snapshot}, + new_snapshots={new_snapshot.snapshot_id: new_snapshot}, + modified_snapshots={old_snapshot.name: (new_snapshot, old_snapshot)}, + previous_plan_id=None, + previously_promoted_snapshot_ids=set(), + previous_finalized_snapshots=None, + previous_gateway_managed_virtual_layer=False, + gateway_managed_virtual_layer=False, + environment_statements=[], + ) + + return context_diff, new_snapshot + + +def test_forward_only_preview_start_does_not_limit_regular_backfill(make_snapshot): + context_diff, preview_new_snapshot = _make_forward_only_preview_context_diff(make_snapshot) + + normal_old_snapshot = make_snapshot( + SqlModel( + name="normal", + query=parse_one("select 1, ds"), + dialect="duckdb", + kind=dict(name=ModelKindName.INCREMENTAL_BY_TIME_RANGE, time_column="ds"), + start="2025-01-01", + ) + ) + normal_old_snapshot.categorize_as(SnapshotChangeCategory.BREAKING) + normal_new_snapshot = make_snapshot( + SqlModel( + name="normal", + query=parse_one("select 2, ds"), + dialect="duckdb", + kind=dict(name=ModelKindName.INCREMENTAL_BY_TIME_RANGE, time_column="ds"), + start="2025-01-01", + ) + ) + normal_new_snapshot.previous_versions = normal_old_snapshot.all_versions + + context_diff.modified_snapshots[normal_new_snapshot.name] = ( + normal_new_snapshot, + normal_old_snapshot, + ) + context_diff.snapshots[normal_new_snapshot.snapshot_id] = normal_new_snapshot + context_diff.new_snapshots[normal_new_snapshot.snapshot_id] = normal_new_snapshot + + plan = PlanBuilder( + context_diff, + default_start="2025-01-02", + end="2025-01-03", + preview_min_intervals=1, + backfill_models={normal_new_snapshot.name, preview_new_snapshot.name}, + is_dev=True, + enable_preview=True, + ).build() + + missing_intervals = {i.snapshot_id: i.intervals for i in plan.missing_intervals} + + assert plan.provided_start is None + assert missing_intervals == { + normal_new_snapshot.snapshot_id: [ + (to_timestamp("2025-01-01"), to_timestamp("2025-01-02")), + (to_timestamp("2025-01-02"), to_timestamp("2025-01-03")), + (to_timestamp("2025-01-03"), to_timestamp("2025-01-04")), + ], + preview_new_snapshot.snapshot_id: [ + (to_timestamp("2025-01-02"), to_timestamp("2025-01-03")), + (to_timestamp("2025-01-03"), to_timestamp("2025-01-04")), + ], + } + + +def test_non_forward_only_preview_start_does_not_limit_explicit_backfill(make_snapshot): + old_snapshot = make_snapshot( + SqlModel( + name="a", + query=parse_one("select 1, ds"), + dialect="duckdb", + kind=IncrementalByTimeRangeKind( + time_column="ds", + auto_restatement_cron="@daily", + ), + start="2025-01-01", + ) + ) + old_snapshot.categorize_as(SnapshotChangeCategory.BREAKING) + new_snapshot = make_snapshot( + SqlModel( + name="a", + query=parse_one("select 2, ds"), + dialect="duckdb", + kind=IncrementalByTimeRangeKind( + time_column="ds", + auto_restatement_cron="@daily", + ), + start="2025-01-01", + ) + ) + new_snapshot.previous_versions = old_snapshot.all_versions + + context_diff = ContextDiff( + environment="test_environment", + is_new_environment=True, + is_unfinalized_environment=False, + normalize_environment_name=True, + create_from="prod", + create_from_env_exists=True, + added=set(), + removed_snapshots={}, + snapshots={new_snapshot.snapshot_id: new_snapshot}, + new_snapshots={new_snapshot.snapshot_id: new_snapshot}, + modified_snapshots={old_snapshot.name: (new_snapshot, old_snapshot)}, + previous_plan_id=None, + previously_promoted_snapshot_ids=set(), + previous_finalized_snapshots=None, + previous_gateway_managed_virtual_layer=False, + gateway_managed_virtual_layer=False, + environment_statements=[], + ) + + plan = PlanBuilder( + context_diff, + default_start="2025-01-02", + end="2025-01-03", + backfill_models={new_snapshot.name}, + is_dev=True, + ).build() + + assert plan.provided_start is None + assert plan.missing_intervals == [ + SnapshotIntervals( + snapshot_id=new_snapshot.snapshot_id, + intervals=[ + (to_timestamp("2025-01-01"), to_timestamp("2025-01-02")), + (to_timestamp("2025-01-02"), to_timestamp("2025-01-03")), + (to_timestamp("2025-01-03"), to_timestamp("2025-01-04")), + ], + ) + ] + + +@time_machine.travel("2026-06-02 00:00:00 UTC") +def test_forward_only_preview_uses_preview_start(make_snapshot): + context_diff, new_snapshot = _make_forward_only_preview_context_diff(make_snapshot) + + plan = PlanBuilder( + context_diff, + start="2025-01-01", + preview_start="yesterday", + preview_min_intervals=1, + enable_preview=True, + is_dev=True, + ).build() + + assert plan.start == "2025-01-01" + assert plan.restatements == { + new_snapshot.snapshot_id: ( + to_timestamp("2026-06-01"), + to_timestamp("2026-06-02"), + ) + } + + +@time_machine.travel("2026-06-02 00:00:00 UTC") +def test_forward_only_preview_min_intervals_expands_preview_start(make_snapshot): + context_diff, new_snapshot = _make_forward_only_preview_context_diff(make_snapshot) + + plan = PlanBuilder( + context_diff, + start="2025-01-01", + preview_start="now", + preview_min_intervals=1, + enable_preview=True, + is_dev=True, + ).build() + + assert plan.restatements == { + new_snapshot.snapshot_id: ( + to_timestamp("2026-06-01"), + to_timestamp("2026-06-02"), + ) + } + + +@time_machine.travel("2026-06-02 00:00:00 UTC") +def test_forward_only_preview_start_can_exceed_preview_min_intervals(make_snapshot): + context_diff, new_snapshot = _make_forward_only_preview_context_diff(make_snapshot) + + plan = PlanBuilder( + context_diff, + start="2025-01-01", + preview_start="2025-01-01", + preview_min_intervals=1, + enable_preview=True, + is_dev=True, + ).build() + + assert plan.restatements == { + new_snapshot.snapshot_id: ( + to_timestamp("2025-01-01"), + to_timestamp("2026-06-02"), + ) + } + + def test_restate_production_model_in_dev(make_snapshot, mocker: MockerFixture): snapshot = make_snapshot( SqlModel( diff --git a/tests/integrations/github/cicd/test_config.py b/tests/integrations/github/cicd/test_config.py index e4424cf3ba..20b72393dc 100644 --- a/tests/integrations/github/cicd/test_config.py +++ b/tests/integrations/github/cicd/test_config.py @@ -36,12 +36,14 @@ def test_load_yaml_config_default(tmp_path): assert config.cicd_bot.command_namespace is None assert config.cicd_bot.auto_categorize_changes == config.plan.auto_categorize_changes assert config.cicd_bot.default_pr_start is None + assert config.cicd_bot.default_pr_preview_start == "yesterday" assert not config.cicd_bot.enable_deploy_command assert config.cicd_bot.skip_pr_backfill assert not config.cicd_bot.pr_include_unmodified assert config.cicd_bot.pr_environment_name is None assert config.cicd_bot.prod_branch_names == ["main", "master"] assert not config.cicd_bot.pr_min_intervals + assert config.cicd_bot.pr_preview_min_intervals == 1 def test_load_yaml_config(tmp_path): @@ -60,12 +62,14 @@ def test_load_yaml_config(tmp_path): sql: full seed: full default_pr_start: + default_pr_preview_start: 2 days ago enable_deploy_command: true skip_pr_backfill: false pr_include_unmodified: true pr_environment_name: "MyOverride" prod_branch_name: testing pr_min_intervals: 1 + pr_preview_min_intervals: 2 model_defaults: dialect: duckdb """, @@ -85,12 +89,14 @@ def test_load_yaml_config(tmp_path): seed=AutoCategorizationMode.FULL, ) assert config.cicd_bot.default_pr_start is None + assert config.cicd_bot.default_pr_preview_start == "2 days ago" assert config.cicd_bot.enable_deploy_command assert not config.cicd_bot.skip_pr_backfill assert config.cicd_bot.pr_include_unmodified assert config.cicd_bot.pr_environment_name == "MyOverride" assert config.cicd_bot.prod_branch_names == ["testing"] assert config.cicd_bot.pr_min_intervals == 1 + assert config.cicd_bot.pr_preview_min_intervals == 2 def test_load_python_config_defaults(tmp_path): @@ -117,12 +123,14 @@ def test_load_python_config_defaults(tmp_path): assert config.cicd_bot.command_namespace is None assert config.cicd_bot.auto_categorize_changes == config.plan.auto_categorize_changes assert config.cicd_bot.default_pr_start is None + assert config.cicd_bot.default_pr_preview_start == "yesterday" assert not config.cicd_bot.enable_deploy_command assert config.cicd_bot.skip_pr_backfill assert not config.cicd_bot.pr_include_unmodified assert config.cicd_bot.pr_environment_name is None assert config.cicd_bot.prod_branch_names == ["main", "master"] assert not config.cicd_bot.pr_min_intervals + assert config.cicd_bot.pr_preview_min_intervals == 1 def test_load_python_config(tmp_path): @@ -145,7 +153,9 @@ def test_load_python_config(tmp_path): seed=AutoCategorizationMode.FULL, ), default_pr_start="1 week ago", + default_pr_preview_start="2 days ago", pr_min_intervals=1, + pr_preview_min_intervals=2, enable_deploy_command=True, skip_pr_backfill=False, pr_include_unmodified=True, @@ -172,12 +182,14 @@ def test_load_python_config(tmp_path): seed=AutoCategorizationMode.FULL, ) assert config.cicd_bot.default_pr_start == "1 week ago" + assert config.cicd_bot.default_pr_preview_start == "2 days ago" assert config.cicd_bot.enable_deploy_command assert not config.cicd_bot.skip_pr_backfill assert config.cicd_bot.pr_include_unmodified assert config.cicd_bot.pr_environment_name == "MyOverride" assert config.cicd_bot.prod_branch_names == ["testing"] assert config.cicd_bot.pr_min_intervals == 1 + assert config.cicd_bot.pr_preview_min_intervals == 2 def test_validation(tmp_path): diff --git a/tests/integrations/github/cicd/test_github_controller.py b/tests/integrations/github/cicd/test_github_controller.py index e4fe10e321..96895a5960 100644 --- a/tests/integrations/github/cicd/test_github_controller.py +++ b/tests/integrations/github/cicd/test_github_controller.py @@ -295,6 +295,67 @@ def test_pr_plan_min_intervals(github_client, make_controller): assert controller.pr_plan.start_override_per_model +def test_pr_plan_preview_window(github_client, make_controller, mocker: MockerFixture): + context = mocker.MagicMock() + plan_builder = mocker.MagicMock() + plan = mocker.MagicMock(spec=Plan) + plan_builder.build.return_value = plan + context.config.plan.forward_only = False + context.plan_builder.return_value = plan_builder + mocker.patch("sqlmesh.integrations.github.cicd.controller.Context", return_value=context) + + bot_config = GithubCICDBotConfig(default_pr_start="2025-01-01") + controller = make_controller( + "tests/fixtures/github/pull_request_synchronized.json", + github_client, + bot_config=bot_config, + ) + + assert controller.pr_plan is plan + context.plan_builder.assert_called_once_with( + environment="hello_world_2", + skip_tests=True, + skip_linter=True, + categorizer_config=bot_config.auto_categorize_changes, + start="2025-01-01", + min_intervals=None, + preview_start="yesterday", + preview_min_intervals=1, + skip_backfill=bot_config.skip_pr_backfill, + include_unmodified=False, + forward_only=controller.forward_only_plan, + ) + + context.plan_builder.reset_mock() + plan_builder.build.reset_mock() + + bot_config = GithubCICDBotConfig( + default_pr_start="2025-01-01", + default_pr_preview_start="2 days ago", + pr_preview_min_intervals=2, + ) + controller = make_controller( + "tests/fixtures/github/pull_request_synchronized.json", + github_client, + bot_config=bot_config, + ) + + assert controller.pr_plan is plan + context.plan_builder.assert_called_once_with( + environment="hello_world_2", + skip_tests=True, + skip_linter=True, + categorizer_config=bot_config.auto_categorize_changes, + start="2025-01-01", + min_intervals=None, + preview_start="2 days ago", + preview_min_intervals=2, + skip_backfill=bot_config.skip_pr_backfill, + include_unmodified=False, + forward_only=controller.forward_only_plan, + ) + + def test_prod_plan(github_client, make_controller): controller = make_controller( "tests/fixtures/github/pull_request_synchronized.json", github_client