diff --git a/pyartcd/pyartcd/pipelines/prepare_release_konflux.py b/pyartcd/pyartcd/pipelines/prepare_release_konflux.py index a4629b9100..1f902d49d5 100644 --- a/pyartcd/pyartcd/pipelines/prepare_release_konflux.py +++ b/pyartcd/pyartcd/pipelines/prepare_release_konflux.py @@ -20,7 +20,13 @@ import click import semver from artcommonlib import exectools -from artcommonlib.assembly import AssemblyTypes, assembly_config_struct, assembly_group_config +from artcommonlib.assembly import ( + AssemblyTypes, + assembly_basis, + assembly_config_struct, + assembly_group_config, + assembly_own_issues_config, +) from artcommonlib.constants import ( REGISTRY_QUAY_OCP_RELEASE_DEV, SHIPMENT_DATA_URL_TEMPLATE, @@ -376,6 +382,23 @@ async def check_blockers(self): if self.assembly_type != AssemblyTypes.STANDARD: self.logger.info(f"Skipping Blocker Bugs check for non-standard assembly {self.assembly}") return + + releases_model = Model(self.releases_config) + has_basis = bool(assembly_basis(releases_model, self.assembly).assembly) + if has_basis: + # Note: only reads issues.include (not include! or include?); operator-suffixed variants are not handled. + issues_config = assembly_own_issues_config(releases_model, self.assembly) + included_bug_ids = {str(i["id"]) for i in issues_config.include} + if included_bug_ids: + self.logger.info( + "Skipping blocker bug check: assembly %s is a targeted fix on basis assembly, " + "shipping %d bug(s): %s", + self.assembly, + len(included_bug_ids), + ", ".join(sorted(included_bug_ids)), + ) + return + self.logger.info(f"Checking Blocker Bugs for release {self.assembly}") cmd = self._elliott_base_command + ["find-bugs:blocker", "--exclude-status=ON_QA"] stdout = await self.execute_command_with_logging(cmd) diff --git a/pyartcd/pyartcd/pipelines/promote.py b/pyartcd/pyartcd/pipelines/promote.py index 5849e1bb08..450af58cfd 100644 --- a/pyartcd/pyartcd/pipelines/promote.py +++ b/pyartcd/pyartcd/pipelines/promote.py @@ -25,7 +25,7 @@ go_arch_for_brew_arch, go_suffix_for_arch, ) -from artcommonlib.assembly import AssemblyTypes, assembly_config_struct +from artcommonlib.assembly import AssemblyTypes, assembly_basis, assembly_config_struct, assembly_own_issues_config from artcommonlib.constants import REGISTRY_CI_OPENSHIFT, REGISTRY_QUAY_OCP_RELEASE_DEV from artcommonlib.exceptions import VerificationError from artcommonlib.exectools import manifest_tool, manifest_tool_auth_file, to_thread @@ -299,7 +299,24 @@ async def _run_pipeline(self): else: logger.info("Checking for blocker bugs...") try: - await self.check_blocker_bugs() + releases_model = Model(releases_config) + has_basis = bool(assembly_basis(releases_model, self.assembly).assembly) + skip_blocker_check = False + if has_basis: + # Note: only reads issues.include (not include! or include?); operator-suffixed variants are not handled. + issues_config = assembly_own_issues_config(releases_model, self.assembly) + included_bug_ids = {str(i["id"]) for i in issues_config.include} + if included_bug_ids: + logger.info( + "Skipping blocker bug check: assembly %s is a targeted fix on basis assembly, " + "shipping %d bug(s): %s", + self.assembly, + len(included_bug_ids), + ", ".join(sorted(included_bug_ids)), + ) + skip_blocker_check = True + if not skip_blocker_check: + await self.check_blocker_bugs() except VerificationError as err: logger.warn("Blocker bugs found for release: %s", err) justification = self._reraise_if_not_permitted(err, "BLOCKER_BUGS", permits) diff --git a/pyartcd/tests/pipelines/test_prepare_release_konflux.py b/pyartcd/tests/pipelines/test_prepare_release_konflux.py index 46b43338de..46a4ff5fd6 100644 --- a/pyartcd/tests/pipelines/test_prepare_release_konflux.py +++ b/pyartcd/tests/pipelines/test_prepare_release_konflux.py @@ -981,3 +981,90 @@ async def test_run_exits_unstable_when_deferred_build_failures_exist( mock_set_shipment_mr_ready.assert_awaited_once() mock_verify_payload.assert_awaited_once() mock_report_deferred_build_failures.assert_awaited_once_with(pipeline.bundle_build_errors) + + async def test_check_blockers_skips_when_basis_assembly_has_included_bugs(self): + """Blocker check skipped when assembly has basis.assembly AND issues.include (targeted fix).""" + pipeline = PrepareReleaseKonfluxPipeline( + slack_client=self.mock_slack_client, + runtime=self.runtime, + group=self.group, + assembly=self.assembly, + ) + pipeline.gitlab_token = self.gitlab_token + pipeline.assembly_type = AssemblyTypes.STANDARD + pipeline.releases_config = Model( + { + "releases": { + "test-assembly": { + "assembly": { + "type": AssemblyTypes.STANDARD.value, + "basis": {"assembly": "test-assembly-parent"}, + "issues": { + "include": [ + {"id": "OCPBUGS-85292"}, + {"id": "OCPBUGS-99999"}, + ], + "exclude": [], + }, + "group": {"product": "ocp", "release_date": "2025-Oct-22"}, + } + } + } + } + ) + pipeline.logger = Mock() + pipeline.execute_command_with_logging = AsyncMock() + + await pipeline.check_blockers() + + # Blocker check skipped — basis assembly + included bugs = targeted fix + pipeline.execute_command_with_logging.assert_not_called() + + async def test_check_blockers_runs_when_no_basis_assembly(self): + """Blocker check still runs when assembly has issues.include but no basis.assembly.""" + pipeline = PrepareReleaseKonfluxPipeline( + slack_client=self.mock_slack_client, + runtime=self.runtime, + group=self.group, + assembly=self.assembly, + ) + pipeline.gitlab_token = self.gitlab_token + pipeline.assembly_type = AssemblyTypes.STANDARD + pipeline.releases_config = Model( + { + "releases": { + "test-assembly": { + "assembly": { + "type": AssemblyTypes.STANDARD.value, + "issues": { + "include": [{"id": "OCPBUGS-85292"}], + }, + } + } + } + } + ) + pipeline.logger = Mock() + pipeline.execute_command_with_logging = AsyncMock(return_value="Found 0 bugs: ") + + await pipeline.check_blockers() + + # Blocker check runs because no basis.assembly + pipeline.execute_command_with_logging.assert_called_once() + + async def test_check_blockers_skips_non_standard_assembly(self): + """Test that check_blockers skips for non-standard assembly types.""" + pipeline = PrepareReleaseKonfluxPipeline( + slack_client=self.mock_slack_client, + runtime=self.runtime, + group=self.group, + assembly=self.assembly, + ) + pipeline.assembly_type = AssemblyTypes.STREAM + pipeline.logger = Mock() + pipeline.execute_command_with_logging = AsyncMock() + + await pipeline.check_blockers() + + # Verify that execute_command_with_logging was not called + pipeline.execute_command_with_logging.assert_not_called() diff --git a/pyartcd/tests/pipelines/test_promote.py b/pyartcd/tests/pipelines/test_promote.py index 74f70d3127..308c18ebef 100644 --- a/pyartcd/tests/pipelines/test_promote.py +++ b/pyartcd/tests/pipelines/test_promote.py @@ -2160,3 +2160,89 @@ async def test_push_manifest_list_uses_registry_config( str(Path(temp_dir) / "4.10.99.manifest-list.yaml"), ], ) + + @patch("pyartcd.pipelines.promote.RegistryConfig") + @patch("pyartcd.jira_client.JIRAClient.from_url", return_value=None) + @patch( + "pyartcd.pipelines.promote.util.load_releases_config", + return_value={ + "releases": { + "4.10.99": { + "assembly": { + "type": "standard", + "basis": {"assembly": "4.10.98"}, + "issues": { + "include": [{"id": "OCPBUGS-85292"}, {"id": "OCPBUGS-99999"}], + }, + } + } + } + }, + ) + @patch( + "pyartcd.pipelines.promote.util.load_group_config", + return_value=Model(dict(arches=["x86_64", "s390x"], upgrades="4.10.98,4.9.99", advisories={"image": 2})), + ) + async def test_run_skips_blocker_check_when_basis_assembly_has_included_bugs( + self, load_group_config: AsyncMock, load_releases_config: AsyncMock, _, __ + ): + """Blocker bug check skipped when assembly has basis.assembly AND issues.include (targeted fix).""" + runtime = MagicMock( + config={ + "build_config": { + "ocp_build_data_url": "https://example.com/ocp-build-data.git", + }, + "jira": { + "url": JIRA_SERVER_URL, + }, + }, + working_dir=Path("/path/to/working"), + dry_run=False, + ) + runtime.new_slack_client.return_value = AsyncMock() + runtime.new_slack_client.return_value.say.return_value = {'message': {'ts': ''}} + runtime.new_slack_client.return_value.bind_channel = MagicMock() + + pipeline = await PromotePipeline.create( + runtime, group="openshift-4.10", assembly="4.10.99", signing_env="prod", skip_sigstore=True + ) + pipeline.check_blocker_bugs = AsyncMock() + pipeline.change_advisory_state_qe = AsyncMock() + pipeline.get_advisory_info = AsyncMock(return_value={"id": 2, "errata_id": 2, "status": "QE"}) + + # Pipeline will fail later (no live ID), but blocker check should already have been skipped + with self.assertRaises(Exception): + await pipeline.run() + + pipeline.check_blocker_bugs.assert_not_called() + + @patch("pyartcd.pipelines.promote.exectools.cmd_gather_async") + async def test_check_blocker_bugs_no_excluded_bugs(self, cmd_gather_async: AsyncMock): + """Test that check_blocker_bugs works without excluded bugs.""" + runtime = MagicMock( + config={ + "build_config": { + "ocp_build_data_url": "https://example.com/ocp-build-data.git", + }, + "jira": { + "url": JIRA_SERVER_URL, + }, + }, + dry_run=False, + logger=MagicMock(), + new_slack_client=MagicMock(return_value=AsyncMock()), + ) + with tempfile.TemporaryDirectory() as temp_dir: + runtime.working_dir = Path(temp_dir) + pipeline = PromotePipeline(runtime, group="openshift-4.10", assembly="4.10.99", signing_env="prod") + + # Mock exectools.cmd_gather_async to return "Found 0 bugs" + cmd_gather_async.return_value = ("", "Found 0 bugs: ", "") + + # Call check_blocker_bugs without excluded bug IDs + await pipeline.check_blocker_bugs() + + # Verify the command does not include --exclude-bugs + call_args = cmd_gather_async.call_args[0][0] + self.assertIn("find-bugs:blocker", call_args) + self.assertNotIn("--exclude-bugs", call_args)