diff --git a/ymir/agents/tests/unit/test_triage_agent.py b/ymir/agents/tests/unit/test_triage_agent.py index 4f3b60ba3..1efa1d355 100644 --- a/ymir/agents/tests/unit/test_triage_agent.py +++ b/ymir/agents/tests/unit/test_triage_agent.py @@ -7,7 +7,6 @@ TriageState, _build_reproducer_input, _map_version_to_module_branch, - _parse_module_summary, _should_update_jira, determine_target_branch, ) @@ -23,7 +22,7 @@ TriageEligibility, TriageOutputSchema, ) -from ymir.common.version_utils import is_modular +from ymir.common.version_utils import is_modular, parse_module_stream @pytest.mark.parametrize( @@ -254,7 +253,7 @@ def test_is_modular(summary, downstream_component, expected): ], ) def test_parse_module_summary(summary, downstream_component, expected_module, expected_stream): - result = _parse_module_summary(summary, downstream_component) + result = parse_module_stream(summary, downstream_component) assert result is not None module, stream = result assert module == expected_module @@ -262,11 +261,11 @@ def test_parse_module_summary(summary, downstream_component, expected_module, ex def test_parse_module_summary_non_modular(): - assert _parse_module_summary("postgresql:PostgreSQL: vuln", "postgresql") is None + assert parse_module_stream("postgresql:PostgreSQL: vuln", "postgresql") is None def test_parse_module_summary_package_mismatch(): - assert _parse_module_summary("postgresql:12/postgresql:vuln", "nginx") is None + assert parse_module_stream("postgresql:12/postgresql:vuln", "nginx") is None # --- Modular branch mapping tests --- diff --git a/ymir/agents/triage_agent.py b/ymir/agents/triage_agent.py index 1f230110d..c57433834 100644 --- a/ymir/agents/triage_agent.py +++ b/ymir/agents/triage_agent.py @@ -1,7 +1,6 @@ import asyncio import logging import os -import re import shutil import sys import traceback @@ -69,11 +68,11 @@ get_latest_candidate_build, ) from ymir.common.version_utils import ( - MODULAR_SUMMARY_PREFIX, construct_internal_branch_name, is_modular, is_older_zstream, normalize_fix_version, + parse_module_stream, parse_rhel_version, ) from ymir.tools.privileged.utils import APPLICABILITY_DIR @@ -187,24 +186,6 @@ async def _enqueue_reproducer(redis, state, user_triggered: bool) -> None: logger.info("Pushed %s to %s", state.jira_issue, queue) -def _modular_summary_re(downstream_component: str) -> re.Pattern[str]: - """Build a modular-summary regex anchored on the Downstream Component Name.""" - return re.compile(MODULAR_SUMMARY_PREFIX + re.escape(downstream_component) + r":") - - -def _parse_module_summary(summary: str, downstream_component: str) -> tuple[str, str] | None: - """Extract module name and stream from a modular Jira summary. - - Requires the component segment to match *downstream_component*. - E.g. summary ``postgresql:12/postgresql:…`` + package ``postgresql`` - → ``("postgresql", "12")``. - """ - m = _modular_summary_re(downstream_component).match(summary) - if not m: - return None - return m.group(1), m.group(2) - - def _map_version_to_module_branch(version: str, summary: str, downstream_component: str) -> str | None: """Map version string to a modular target branch. @@ -221,7 +202,7 @@ def _map_version_to_module_branch(version: str, summary: str, downstream_compone logger.warning(f"Failed to parse version for modular branch: {version}") return None - parsed_module = _parse_module_summary(summary, downstream_component) + parsed_module = parse_module_stream(summary, downstream_component) if not parsed_module: logger.warning( f"Failed to parse module/stream from summary={summary!r} " diff --git a/ymir/common/tests/unit/test_version_utils.py b/ymir/common/tests/unit/test_version_utils.py index e1972f48d..f62df1eb3 100644 --- a/ymir/common/tests/unit/test_version_utils.py +++ b/ymir/common/tests/unit/test_version_utils.py @@ -5,6 +5,7 @@ get_maintenance_rhel_branch, is_older_zstream, parse_branch_name, + parse_module_stream, parse_rhel_version, parse_zstream_branch_name, ) @@ -176,3 +177,25 @@ async def mock_load_rhel_config(): flexmock(config).should_receive("load_rhel_config").replace_with(mock_load_rhel_config) result = await get_maintenance_rhel_branch(branch) assert result == expected + + +@pytest.mark.parametrize( + "summary, component, expected", + [ + ("CVE-2025-12345 postgresql:16/postgresql: overflow", "postgresql", ("postgresql", "16")), + ("CVE-2025-12345 postgresql:18/postgresql: overflow", "postgresql", ("postgresql", "18")), + ("postgresql:12/postgresql: overflow", "postgresql", ("postgresql", "12")), + ("CVE-2025-12345 buffer overflow in curl", "curl", None), + ( + "CVE-2025-12345 CVE-2025-67890 postgresql:16/postgresql: overflow", + "postgresql", + ("postgresql", "16"), + ), + ("CVE-2025-12345 postgresql:16/postgis: overflow", "postgis", ("postgresql", "16")), + (None, "postgresql", None), + ("CVE-2025-12345 postgresql:16/postgresql: overflow", None, None), + ("", "postgresql", None), + ], +) +def test_parse_module_stream(summary, component, expected): + assert parse_module_stream(summary, component) == expected diff --git a/ymir/common/version_utils.py b/ymir/common/version_utils.py index e87fc8077..8153d2c0a 100644 --- a/ymir/common/version_utils.py +++ b/ymir/common/version_utils.py @@ -249,10 +249,27 @@ async def is_older_zstream( return target_minor < current_minor -MODULAR_SUMMARY_PREFIX = r"^(?:CVE-\d{4}-\d+\s+)?([\w.+-]+):([^/\s]+)/" +MODULAR_SUMMARY_PREFIX = r"^(?:\S+\s+)*([\w.+-]+):([^/\s]+)/" def is_modular(summary: str | None, component: str | None) -> bool: if not summary or not component: return False return bool(re.match(MODULAR_SUMMARY_PREFIX + re.escape(component) + r":", summary)) + + +def parse_module_stream(summary: str | None, component: str | None) -> tuple[str, str] | None: + """Extract module name and stream from a modular Jira summary. + + Requires the component segment after ``/`` to match *component*. + E.g. summary ``"postgresql:16/postgis: …"`` + component ``"postgis"`` + → ``("postgresql", "16")``. + + Returns ``None`` when the summary is not modular or does not match. + """ + if not summary or not component: + return None + m = re.match(MODULAR_SUMMARY_PREFIX + re.escape(component) + r":", summary) + if not m: + return None + return m.group(1), m.group(2) diff --git a/ymir/tools/privileged/jira.py b/ymir/tools/privileged/jira.py index 9b21b10e8..cf3915abc 100644 --- a/ymir/tools/privileged/jira.py +++ b/ymir/tools/privileged/jira.py @@ -25,9 +25,9 @@ from ymir.common.version_utils import ( get_fix_version_variants, get_maintenance_majors, - is_modular, normalize_fix_version, nvr_to_cs_nvr, + parse_module_stream, parse_rhel_version, ) from ymir.tools.base import CloneableTool as Tool @@ -510,10 +510,14 @@ async def _check_duplicate_tracker( component: str, fix_version: str, issue_key: str, - modular: bool = False, + module_stream: tuple[str, str] | None = None, ) -> tuple[str | None, bool]: """Check for duplicate CVE trackers (same CVE + component + fix version). + *module_stream* is the ``(module, stream)`` pair of the current issue + (e.g. ``("postgresql", "16")``), or ``None`` for non-modular issues. + Candidates must have the exact same module:stream to count as duplicates. + Returns (older_tracker_key, should_block): - (None, False): no duplicate found - ("RHEL-123", True): active older tracker exists, should block triage @@ -536,7 +540,9 @@ async def _check_duplicate_tracker( input={"jql": jql, "fields": ["status", "resolution", "summary"], "max_results": 50} ) issues = [ - i for i in output.result if is_modular(i.get("fields", {}).get("summary"), component) == modular + i + for i in output.result + if parse_module_stream(i.get("fields", {}).get("summary"), component) == module_stream ] if not issues: @@ -961,7 +967,7 @@ async def _check_for_duplicate( component, target_version, issue_key, - modular=is_modular(summary, component), + module_stream=parse_module_stream(summary, component), ) except Exception as e: logger.warning(f"Duplicate tracker check failed for {issue_key}: {e}") diff --git a/ymir/tools/privileged/tests/unit/test_jira.py b/ymir/tools/privileged/tests/unit/test_jira.py index ab4e1d49e..1b7167e45 100644 --- a/ymir/tools/privileged/tests/unit/test_jira.py +++ b/ymir/tools/privileged/tests/unit/test_jira.py @@ -1587,12 +1587,132 @@ async def test_check_duplicate_modular_candidate_filtered_out(): "postgresql", "rhel-9.2.0.z", "RHEL-500", - modular=False, + module_stream=None, ) assert dup_key is None assert should_block is False +@pytest.mark.asyncio +async def test_check_duplicate_same_module_stream_is_duplicate(): + """Modular issue with same module:stream should be flagged as duplicate.""" + search_result = [ + { + "key": "RHEL-100", + "fields": { + "status": {"name": "New"}, + "resolution": None, + "summary": "CVE-2025-12345 postgresql:16/postgresql: overflow", + }, + }, + ] + flexmock(SearchJiraIssuesTool).should_receive("run").and_return( + _create_async_return(JSONToolOutput(result=search_result)) + ).once() + + dup_key, should_block = await _check_duplicate_tracker( + "CVE-2025-12345", + "postgresql", + "rhel-9.6.z", + "RHEL-500", + module_stream=("postgresql", "16"), + ) + assert dup_key == "RHEL-100" + assert should_block is True + + +@pytest.mark.asyncio +async def test_check_duplicate_different_module_stream_not_duplicate(): + """Modular issues with different streams (e.g. postgresql:16 vs :18) are NOT duplicates.""" + search_result = [ + { + "key": "RHEL-100", + "fields": { + "status": {"name": "New"}, + "resolution": None, + "summary": "CVE-2025-12345 postgresql:16/postgresql: overflow", + }, + }, + ] + flexmock(SearchJiraIssuesTool).should_receive("run").and_return( + _create_async_return(JSONToolOutput(result=search_result)) + ).once() + + dup_key, should_block = await _check_duplicate_tracker( + "CVE-2025-12345", + "postgresql", + "rhel-9.6.z", + "RHEL-500", + module_stream=("postgresql", "18"), + ) + assert dup_key is None + assert should_block is False + + +@pytest.mark.asyncio +async def test_check_duplicate_modular_issue_no_match_for_nonmodular_candidate(): + """Modular issue (postgresql:16) should not match a non-modular candidate.""" + search_result = [ + { + "key": "RHEL-100", + "fields": { + "status": {"name": "New"}, + "resolution": None, + "summary": "CVE-2025-12345 buffer overflow in postgresql [rhel-9.6.z]", + }, + }, + ] + flexmock(SearchJiraIssuesTool).should_receive("run").and_return( + _create_async_return(JSONToolOutput(result=search_result)) + ).once() + + dup_key, should_block = await _check_duplicate_tracker( + "CVE-2025-12345", + "postgresql", + "rhel-9.6.z", + "RHEL-500", + module_stream=("postgresql", "16"), + ) + assert dup_key is None + assert should_block is False + + +@pytest.mark.asyncio +async def test_check_duplicate_mixed_streams_picks_matching(): + """Only candidates with the same stream match; others are filtered out.""" + search_result = [ + { + "key": "RHEL-100", + "fields": { + "status": {"name": "New"}, + "resolution": None, + "summary": "CVE-2025-12345 postgresql:18/postgresql: overflow", + }, + }, + { + "key": "RHEL-200", + "fields": { + "status": {"name": "New"}, + "resolution": None, + "summary": "CVE-2025-12345 postgresql:16/postgresql: overflow", + }, + }, + ] + flexmock(SearchJiraIssuesTool).should_receive("run").and_return( + _create_async_return(JSONToolOutput(result=search_result)) + ).once() + + dup_key, should_block = await _check_duplicate_tracker( + "CVE-2025-12345", + "postgresql", + "rhel-9.6.z", + "RHEL-500", + module_stream=("postgresql", "16"), + ) + assert dup_key == "RHEL-200" + assert should_block is True + + # --- Eligibility tool: duplicate tracker integration tests --- @@ -1617,7 +1737,7 @@ async def test_eligibility_zstream_blocking_duplicate(): ) ).once() flexmock(jira_tools).should_receive("_check_duplicate_tracker").with_args( - "CVE-2025-12345", "curl", "rhel-9.6.z", "RHEL-12345", modular=False + "CVE-2025-12345", "curl", "rhel-9.6.z", "RHEL-12345", module_stream=None ).and_return(_create_async_return(("RHEL-100", True))).once() result = (await CheckCveTriageEligibilityTool().run(input={"issue_key": "RHEL-12345"})).result @@ -1647,7 +1767,7 @@ async def test_eligibility_zstream_nonblocking_duplicate(): ) ).once() flexmock(jira_tools).should_receive("_check_duplicate_tracker").with_args( - "CVE-2025-12345", "curl", "rhel-9.6.z", "RHEL-12345", modular=False + "CVE-2025-12345", "curl", "rhel-9.6.z", "RHEL-12345", module_stream=None ).and_return(_create_async_return(("RHEL-100", False))).once() result = (await CheckCveTriageEligibilityTool().run(input={"issue_key": "RHEL-12345"})).result @@ -1676,7 +1796,7 @@ async def test_eligibility_no_duplicate(): ) ).once() flexmock(jira_tools).should_receive("_check_duplicate_tracker").with_args( - "CVE-2025-12345", "curl", "rhel-9.6.z", "RHEL-12345", modular=False + "CVE-2025-12345", "curl", "rhel-9.6.z", "RHEL-12345", module_stream=None ).and_return(_create_async_return((None, False))).once() result = (await CheckCveTriageEligibilityTool().run(input={"issue_key": "RHEL-12345"})).result @@ -1705,7 +1825,11 @@ async def test_eligibility_modular_filters_nonmodular_duplicates(): ) ).once() flexmock(jira_tools).should_receive("_check_duplicate_tracker").with_args( - "CVE-2025-12345", "postgresql", "rhel-9.6.z", "RHEL-12345", modular=True + "CVE-2025-12345", + "postgresql", + "rhel-9.6.z", + "RHEL-12345", + module_stream=("postgresql", "15"), ).and_return(_create_async_return((None, False))).once() result = (await CheckCveTriageEligibilityTool().run(input={"issue_key": "RHEL-12345"})).result