From 868db96e6d7ac80edb4318526e54684200c3e2c4 Mon Sep 17 00:00:00 2001 From: Maja Massarini Date: Tue, 1 Sep 2026 11:37:16 +0200 Subject: [PATCH 1/6] Fix: Triage Y-stream CVEs when Z-stream clones are NOT_AFFECTED MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes PACKIT-5281: Y-stream CVEs were incorrectly skipped or postponed when Z-stream clones were not affected. The CVE eligibility check would say "fix is handled via Z-stream CentOS path" or "waiting for Z-stream to ship" without checking if the Z-streams were actually triaged as NOT_AFFECTED. This caused maintainers to manually close Y-stream issues that should have been automatically triaged and marked as not affected. Changes: - Add _check_zstream_not_affected(): searches for Z-stream clones with ymir_triaged_not_affected label - Add _check_zstream_pending_triage(): searches for Z-stream clones without any terminal ymir_triaged* labels - Modify _check_lowmod_ystream_eligibility(): for Low/Moderate Y-stream CVEs with CS_FIRST approach detected: * First check if Z-stream was NOT_AFFECTED → return IMMEDIATELY (triage Y-stream) * Then check if Z-stream pending triage → return PENDING_DEPENDENCIES (wait) * Otherwise → return NEVER (existing behavior - skip Y-stream) - Modify _check_for_dependency_blocker(): for Important/Critical Y-stream CVEs: * Check if Z-stream was NOT_AFFECTED before postponing * If yes → return None (proceed with triage, same as if clone had shipped) Example scenarios fixed: - RHEL-214038 (rhel-9.9, Moderate): was told "CentOS Stream path", now will be triaged when Z-stream is not affected - RHEL-224798, RHEL-224847 (rhel-10.3/9.9, Important): were postponed waiting for Z-stream, now will be triaged when Z-stream is not affected Assisted-by: Claude Sonnet 4.5 --- ymir/tools/privileged/jira.py | 251 ++++++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) diff --git a/ymir/tools/privileged/jira.py b/ymir/tools/privileged/jira.py index ebce8214c..102fe0c87 100644 --- a/ymir/tools/privileged/jira.py +++ b/ymir/tools/privileged/jira.py @@ -795,6 +795,159 @@ def _build_rhel_first_result(clone_key: str, detail: str) -> tuple[FixApproach, ) +async def _get_applicable_zstream_variants(major_version: str) -> set[str] | None: + """Get the applicable Z-stream version variants for a major version. + + Uses version mapper precedence: upcoming Z-stream first, falls back to current. + This prevents older current-stream clones from overriding applicable upcoming clones. + + Returns a set of lowercase variant strings (e.g., {"rhel-9.9.z"}), or None if + no applicable Z-stream exists or the major version is in maintenance. + """ + rhel_config = await load_rhel_config() + current_z_streams = rhel_config.get("current_z_streams", {}) + upcoming_z_streams = rhel_config.get("upcoming_z_streams", {}) + maintenance_majors = get_maintenance_majors(rhel_config) + + if major_version in maintenance_majors: + logger.info(f"Major version {major_version} is in maintenance, no applicable Z-stream") + return None + + # Use version mapper precedence: upcoming first, fall back to current + applicable_z_stream = upcoming_z_streams.get(major_version) or current_z_streams.get(major_version) + + if not applicable_z_stream: + logger.info(f"No applicable Z-stream found for major version {major_version}") + return None + + variants = {variant.lower() for variant in get_fix_version_variants(applicable_z_stream)} + logger.info(f"Applicable Z-stream for RHEL-{major_version}: {applicable_z_stream} (variants: {variants})") + return variants + + +async def _check_zstream_not_affected( + cve_id: str, component: str, exclude_key: str, major_version: str +) -> list[str]: + """Check if any Z-stream clone was triaged as NOT_AFFECTED. + + Used for Y-stream CVEs where we detected a CS_FIRST approach or are waiting + for Z-stream to ship. Before skipping or postponing the Y-stream, we check + if the Z-stream clones were actually not affected — in that case, the + Y-stream should also be triaged to confirm it's not affected. + + Returns list of Z-stream issue keys that have ymir_triaged_not_affected label. + """ + escaped_cve_id = cve_id.replace('"', '\\"') + escaped_component = component.replace('"', '\\"') + jql = ( + f'summary ~ "{escaped_cve_id}" AND component = "{escaped_component}"' + f' AND labels = "SecurityTracking" AND labels = "ymir_triaged_not_affected"' + f' AND key != "{exclude_key}"' + ) + logger.info(f"Checking for NOT_AFFECTED Z-stream clones for {cve_id} (major={major_version})") + + tool = SearchJiraIssuesTool() + output = await tool.run( + input={ + "jql": jql, + "fields": ["fixVersions"], + "max_results": 50, + } + ) + issues = output.result or [] + + relevant_z_streams = await _get_applicable_zstream_variants(major_version) + if not relevant_z_streams: + return [] + + not_affected_keys = [] + for issue in issues: + key = issue.get("key", "") + fix_versions = issue.get("fields", {}).get("fixVersions", []) + fv_names = [fv.get("name", "") for fv in fix_versions] + if any(fv.lower() in relevant_z_streams for fv in fv_names): + logger.info(f" {key}: fixVersions={fv_names} — NOT_AFFECTED Z-stream clone found") + not_affected_keys.append(key) + + if not_affected_keys: + logger.info( + f"Found {len(not_affected_keys)} NOT_AFFECTED Z-stream clone(s) for {cve_id}: {not_affected_keys}" + ) + else: + logger.info(f"No NOT_AFFECTED Z-stream clones found for {cve_id} (major={major_version})") + + return not_affected_keys + + +async def _check_zstream_pending_triage( + cve_id: str, component: str, exclude_key: str, major_version: str +) -> list[str]: + """Check if Z-stream clones exist but haven't been triaged yet. + + Used for Y-stream CVEs where CS_FIRST approach was detected. If Z-stream + clones exist but don't have any ymir_triaged* terminal labels, we should + wait for them to be triaged before deciding whether the Y-stream should be + skipped or triaged. + + Returns list of Z-stream issue keys without ymir_triaged* terminal labels. + """ + escaped_cve_id = cve_id.replace('"', '\\"') + escaped_component = component.replace('"', '\\"') + # Search for Z-stream clones without any terminal labels. + # Terminal labels indicate triage completion (success or blocked state): + # - ymir_triaged_* (backport/rebase/rebuild/postponed/not_affected/generic) + # - ymir_needs_attention (clarification needed - blocked) + # - ymir_triage_errored (exhausted retries - blocked) + # Non-terminal labels like ymir_triage_in_progress are not excluded. + jql = ( + f'summary ~ "{escaped_cve_id}" AND component = "{escaped_component}"' + f' AND labels = "SecurityTracking"' + f' AND labels != "ymir_triaged_backport"' + f' AND labels != "ymir_triaged_rebase"' + f' AND labels != "ymir_triaged_rebuild"' + f' AND labels != "ymir_triaged_postponed"' + f' AND labels != "ymir_triaged_not_affected"' + f' AND labels != "ymir_triaged"' + f' AND labels != "ymir_needs_attention"' + f' AND labels != "ymir_triage_errored"' + f' AND key != "{exclude_key}"' + ) + logger.info(f"Checking for pending-triage Z-stream clones for {cve_id} (major={major_version})") + + tool = SearchJiraIssuesTool() + output = await tool.run( + input={ + "jql": jql, + "fields": ["fixVersions", "labels"], + "max_results": 50, + } + ) + issues = output.result or [] + + relevant_z_streams = await _get_applicable_zstream_variants(major_version) + if not relevant_z_streams: + return [] + + pending_keys = [] + for issue in issues: + key = issue.get("key", "") + fix_versions = issue.get("fields", {}).get("fixVersions", []) + fv_names = [fv.get("name", "") for fv in fix_versions] + labels = issue.get("fields", {}).get("labels", []) + if any(fv.lower() in relevant_z_streams for fv in fv_names): + logger.info(f" {key}: fixVersions={fv_names}, labels={labels} — pending triage") + pending_keys.append(key) + + if pending_keys: + logger.info( + f"Found {len(pending_keys)} pending-triage Z-stream clone(s) for {cve_id}: {pending_keys}" + ) + else: + logger.info(f"No pending-triage Z-stream clones found for {cve_id} (major={major_version})") + + return pending_keys + + class CheckCveTriageEligibilityToolInput(BaseModel): issue_key: str = Field(description="Jira issue key (e.g. RHEL-12345)") @@ -999,6 +1152,33 @@ async def _check_for_dependency_blocker( ) return None, dependency.shipped_candidates + # Before postponing, check if any Z-stream clones were NOT_AFFECTED + parsed = parse_rhel_version(target_version) + major_version = parsed[0] if parsed else None + if major_version: + try: + not_affected_clones = await _check_zstream_not_affected( + cve_id, component, issue_key, major_version + ) + except Exception as e: + logger.warning(f"Z-stream NOT_AFFECTED check failed for {cve_id}: {e}") + return JSONToolOutput( + CVEEligibilityResult( + is_cve=True, + eligibility=TriageEligibility.NEVER, + reason=f"CVE {cve_id} ({target_version}): NOT_AFFECTED check failed: {e}", + error=str(e), + ).model_dump() + ) + + if not_affected_clones: + logger.info( + f"Z-stream clone(s) {not_affected_clones} for {cve_id} were NOT_AFFECTED, " + "Y-stream should also be triaged" + ) + # Return None to proceed with triage (same as if a clone had shipped) + return None + logger.info( f"Dependency check for {issue_key} ({target_version}): PENDING_DEPENDENCIES " f"(no clones shipped yet, waiting for: {dependency.pending_keys})" @@ -1193,6 +1373,77 @@ async def _check_lowmod_ystream_eligibility( ) if approach is FixApproach.CS_FIRST: + # Before skipping the Y-stream, check if Z-stream clones were NOT_AFFECTED + try: + not_affected_clones = await _check_zstream_not_affected( + cve_id, component, issue_key, major_version + ) + except Exception as e: + logger.warning(f"Z-stream NOT_AFFECTED check failed for {cve_id}: {e}") + return JSONToolOutput( + CVEEligibilityResult( + is_cve=True, + eligibility=TriageEligibility.NEVER, + reason=f"CVE {cve_id} ({target_version}): NOT_AFFECTED check failed: {e}", + error=str(e), + ).model_dump() + ) + + if not_affected_clones: + logger.info( + f"Z-stream clone(s) {not_affected_clones} for {cve_id} were NOT_AFFECTED, " + "Y-stream should also be triaged" + ) + return JSONToolOutput( + CVEEligibilityResult( + is_cve=True, + eligibility=TriageEligibility.IMMEDIATELY, + reason=( + f"Y-stream CVE ({target_version}, {severity} severity): " + f"Z-stream clone {not_affected_clones[0]} was not affected, " + "checking if Y-stream is also not affected" + ), + needs_internal_fix=False, + duplicate_of=duplicate_of, + ).model_dump() + ) + + # Check if Z-stream clones exist but haven't been triaged yet + try: + pending_triage = await _check_zstream_pending_triage( + cve_id, component, issue_key, major_version + ) + except Exception as e: + logger.warning(f"Z-stream pending triage check failed for {cve_id}: {e}") + return JSONToolOutput( + CVEEligibilityResult( + is_cve=True, + eligibility=TriageEligibility.NEVER, + reason=f"CVE {cve_id} ({target_version}): pending triage check failed: {e}", + error=str(e), + ).model_dump() + ) + + if pending_triage: + logger.info( + f"Z-stream clone(s) {pending_triage} for {cve_id} pending triage, " + "waiting for results before deciding Y-stream eligibility" + ) + return JSONToolOutput( + CVEEligibilityResult( + is_cve=True, + eligibility=TriageEligibility.PENDING_DEPENDENCIES, + reason=( + f"Y-stream CVE ({target_version}, {severity} severity): " + f"waiting for Z-stream clone triage results to determine if CVE is affected " + f"(CentOS Stream first approach detected via {detail})" + ), + needs_internal_fix=False, + pending_zstream_issues=pending_triage, + ).model_dump() + ) + + # Z-stream clones were triaged and ARE affected (CS-first path applies) return JSONToolOutput( CVEEligibilityResult( is_cve=True, From 18524f505c969e386176d5cfc48557a1b9a59881 Mon Sep 17 00:00:00 2001 From: Maja Massarini Date: Tue, 1 Sep 2026 13:39:33 +0200 Subject: [PATCH 2/6] Add unit tests for Z-stream NOT_AFFECTED and pending-triage checks Adds comprehensive unit tests for the new Z-stream status check functions introduced in PACKIT-5281. Test coverage: - _get_applicable_zstream_variants(): * Upcoming Z-stream takes precedence over current * Falls back to current when no upcoming exists * Returns None for non-existent or maintenance versions - _check_zstream_not_affected(): * Finds Z-stream clones with ymir_triaged_not_affected label * Filters by applicable Z-stream version (upcoming > current) * Ignores old current Z-stream when upcoming exists * Returns empty list when no applicable clones found - _check_zstream_pending_triage(): * Finds Z-stream clones without terminal labels * Excludes clones with terminal labels (handled by JQL) * Filters by applicable Z-stream version * Ignores old current Z-stream when upcoming exists * Returns empty list when no applicable clones found Test patterns follow existing conventions: - Uses flexmock for mocking external dependencies - Mocks SearchJiraIssuesTool.run() and load_rhel_config() - Uses RHEL_CONFIG fixture matching production structure - Tests both positive and edge cases Assisted-by: Claude Sonnet 4.5 --- ymir/tools/privileged/jira.py | 55 +-- ymir/tools/privileged/tests/unit/test_jira.py | 268 +++++++++++++ .../tests/unit/test_jira_zstream_status.py | 372 ++++++++++++++++++ 3 files changed, 671 insertions(+), 24 deletions(-) create mode 100644 ymir/tools/privileged/tests/unit/test_jira_zstream_status.py diff --git a/ymir/tools/privileged/jira.py b/ymir/tools/privileged/jira.py index 102fe0c87..d68555c09 100644 --- a/ymir/tools/privileged/jira.py +++ b/ymir/tools/privileged/jira.py @@ -837,6 +837,12 @@ async def _check_zstream_not_affected( Returns list of Z-stream issue keys that have ymir_triaged_not_affected label. """ + # Check if there's an applicable Z-stream first (early return to avoid unnecessary Jira search) + relevant_z_streams = await _get_applicable_zstream_variants(major_version) + if not relevant_z_streams: + logger.info(f"No applicable Z-stream for major version {major_version}, skipping NOT_AFFECTED check") + return [] + escaped_cve_id = cve_id.replace('"', '\\"') escaped_component = component.replace('"', '\\"') jql = ( @@ -856,10 +862,6 @@ async def _check_zstream_not_affected( ) issues = output.result or [] - relevant_z_streams = await _get_applicable_zstream_variants(major_version) - if not relevant_z_streams: - return [] - not_affected_keys = [] for issue in issues: key = issue.get("key", "") @@ -891,6 +893,14 @@ async def _check_zstream_pending_triage( Returns list of Z-stream issue keys without ymir_triaged* terminal labels. """ + # Check if there's an applicable Z-stream first (early return to avoid unnecessary Jira search) + relevant_z_streams = await _get_applicable_zstream_variants(major_version) + if not relevant_z_streams: + logger.info( + f"No applicable Z-stream for major version {major_version}, skipping pending-triage check" + ) + return [] + escaped_cve_id = cve_id.replace('"', '\\"') escaped_component = component.replace('"', '\\"') # Search for Z-stream clones without any terminal labels. @@ -924,10 +934,6 @@ async def _check_zstream_pending_triage( ) issues = output.result or [] - relevant_z_streams = await _get_applicable_zstream_variants(major_version) - if not relevant_z_streams: - return [] - pending_keys = [] for issue in issues: key = issue.get("key", "") @@ -1357,22 +1363,7 @@ async def _check_lowmod_ystream_eligibility( ).model_dump() ) - if approach is FixApproach.PENDING: - return JSONToolOutput( - CVEEligibilityResult( - is_cve=True, - eligibility=TriageEligibility.PENDING_DEPENDENCIES, - reason=( - f"Y-stream CVE ({target_version}, {severity} severity): " - f"waiting for RHEL-{major_version} Z-stream clone Fixed in Build " - "to determine fix path (CentOS Stream first or RHEL first approach)" - ), - needs_internal_fix=False, - pending_zstream_issues=pending_keys, - ).model_dump() - ) - - if approach is FixApproach.CS_FIRST: + if approach is FixApproach.PENDING or approach is FixApproach.CS_FIRST: # Before skipping the Y-stream, check if Z-stream clones were NOT_AFFECTED try: not_affected_clones = await _check_zstream_not_affected( @@ -1443,6 +1434,22 @@ async def _check_lowmod_ystream_eligibility( ).model_dump() ) + # No NOT_AFFECTED clones and no pending-triage clones found + if approach is FixApproach.PENDING: + # Still waiting for Fixed in Build to determine fix approach + return JSONToolOutput( + CVEEligibilityResult( + is_cve=True, + eligibility=TriageEligibility.PENDING_DEPENDENCIES, + reason=( + f"Y-stream CVE ({target_version}, {severity} severity): " + f"waiting for RHEL-{major_version} Z-stream clone Fixed in Build " + "to determine fix path (CentOS Stream first or RHEL first approach)" + ), + needs_internal_fix=False, + pending_zstream_issues=pending_keys, + ).model_dump() + ) # Z-stream clones were triaged and ARE affected (CS-first path applies) return JSONToolOutput( CVEEligibilityResult( diff --git a/ymir/tools/privileged/tests/unit/test_jira.py b/ymir/tools/privileged/tests/unit/test_jira.py index 86cf965d5..8bc482c7a 100644 --- a/ymir/tools/privileged/tests/unit/test_jira.py +++ b/ymir/tools/privileged/tests/unit/test_jira.py @@ -1311,6 +1311,10 @@ async def test_eligibility_ystream_clones_pending(): ).and_return( _create_async_return(ZStreamDependencyResult(any_shipped=False, pending_keys=["RHEL-999"])) ).once() + # Mock the NOT_AFFECTED check (no NOT_AFFECTED clones found) + flexmock(jira_tools).should_receive("_check_zstream_not_affected").and_return( + _create_async_return([]) + ).once() result = (await CheckCveTriageEligibilityTool().run(input={"issue_key": "RHEL-12345"})).result assert result["eligibility"] == TriageEligibility.PENDING_DEPENDENCIES @@ -1338,6 +1342,14 @@ async def test_eligibility_ystream_low_moderate_pending(severity): flexmock(jira_tools).should_receive("_check_zstream_fix_approach").with_args( "CVE-2025-12345", "curl", "RHEL-12345", "9" ).and_return(_create_async_return((FixApproach.PENDING, ["RHEL-999"], ""))).once() + # Z-stream clone not NOT_AFFECTED + flexmock(jira_tools).should_receive("_check_zstream_not_affected").with_args( + "CVE-2025-12345", "curl", "RHEL-12345", "9" + ).and_return(_create_async_return([])).once() + # Z-stream clone not pending triage (still waiting for Fixed in Build) + flexmock(jira_tools).should_receive("_check_zstream_pending_triage").with_args( + "CVE-2025-12345", "curl", "RHEL-12345", "9" + ).and_return(_create_async_return([])).once() result = (await CheckCveTriageEligibilityTool().run(input={"issue_key": "RHEL-12345"})).result assert result["eligibility"] == TriageEligibility.PENDING_DEPENDENCIES @@ -1369,12 +1381,268 @@ async def test_eligibility_ystream_low_moderate_cs_first(severity): ).and_return( _create_async_return((FixApproach.CS_FIRST, [], "CS build curl-8.0-1.el9 found in CS Koji")) ).once() + # Mock the new Z-stream status checks (no NOT_AFFECTED, no pending) + flexmock(jira_tools).should_receive("_check_zstream_not_affected").and_return( + _create_async_return([]) + ).once() + flexmock(jira_tools).should_receive("_check_zstream_pending_triage").and_return( + _create_async_return([]) + ).once() result = (await CheckCveTriageEligibilityTool().run(input={"issue_key": "RHEL-12345"})).result assert result["eligibility"] == TriageEligibility.NEVER assert "CentOS Stream first approach" in result["reason"] +@pytest.mark.parametrize("severity", ["Low", "Moderate"]) +@pytest.mark.asyncio +async def test_eligibility_ystream_cs_first_zstream_not_affected(severity): + """Low/Moderate Y-stream, CS first, but Z-stream clone is NOT_AFFECTED — IMMEDIATELY.""" + issue = _make_jira_issue( + labels=["SecurityTracking"], + fix_versions=[{"name": "rhel-9.8"}], + summary="CVE-2025-12345 buffer overflow in curl [rhel-9.8]", + severity=severity, + components=[{"name": "curl"}], + ) + flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + flexmock(jira_tools).should_receive("_check_duplicate_tracker").and_return( + _create_async_return((None, False)) + ).once() + flexmock(jira_tools).should_receive("_check_zstream_fix_approach").with_args( + "CVE-2025-12345", "curl", "RHEL-12345", "9" + ).and_return(_create_async_return((FixApproach.CS_FIRST, [], "matching CS build in CS Koji"))).once() + # Z-stream clone was NOT_AFFECTED, so Y-stream should be triaged too + flexmock(jira_tools).should_receive("_check_zstream_not_affected").with_args( + "CVE-2025-12345", "curl", "RHEL-12345", "9" + ).and_return(_create_async_return(["RHEL-999"])).once() + + result = (await CheckCveTriageEligibilityTool().run(input={"issue_key": "RHEL-12345"})).result + assert result["eligibility"] == TriageEligibility.IMMEDIATELY + assert result["needs_internal_fix"] is False + assert "Z-stream clone RHEL-999 was not affected" in result["reason"] + + +@pytest.mark.parametrize("severity", ["Low", "Moderate"]) +@pytest.mark.asyncio +async def test_eligibility_ystream_cs_first_zstream_pending_triage(severity): + """Low/Moderate Y-stream, CS first, Z-stream pending triage — PENDING_DEPENDENCIES.""" + issue = _make_jira_issue( + labels=["SecurityTracking"], + fix_versions=[{"name": "rhel-9.8"}], + summary="CVE-2025-12345 buffer overflow in curl [rhel-9.8]", + severity=severity, + components=[{"name": "curl"}], + ) + flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + flexmock(jira_tools).should_receive("_check_duplicate_tracker").and_return( + _create_async_return((None, False)) + ).once() + flexmock(jira_tools).should_receive("_check_zstream_fix_approach").with_args( + "CVE-2025-12345", "curl", "RHEL-12345", "9" + ).and_return(_create_async_return((FixApproach.CS_FIRST, [], "matching CS build in CS Koji"))).once() + # Z-stream clone not NOT_AFFECTED + flexmock(jira_tools).should_receive("_check_zstream_not_affected").with_args( + "CVE-2025-12345", "curl", "RHEL-12345", "9" + ).and_return(_create_async_return([])).once() + # Z-stream clone pending triage + flexmock(jira_tools).should_receive("_check_zstream_pending_triage").with_args( + "CVE-2025-12345", "curl", "RHEL-12345", "9" + ).and_return(_create_async_return(["RHEL-888"])).once() + + result = (await CheckCveTriageEligibilityTool().run(input={"issue_key": "RHEL-12345"})).result + assert result["eligibility"] == TriageEligibility.PENDING_DEPENDENCIES + assert result["needs_internal_fix"] is False + assert "waiting for Z-stream clone triage results" in result["reason"] + assert result["pending_zstream_issues"] == ["RHEL-888"] + + +@pytest.mark.parametrize("severity", ["Low", "Moderate"]) +@pytest.mark.asyncio +async def test_eligibility_ystream_pending_zstream_not_affected(severity): + """Low/Moderate Y-stream, PENDING approach, but Z-stream clone is NOT_AFFECTED — IMMEDIATELY. + + Regression test: when Z-stream clone is open with ymir_triaged_not_affected label + but no Fixed in Build, _check_zstream_fix_approach returns PENDING. We should + detect the NOT_AFFECTED status and proceed to triage rather than waiting. + """ + issue = _make_jira_issue( + labels=["SecurityTracking"], + fix_versions=[{"name": "rhel-9.8"}], + summary="CVE-2025-12345 buffer overflow in curl [rhel-9.8]", + severity=severity, + components=[{"name": "curl"}], + ) + flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + flexmock(jira_tools).should_receive("_check_duplicate_tracker").and_return( + _create_async_return((None, False)) + ).once() + # Z-stream clone exists but has no Fixed in Build (returns PENDING) + flexmock(jira_tools).should_receive("_check_zstream_fix_approach").with_args( + "CVE-2025-12345", "curl", "RHEL-12345", "9" + ).and_return(_create_async_return((FixApproach.PENDING, ["RHEL-777"], "open clone, no build"))).once() + # But the clone has ymir_triaged_not_affected label + flexmock(jira_tools).should_receive("_check_zstream_not_affected").with_args( + "CVE-2025-12345", "curl", "RHEL-12345", "9" + ).and_return(_create_async_return(["RHEL-777"])).once() + + result = (await CheckCveTriageEligibilityTool().run(input={"issue_key": "RHEL-12345"})).result + assert result["eligibility"] == TriageEligibility.IMMEDIATELY + assert result["needs_internal_fix"] is False + assert "Z-stream clone RHEL-777 was not affected" in result["reason"] + + +@pytest.mark.parametrize("severity", ["Low", "Moderate"]) +@pytest.mark.asyncio +async def test_eligibility_ystream_cs_first_zstream_not_affected_error(severity): + """Low/Moderate Y-stream, CS first, NOT_AFFECTED check fails — NEVER with error (retryable).""" + issue = _make_jira_issue( + labels=["SecurityTracking"], + fix_versions=[{"name": "rhel-9.8"}], + summary="CVE-2025-12345 buffer overflow in curl [rhel-9.8]", + severity=severity, + components=[{"name": "curl"}], + ) + flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + flexmock(jira_tools).should_receive("_check_duplicate_tracker").and_return( + _create_async_return((None, False)) + ).once() + flexmock(jira_tools).should_receive("_check_zstream_fix_approach").with_args( + "CVE-2025-12345", "curl", "RHEL-12345", "9" + ).and_return(_create_async_return((FixApproach.CS_FIRST, [], "matching CS build in CS Koji"))).once() + + # Simulate Jira search failure + async def raise_error(*args, **kwargs): + raise RuntimeError("Jira API connection timeout") + + flexmock(jira_tools).should_receive("_check_zstream_not_affected").replace_with(raise_error).once() + + result = (await CheckCveTriageEligibilityTool().run(input={"issue_key": "RHEL-12345"})).result + assert result["eligibility"] == TriageEligibility.NEVER + assert result["error"] is not None + assert "NOT_AFFECTED check failed" in result["reason"] + assert "Jira API connection timeout" in result["error"] + + +@pytest.mark.parametrize("severity", ["Low", "Moderate"]) +@pytest.mark.asyncio +async def test_eligibility_ystream_cs_first_zstream_pending_triage_error(severity): + """Low/Moderate Y-stream, CS first, pending triage check fails — NEVER with error (retryable).""" + issue = _make_jira_issue( + labels=["SecurityTracking"], + fix_versions=[{"name": "rhel-9.8"}], + summary="CVE-2025-12345 buffer overflow in curl [rhel-9.8]", + severity=severity, + components=[{"name": "curl"}], + ) + flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + flexmock(jira_tools).should_receive("_check_duplicate_tracker").and_return( + _create_async_return((None, False)) + ).once() + flexmock(jira_tools).should_receive("_check_zstream_fix_approach").with_args( + "CVE-2025-12345", "curl", "RHEL-12345", "9" + ).and_return(_create_async_return((FixApproach.CS_FIRST, [], "matching CS build in CS Koji"))).once() + # Z-stream NOT_AFFECTED check succeeds (returns empty) + flexmock(jira_tools).should_receive("_check_zstream_not_affected").with_args( + "CVE-2025-12345", "curl", "RHEL-12345", "9" + ).and_return(_create_async_return([])).once() + + # Simulate Jira search failure for pending triage check + async def raise_error(*args, **kwargs): + raise RuntimeError("Jira API rate limit exceeded") + + flexmock(jira_tools).should_receive("_check_zstream_pending_triage").replace_with(raise_error).once() + + result = (await CheckCveTriageEligibilityTool().run(input={"issue_key": "RHEL-12345"})).result + assert result["eligibility"] == TriageEligibility.NEVER + assert result["error"] is not None + assert "pending triage check failed" in result["reason"] + assert "Jira API rate limit exceeded" in result["error"] + + +@pytest.mark.asyncio +async def test_eligibility_dependency_blocker_zstream_not_affected(): + """Y-stream with pending dependency blocker, but Z-stream NOT_AFFECTED — proceed to triage.""" + issue = _make_jira_issue( + labels=["SecurityTracking"], + fix_versions=[{"name": "rhel-9.8"}], + summary="CVE-2025-12345 buffer overflow in curl [rhel-9.8]", + severity="Important", + components=[{"name": "curl"}], + ) + flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + flexmock(jira_tools).should_receive("_check_duplicate_tracker").and_return( + _create_async_return((None, False)) + ).once() + # Has Z-stream clones but none shipped yet (would normally block) + flexmock(jira_tools).should_receive("_check_zstream_clones_shipped").with_args( + "CVE-2025-12345", "curl", "RHEL-12345" + ).and_return(_create_async_return((False, ["RHEL-777"]))).once() + # But Z-stream was NOT_AFFECTED, so should proceed anyway + flexmock(jira_tools).should_receive("_check_zstream_not_affected").with_args( + "CVE-2025-12345", "curl", "RHEL-12345", "9" + ).and_return(_create_async_return(["RHEL-777"])).once() + + result = (await CheckCveTriageEligibilityTool().run(input={"issue_key": "RHEL-12345"})).result + # Should proceed to triage (no dependency blocker), so we get IMMEDIATELY for Important severity + # needs_internal_fix is False because Z-stream was NOT_AFFECTED (no fix needed) + assert result["eligibility"] == TriageEligibility.IMMEDIATELY + assert result["needs_internal_fix"] is False + + +@pytest.mark.asyncio +async def test_eligibility_dependency_blocker_zstream_not_affected_error(): + """Y-stream dependency blocker, NOT_AFFECTED check fails — NEVER with error (retryable).""" + issue = _make_jira_issue( + labels=["SecurityTracking"], + fix_versions=[{"name": "rhel-9.8"}], + summary="CVE-2025-12345 buffer overflow in curl [rhel-9.8]", + severity="Important", + components=[{"name": "curl"}], + ) + flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + flexmock(jira_tools).should_receive("_check_duplicate_tracker").and_return( + _create_async_return((None, False)) + ).once() + # Has Z-stream clones but none shipped yet + flexmock(jira_tools).should_receive("_check_zstream_clones_shipped").with_args( + "CVE-2025-12345", "curl", "RHEL-12345" + ).and_return(_create_async_return((False, ["RHEL-777"]))).once() + + # Simulate Jira search failure + async def raise_error(*args, **kwargs): + raise RuntimeError("Jira server unavailable") + + flexmock(jira_tools).should_receive("_check_zstream_not_affected").replace_with(raise_error).once() + + result = (await CheckCveTriageEligibilityTool().run(input={"issue_key": "RHEL-12345"})).result + assert result["eligibility"] == TriageEligibility.NEVER + assert result["error"] is not None + assert "NOT_AFFECTED check failed" in result["reason"] + assert "Jira server unavailable" in result["error"] + + @pytest.mark.parametrize("severity", ["Low", "Moderate"]) @pytest.mark.asyncio async def test_eligibility_ystream_low_moderate_rhel_first(severity): diff --git a/ymir/tools/privileged/tests/unit/test_jira_zstream_status.py b/ymir/tools/privileged/tests/unit/test_jira_zstream_status.py new file mode 100644 index 000000000..7b7f23f8c --- /dev/null +++ b/ymir/tools/privileged/tests/unit/test_jira_zstream_status.py @@ -0,0 +1,372 @@ +"""Unit tests for Z-stream status check functions (PACKIT-5281).""" + +import os + +import pytest +from beeai_framework.tools import JSONToolOutput +from flexmock import flexmock + +from ymir.tools.privileged import jira as jira_tools +from ymir.tools.privileged.jira import ( + SearchJiraIssuesTool, + _check_zstream_not_affected, + _check_zstream_pending_triage, + _get_applicable_zstream_variants, +) + + +def _create_async_return(value): + """Create a coroutine that returns the given value when awaited.""" + + async def async_return(*args, **kwargs): + return value + + return async_return() + + +RHEL_CONFIG = { + "current_y_streams": {"9": "rhel-9.8", "10": "rhel-10.2", "11": "rhel-11.2"}, + "current_z_streams": {"8": "rhel-8.10.z", "9": "rhel-9.6.z", "11": "rhel-11.1.z"}, + "upcoming_z_streams": {"9": "rhel-9.7.z", "10": "rhel-10.3.z"}, +} + + +@pytest.fixture(autouse=True) +def mocked_env(): + flexmock(os).should_receive("getenv").with_args("JIRA_URL").and_return("http://jira") + flexmock(jira_tools).should_receive("get_jira_auth_headers").and_return( + { + "Authorization": "Basic dGVzdEBleGFtcGxlLmNvbToxMjM0NQ==", + "Content-Type": "application/json", + "Accept": "application/json", + } + ) + + +# Tests for _get_applicable_zstream_variants() + + +@pytest.mark.asyncio +async def test_get_applicable_zstream_variants_upcoming_wins(): + """Upcoming Z-stream takes precedence over current when both exist.""" + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + variants = await _get_applicable_zstream_variants("9") + # get_fix_version_variants returns both Y and Z forms for GA transitions + assert variants == {"rhel-9.7", "rhel-9.7.z"} + + +@pytest.mark.asyncio +async def test_get_applicable_zstream_variants_current_fallback(): + """Falls back to current Z-stream when no upcoming exists (RHEL-11).""" + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + variants = await _get_applicable_zstream_variants("11") + # get_fix_version_variants returns both Y and Z forms + assert variants == {"rhel-11.1", "rhel-11.1.z"} + + +@pytest.mark.asyncio +async def test_get_applicable_zstream_variants_no_zstream(): + """Returns None when no Z-stream exists for major version.""" + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + variants = await _get_applicable_zstream_variants("7") + assert variants is None + + +@pytest.mark.asyncio +async def test_get_applicable_zstream_variants_maintenance(): + """Returns None when major version is in maintenance (has Z-stream but no Y-stream).""" + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + # Version 8 has current_z_streams but no current_y_streams = maintenance + variants = await _get_applicable_zstream_variants("8") + assert variants is None + + +# Tests for _check_zstream_not_affected() + + +@pytest.mark.asyncio +async def test_check_zstream_not_affected_found(): + """Z-stream clone with ymir_triaged_not_affected label is found.""" + search_result = [ + { + "key": "RHEL-111", + "fields": { + "fixVersions": [{"name": "rhel-9.7.z"}], + }, + }, + ] + flexmock(SearchJiraIssuesTool).should_receive("run").and_return( + _create_async_return(JSONToolOutput(result=search_result)) + ).once() + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + not_affected = await _check_zstream_not_affected("CVE-2026-12345", "curl", "RHEL-999", "9") + assert not_affected == ["RHEL-111"] + + +@pytest.mark.asyncio +async def test_check_zstream_not_affected_none_found(): + """No Z-stream clones with ymir_triaged_not_affected label exist.""" + search_result = [] + flexmock(SearchJiraIssuesTool).should_receive("run").and_return( + _create_async_return(JSONToolOutput(result=search_result)) + ).once() + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + not_affected = await _check_zstream_not_affected("CVE-2026-12345", "curl", "RHEL-999", "9") + assert not_affected == [] + + +@pytest.mark.asyncio +async def test_check_zstream_not_affected_clone_is_affected(): + """Z-stream clone exists but doesn't have ymir_triaged_not_affected label (is affected).""" + # Search returns empty because JQL requires ymir_triaged_not_affected label + search_result = [] + flexmock(SearchJiraIssuesTool).should_receive("run").and_return( + _create_async_return(JSONToolOutput(result=search_result)) + ).once() + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + not_affected = await _check_zstream_not_affected("CVE-2026-12345", "curl", "RHEL-999", "9") + assert not_affected == [] + + +@pytest.mark.asyncio +async def test_check_zstream_not_affected_wrong_version(): + """Z-stream clone with wrong fix version is filtered out.""" + search_result = [ + { + "key": "RHEL-111", + "fields": { + "fixVersions": [{"name": "rhel-8.10.z"}], # Wrong major version + }, + }, + ] + flexmock(SearchJiraIssuesTool).should_receive("run").and_return( + _create_async_return(JSONToolOutput(result=search_result)) + ).once() + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + not_affected = await _check_zstream_not_affected("CVE-2026-12345", "curl", "RHEL-999", "9") + assert not_affected == [] + + +@pytest.mark.asyncio +async def test_check_zstream_not_affected_old_current_ignored(): + """Old current Z-stream clone is ignored when upcoming exists.""" + search_result = [ + { + "key": "RHEL-111", + "fields": { + "fixVersions": [{"name": "rhel-9.6.z"}], # Old current + }, + }, + { + "key": "RHEL-222", + "fields": { + "fixVersions": [{"name": "rhel-9.7.z"}], # Upcoming (wins) + }, + }, + ] + flexmock(SearchJiraIssuesTool).should_receive("run").and_return( + _create_async_return(JSONToolOutput(result=search_result)) + ).once() + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + not_affected = await _check_zstream_not_affected("CVE-2026-12345", "curl", "RHEL-999", "9") + # Only the upcoming Z-stream (9.7.z) should be returned + assert not_affected == ["RHEL-222"] + + +@pytest.mark.asyncio +async def test_check_zstream_not_affected_no_applicable_zstream(): + """Returns empty list when no applicable Z-stream exists (returns early, no Jira search).""" + # Mock SearchJiraIssuesTool to ensure it's NOT called + search_mock = flexmock(SearchJiraIssuesTool) + search_mock.should_receive("run").never() + + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + # Version 7 has no Z-stream in config, should return early + not_affected = await _check_zstream_not_affected("CVE-2026-12345", "curl", "RHEL-999", "7") + assert not_affected == [] + + +# Tests for _check_zstream_pending_triage() + + +@pytest.mark.asyncio +async def test_check_zstream_pending_triage_found(): + """Z-stream clone without terminal labels is found as pending.""" + search_result = [ + { + "key": "RHEL-111", + "fields": { + "fixVersions": [{"name": "rhel-9.7.z"}], + "labels": ["SecurityTracking", "ymir_triage_in_progress"], + }, + }, + ] + flexmock(SearchJiraIssuesTool).should_receive("run").and_return( + _create_async_return(JSONToolOutput(result=search_result)) + ).once() + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + pending = await _check_zstream_pending_triage("CVE-2026-12345", "curl", "RHEL-999", "9") + assert pending == ["RHEL-111"] + + +@pytest.mark.asyncio +async def test_check_zstream_pending_triage_terminal_excluded(): + """Z-stream clones with terminal labels are excluded from pending list.""" + search_result = [] # JQL already excludes terminal labels + flexmock(SearchJiraIssuesTool).should_receive("run").and_return( + _create_async_return(JSONToolOutput(result=search_result)) + ).once() + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + pending = await _check_zstream_pending_triage("CVE-2026-12345", "curl", "RHEL-999", "9") + assert pending == [] + + +@pytest.mark.asyncio +async def test_check_zstream_pending_triage_none_found(): + """No pending Z-stream clones exist.""" + search_result = [] + flexmock(SearchJiraIssuesTool).should_receive("run").and_return( + _create_async_return(JSONToolOutput(result=search_result)) + ).once() + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + pending = await _check_zstream_pending_triage("CVE-2026-12345", "curl", "RHEL-999", "9") + assert pending == [] + + +@pytest.mark.asyncio +async def test_check_zstream_pending_triage_clone_is_affected(): + """Z-stream clone exists but has terminal label (triage completed, is affected).""" + # Search returns empty because JQL excludes terminal labels like ymir_triaged_backport + search_result = [] + flexmock(SearchJiraIssuesTool).should_receive("run").and_return( + _create_async_return(JSONToolOutput(result=search_result)) + ).once() + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + pending = await _check_zstream_pending_triage("CVE-2026-12345", "curl", "RHEL-999", "9") + assert pending == [] + + +@pytest.mark.asyncio +async def test_check_zstream_pending_triage_old_current_ignored(): + """Old current Z-stream clone is ignored when upcoming exists.""" + search_result = [ + { + "key": "RHEL-111", + "fields": { + "fixVersions": [{"name": "rhel-9.6.z"}], # Old current + "labels": ["SecurityTracking"], + }, + }, + { + "key": "RHEL-222", + "fields": { + "fixVersions": [{"name": "rhel-9.7.z"}], # Upcoming (wins) + "labels": ["SecurityTracking"], + }, + }, + ] + flexmock(SearchJiraIssuesTool).should_receive("run").and_return( + _create_async_return(JSONToolOutput(result=search_result)) + ).once() + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + pending = await _check_zstream_pending_triage("CVE-2026-12345", "curl", "RHEL-999", "9") + # Only the upcoming Z-stream (9.7.z) should be returned + assert pending == ["RHEL-222"] + + +@pytest.mark.asyncio +async def test_check_zstream_pending_triage_no_applicable_zstream(): + """Returns empty list when no applicable Z-stream exists (returns early, no Jira search).""" + # Mock SearchJiraIssuesTool to ensure it's NOT called + search_mock = flexmock(SearchJiraIssuesTool) + search_mock.should_receive("run").never() + + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + # Version 7 has no Z-stream in config, should return early + pending = await _check_zstream_pending_triage("CVE-2026-12345", "curl", "RHEL-999", "7") + assert pending == [] + + +# Tests for Jira search failure cases + + +@pytest.mark.asyncio +async def test_check_zstream_not_affected_jira_search_failure(): + """Raises exception when Jira search fails.""" + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + # Simulate Jira search failure + async def raise_error(*args, **kwargs): + raise RuntimeError("Jira API connection timeout") + + flexmock(SearchJiraIssuesTool).should_receive("run").replace_with(raise_error).once() + + with pytest.raises(RuntimeError, match="Jira API connection timeout"): + await _check_zstream_not_affected("CVE-2026-12345", "curl", "RHEL-999", "9") + + +@pytest.mark.asyncio +async def test_check_zstream_pending_triage_jira_search_failure(): + """Raises exception when Jira search fails.""" + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + # Simulate Jira search failure + async def raise_error(*args, **kwargs): + raise RuntimeError("Jira server unavailable") + + flexmock(SearchJiraIssuesTool).should_receive("run").replace_with(raise_error).once() + + with pytest.raises(RuntimeError, match="Jira server unavailable"): + await _check_zstream_pending_triage("CVE-2026-12345", "curl", "RHEL-999", "9") From 0ab6356ac02019a55a34919065602ac402b1c1f2 Mon Sep 17 00:00:00 2001 From: Maja Massarini Date: Wed, 2 Sep 2026 10:17:38 +0200 Subject: [PATCH 3/6] Fix: Filter Z-stream clones by module stream to prevent cross-contamination Modular trackers can share CVE ID, component, and fix version while representing different module streams. Without filtering, a NOT_AFFECTED or pending status in one module stream (e.g., postgresql:16) could incorrectly affect eligibility for another module stream (e.g., postgresql:15). Changes: - Updated _check_zstream_not_affected and _check_zstream_pending_triage to accept summary parameter - Parse module stream from current issue using parse_module_stream - Request summary field from Jira search results - Filter clones to only match when: - Both are modular with the exact same (module, stream) tuple, OR - Both are non-modular (None module stream) - Updated all 3 call sites to pass summary parameter - Updated all existing tests to pass summary parameter - Added 5 regression tests for modular tracker scenarios: - NOT_AFFECTED: modular match, modular mismatch, modular vs non-modular - Pending triage: modular match, modular mismatch Example: postgresql:15/postgis and postgresql:16/postgis both in component postgis now correctly tracked separately - NOT_AFFECTED in :16 doesn't affect :15 eligibility. Related: PACKIT-5281 Assisted-by: Claude Sonnet 4.5 (1M context) --- ymir/tools/privileged/jira.py | 76 ++++-- ymir/tools/privileged/tests/unit/test_jira.py | 16 +- .../tests/unit/test_jira_zstream_status.py | 216 ++++++++++++++++-- 3 files changed, 271 insertions(+), 37 deletions(-) diff --git a/ymir/tools/privileged/jira.py b/ymir/tools/privileged/jira.py index d68555c09..122492560 100644 --- a/ymir/tools/privileged/jira.py +++ b/ymir/tools/privileged/jira.py @@ -826,7 +826,7 @@ async def _get_applicable_zstream_variants(major_version: str) -> set[str] | Non async def _check_zstream_not_affected( - cve_id: str, component: str, exclude_key: str, major_version: str + cve_id: str, component: str, exclude_key: str, major_version: str, summary: str ) -> list[str]: """Check if any Z-stream clone was triaged as NOT_AFFECTED. @@ -835,6 +835,8 @@ async def _check_zstream_not_affected( if the Z-stream clones were actually not affected — in that case, the Y-stream should also be triaged to confirm it's not affected. + For modular trackers, only matches clones with the same module stream. + Returns list of Z-stream issue keys that have ymir_triaged_not_affected label. """ # Check if there's an applicable Z-stream first (early return to avoid unnecessary Jira search) @@ -843,6 +845,9 @@ async def _check_zstream_not_affected( logger.info(f"No applicable Z-stream for major version {major_version}, skipping NOT_AFFECTED check") return [] + # Parse module stream from current issue to filter modular clones + current_module_stream = parse_module_stream(summary, component) + escaped_cve_id = cve_id.replace('"', '\\"') escaped_component = component.replace('"', '\\"') jql = ( @@ -850,13 +855,16 @@ async def _check_zstream_not_affected( f' AND labels = "SecurityTracking" AND labels = "ymir_triaged_not_affected"' f' AND key != "{exclude_key}"' ) - logger.info(f"Checking for NOT_AFFECTED Z-stream clones for {cve_id} (major={major_version})") + logger.info( + f"Checking for NOT_AFFECTED Z-stream clones for {cve_id} " + f"(major={major_version}, modular={current_module_stream is not None})" + ) tool = SearchJiraIssuesTool() output = await tool.run( input={ "jql": jql, - "fields": ["fixVersions"], + "fields": ["fixVersions", "summary"], "max_results": 50, } ) @@ -867,9 +875,24 @@ async def _check_zstream_not_affected( key = issue.get("key", "") fix_versions = issue.get("fields", {}).get("fixVersions", []) fv_names = [fv.get("name", "") for fv in fix_versions] - if any(fv.lower() in relevant_z_streams for fv in fv_names): - logger.info(f" {key}: fixVersions={fv_names} — NOT_AFFECTED Z-stream clone found") - not_affected_keys.append(key) + + # Check if fix version matches + if not any(fv.lower() in relevant_z_streams for fv in fv_names): + continue + + # Check if module stream matches (both modular with same module/stream, or both non-modular) + issue_summary = issue.get("fields", {}).get("summary", "") + issue_module_stream = parse_module_stream(issue_summary, component) + + if current_module_stream != issue_module_stream: + logger.info( + f" {key}: module stream mismatch (current={current_module_stream}, " + f"clone={issue_module_stream}) — skipping" + ) + continue + + logger.info(f" {key}: fixVersions={fv_names} — NOT_AFFECTED Z-stream clone found") + not_affected_keys.append(key) if not_affected_keys: logger.info( @@ -882,7 +905,7 @@ async def _check_zstream_not_affected( async def _check_zstream_pending_triage( - cve_id: str, component: str, exclude_key: str, major_version: str + cve_id: str, component: str, exclude_key: str, major_version: str, summary: str ) -> list[str]: """Check if Z-stream clones exist but haven't been triaged yet. @@ -891,6 +914,8 @@ async def _check_zstream_pending_triage( wait for them to be triaged before deciding whether the Y-stream should be skipped or triaged. + For modular trackers, only matches clones with the same module stream. + Returns list of Z-stream issue keys without ymir_triaged* terminal labels. """ # Check if there's an applicable Z-stream first (early return to avoid unnecessary Jira search) @@ -901,6 +926,9 @@ async def _check_zstream_pending_triage( ) return [] + # Parse module stream from current issue to filter modular clones + current_module_stream = parse_module_stream(summary, component) + escaped_cve_id = cve_id.replace('"', '\\"') escaped_component = component.replace('"', '\\"') # Search for Z-stream clones without any terminal labels. @@ -922,13 +950,16 @@ async def _check_zstream_pending_triage( f' AND labels != "ymir_triage_errored"' f' AND key != "{exclude_key}"' ) - logger.info(f"Checking for pending-triage Z-stream clones for {cve_id} (major={major_version})") + logger.info( + f"Checking for pending-triage Z-stream clones for {cve_id} " + f"(major={major_version}, modular={current_module_stream is not None})" + ) tool = SearchJiraIssuesTool() output = await tool.run( input={ "jql": jql, - "fields": ["fixVersions", "labels"], + "fields": ["fixVersions", "labels", "summary"], "max_results": 50, } ) @@ -940,9 +971,24 @@ async def _check_zstream_pending_triage( fix_versions = issue.get("fields", {}).get("fixVersions", []) fv_names = [fv.get("name", "") for fv in fix_versions] labels = issue.get("fields", {}).get("labels", []) - if any(fv.lower() in relevant_z_streams for fv in fv_names): - logger.info(f" {key}: fixVersions={fv_names}, labels={labels} — pending triage") - pending_keys.append(key) + + # Check if fix version matches + if not any(fv.lower() in relevant_z_streams for fv in fv_names): + continue + + # Check if module stream matches (both modular with same module/stream, or both non-modular) + issue_summary = issue.get("fields", {}).get("summary", "") + issue_module_stream = parse_module_stream(issue_summary, component) + + if current_module_stream != issue_module_stream: + logger.info( + f" {key}: module stream mismatch (current={current_module_stream}, " + f"clone={issue_module_stream}) — skipping" + ) + continue + + logger.info(f" {key}: fixVersions={fv_names}, labels={labels} — pending triage") + pending_keys.append(key) if pending_keys: logger.info( @@ -1164,7 +1210,7 @@ async def _check_for_dependency_blocker( if major_version: try: not_affected_clones = await _check_zstream_not_affected( - cve_id, component, issue_key, major_version + cve_id, component, issue_key, major_version, summary ) except Exception as e: logger.warning(f"Z-stream NOT_AFFECTED check failed for {cve_id}: {e}") @@ -1367,7 +1413,7 @@ async def _check_lowmod_ystream_eligibility( # Before skipping the Y-stream, check if Z-stream clones were NOT_AFFECTED try: not_affected_clones = await _check_zstream_not_affected( - cve_id, component, issue_key, major_version + cve_id, component, issue_key, major_version, summary ) except Exception as e: logger.warning(f"Z-stream NOT_AFFECTED check failed for {cve_id}: {e}") @@ -1402,7 +1448,7 @@ async def _check_lowmod_ystream_eligibility( # Check if Z-stream clones exist but haven't been triaged yet try: pending_triage = await _check_zstream_pending_triage( - cve_id, component, issue_key, major_version + cve_id, component, issue_key, major_version, summary ) except Exception as e: logger.warning(f"Z-stream pending triage check failed for {cve_id}: {e}") diff --git a/ymir/tools/privileged/tests/unit/test_jira.py b/ymir/tools/privileged/tests/unit/test_jira.py index 8bc482c7a..bd390c151 100644 --- a/ymir/tools/privileged/tests/unit/test_jira.py +++ b/ymir/tools/privileged/tests/unit/test_jira.py @@ -1344,11 +1344,11 @@ async def test_eligibility_ystream_low_moderate_pending(severity): ).and_return(_create_async_return((FixApproach.PENDING, ["RHEL-999"], ""))).once() # Z-stream clone not NOT_AFFECTED flexmock(jira_tools).should_receive("_check_zstream_not_affected").with_args( - "CVE-2025-12345", "curl", "RHEL-12345", "9" + "CVE-2025-12345", "curl", "RHEL-12345", "9", "CVE-2025-12345 buffer overflow in curl [rhel-9.8]" ).and_return(_create_async_return([])).once() # Z-stream clone not pending triage (still waiting for Fixed in Build) flexmock(jira_tools).should_receive("_check_zstream_pending_triage").with_args( - "CVE-2025-12345", "curl", "RHEL-12345", "9" + "CVE-2025-12345", "curl", "RHEL-12345", "9", "CVE-2025-12345 buffer overflow in curl [rhel-9.8]" ).and_return(_create_async_return([])).once() result = (await CheckCveTriageEligibilityTool().run(input={"issue_key": "RHEL-12345"})).result @@ -1417,7 +1417,7 @@ async def test_eligibility_ystream_cs_first_zstream_not_affected(severity): ).and_return(_create_async_return((FixApproach.CS_FIRST, [], "matching CS build in CS Koji"))).once() # Z-stream clone was NOT_AFFECTED, so Y-stream should be triaged too flexmock(jira_tools).should_receive("_check_zstream_not_affected").with_args( - "CVE-2025-12345", "curl", "RHEL-12345", "9" + "CVE-2025-12345", "curl", "RHEL-12345", "9", "CVE-2025-12345 buffer overflow in curl [rhel-9.8]" ).and_return(_create_async_return(["RHEL-999"])).once() result = (await CheckCveTriageEligibilityTool().run(input={"issue_key": "RHEL-12345"})).result @@ -1449,11 +1449,11 @@ async def test_eligibility_ystream_cs_first_zstream_pending_triage(severity): ).and_return(_create_async_return((FixApproach.CS_FIRST, [], "matching CS build in CS Koji"))).once() # Z-stream clone not NOT_AFFECTED flexmock(jira_tools).should_receive("_check_zstream_not_affected").with_args( - "CVE-2025-12345", "curl", "RHEL-12345", "9" + "CVE-2025-12345", "curl", "RHEL-12345", "9", "CVE-2025-12345 buffer overflow in curl [rhel-9.8]" ).and_return(_create_async_return([])).once() # Z-stream clone pending triage flexmock(jira_tools).should_receive("_check_zstream_pending_triage").with_args( - "CVE-2025-12345", "curl", "RHEL-12345", "9" + "CVE-2025-12345", "curl", "RHEL-12345", "9", "CVE-2025-12345 buffer overflow in curl [rhel-9.8]" ).and_return(_create_async_return(["RHEL-888"])).once() result = (await CheckCveTriageEligibilityTool().run(input={"issue_key": "RHEL-12345"})).result @@ -1492,7 +1492,7 @@ async def test_eligibility_ystream_pending_zstream_not_affected(severity): ).and_return(_create_async_return((FixApproach.PENDING, ["RHEL-777"], "open clone, no build"))).once() # But the clone has ymir_triaged_not_affected label flexmock(jira_tools).should_receive("_check_zstream_not_affected").with_args( - "CVE-2025-12345", "curl", "RHEL-12345", "9" + "CVE-2025-12345", "curl", "RHEL-12345", "9", "CVE-2025-12345 buffer overflow in curl [rhel-9.8]" ).and_return(_create_async_return(["RHEL-777"])).once() result = (await CheckCveTriageEligibilityTool().run(input={"issue_key": "RHEL-12345"})).result @@ -1559,7 +1559,7 @@ async def test_eligibility_ystream_cs_first_zstream_pending_triage_error(severit ).and_return(_create_async_return((FixApproach.CS_FIRST, [], "matching CS build in CS Koji"))).once() # Z-stream NOT_AFFECTED check succeeds (returns empty) flexmock(jira_tools).should_receive("_check_zstream_not_affected").with_args( - "CVE-2025-12345", "curl", "RHEL-12345", "9" + "CVE-2025-12345", "curl", "RHEL-12345", "9", "CVE-2025-12345 buffer overflow in curl [rhel-9.8]" ).and_return(_create_async_return([])).once() # Simulate Jira search failure for pending triage check @@ -1598,7 +1598,7 @@ async def test_eligibility_dependency_blocker_zstream_not_affected(): ).and_return(_create_async_return((False, ["RHEL-777"]))).once() # But Z-stream was NOT_AFFECTED, so should proceed anyway flexmock(jira_tools).should_receive("_check_zstream_not_affected").with_args( - "CVE-2025-12345", "curl", "RHEL-12345", "9" + "CVE-2025-12345", "curl", "RHEL-12345", "9", "CVE-2025-12345 buffer overflow in curl [rhel-9.8]" ).and_return(_create_async_return(["RHEL-777"])).once() result = (await CheckCveTriageEligibilityTool().run(input={"issue_key": "RHEL-12345"})).result diff --git a/ymir/tools/privileged/tests/unit/test_jira_zstream_status.py b/ymir/tools/privileged/tests/unit/test_jira_zstream_status.py index 7b7f23f8c..db1b8e0ef 100644 --- a/ymir/tools/privileged/tests/unit/test_jira_zstream_status.py +++ b/ymir/tools/privileged/tests/unit/test_jira_zstream_status.py @@ -104,6 +104,7 @@ async def test_check_zstream_not_affected_found(): "key": "RHEL-111", "fields": { "fixVersions": [{"name": "rhel-9.7.z"}], + "summary": "CVE-2026-12345 buffer overflow in curl [rhel-9.7.z]", }, }, ] @@ -114,7 +115,9 @@ async def test_check_zstream_not_affected_found(): _create_async_return(RHEL_CONFIG) ).once() - not_affected = await _check_zstream_not_affected("CVE-2026-12345", "curl", "RHEL-999", "9") + not_affected = await _check_zstream_not_affected( + "CVE-2026-12345", "curl", "RHEL-999", "9", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) assert not_affected == ["RHEL-111"] @@ -129,7 +132,9 @@ async def test_check_zstream_not_affected_none_found(): _create_async_return(RHEL_CONFIG) ).once() - not_affected = await _check_zstream_not_affected("CVE-2026-12345", "curl", "RHEL-999", "9") + not_affected = await _check_zstream_not_affected( + "CVE-2026-12345", "curl", "RHEL-999", "9", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) assert not_affected == [] @@ -145,7 +150,9 @@ async def test_check_zstream_not_affected_clone_is_affected(): _create_async_return(RHEL_CONFIG) ).once() - not_affected = await _check_zstream_not_affected("CVE-2026-12345", "curl", "RHEL-999", "9") + not_affected = await _check_zstream_not_affected( + "CVE-2026-12345", "curl", "RHEL-999", "9", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) assert not_affected == [] @@ -157,6 +164,7 @@ async def test_check_zstream_not_affected_wrong_version(): "key": "RHEL-111", "fields": { "fixVersions": [{"name": "rhel-8.10.z"}], # Wrong major version + "summary": "CVE-2026-12345 buffer overflow in curl [rhel-8.10.z]", }, }, ] @@ -167,7 +175,9 @@ async def test_check_zstream_not_affected_wrong_version(): _create_async_return(RHEL_CONFIG) ).once() - not_affected = await _check_zstream_not_affected("CVE-2026-12345", "curl", "RHEL-999", "9") + not_affected = await _check_zstream_not_affected( + "CVE-2026-12345", "curl", "RHEL-999", "9", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) assert not_affected == [] @@ -195,7 +205,9 @@ async def test_check_zstream_not_affected_old_current_ignored(): _create_async_return(RHEL_CONFIG) ).once() - not_affected = await _check_zstream_not_affected("CVE-2026-12345", "curl", "RHEL-999", "9") + not_affected = await _check_zstream_not_affected( + "CVE-2026-12345", "curl", "RHEL-999", "9", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) # Only the upcoming Z-stream (9.7.z) should be returned assert not_affected == ["RHEL-222"] @@ -212,7 +224,9 @@ async def test_check_zstream_not_affected_no_applicable_zstream(): ).once() # Version 7 has no Z-stream in config, should return early - not_affected = await _check_zstream_not_affected("CVE-2026-12345", "curl", "RHEL-999", "7") + not_affected = await _check_zstream_not_affected( + "CVE-2026-12345", "curl", "RHEL-999", "7", "CVE-2026-12345 buffer overflow in curl [rhel-7.8]" + ) assert not_affected == [] @@ -238,7 +252,9 @@ async def test_check_zstream_pending_triage_found(): _create_async_return(RHEL_CONFIG) ).once() - pending = await _check_zstream_pending_triage("CVE-2026-12345", "curl", "RHEL-999", "9") + pending = await _check_zstream_pending_triage( + "CVE-2026-12345", "curl", "RHEL-999", "9", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) assert pending == ["RHEL-111"] @@ -253,7 +269,9 @@ async def test_check_zstream_pending_triage_terminal_excluded(): _create_async_return(RHEL_CONFIG) ).once() - pending = await _check_zstream_pending_triage("CVE-2026-12345", "curl", "RHEL-999", "9") + pending = await _check_zstream_pending_triage( + "CVE-2026-12345", "curl", "RHEL-999", "9", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) assert pending == [] @@ -268,7 +286,9 @@ async def test_check_zstream_pending_triage_none_found(): _create_async_return(RHEL_CONFIG) ).once() - pending = await _check_zstream_pending_triage("CVE-2026-12345", "curl", "RHEL-999", "9") + pending = await _check_zstream_pending_triage( + "CVE-2026-12345", "curl", "RHEL-999", "9", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) assert pending == [] @@ -284,7 +304,9 @@ async def test_check_zstream_pending_triage_clone_is_affected(): _create_async_return(RHEL_CONFIG) ).once() - pending = await _check_zstream_pending_triage("CVE-2026-12345", "curl", "RHEL-999", "9") + pending = await _check_zstream_pending_triage( + "CVE-2026-12345", "curl", "RHEL-999", "9", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) assert pending == [] @@ -314,7 +336,9 @@ async def test_check_zstream_pending_triage_old_current_ignored(): _create_async_return(RHEL_CONFIG) ).once() - pending = await _check_zstream_pending_triage("CVE-2026-12345", "curl", "RHEL-999", "9") + pending = await _check_zstream_pending_triage( + "CVE-2026-12345", "curl", "RHEL-999", "9", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) # Only the upcoming Z-stream (9.7.z) should be returned assert pending == ["RHEL-222"] @@ -331,7 +355,9 @@ async def test_check_zstream_pending_triage_no_applicable_zstream(): ).once() # Version 7 has no Z-stream in config, should return early - pending = await _check_zstream_pending_triage("CVE-2026-12345", "curl", "RHEL-999", "7") + pending = await _check_zstream_pending_triage( + "CVE-2026-12345", "curl", "RHEL-999", "7", "CVE-2026-12345 buffer overflow in curl [rhel-7.8]" + ) assert pending == [] @@ -352,7 +378,9 @@ async def raise_error(*args, **kwargs): flexmock(SearchJiraIssuesTool).should_receive("run").replace_with(raise_error).once() with pytest.raises(RuntimeError, match="Jira API connection timeout"): - await _check_zstream_not_affected("CVE-2026-12345", "curl", "RHEL-999", "9") + await _check_zstream_not_affected( + "CVE-2026-12345", "curl", "RHEL-999", "9", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) @pytest.mark.asyncio @@ -369,4 +397,164 @@ async def raise_error(*args, **kwargs): flexmock(SearchJiraIssuesTool).should_receive("run").replace_with(raise_error).once() with pytest.raises(RuntimeError, match="Jira server unavailable"): - await _check_zstream_pending_triage("CVE-2026-12345", "curl", "RHEL-999", "9") + await _check_zstream_pending_triage( + "CVE-2026-12345", "curl", "RHEL-999", "9", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) + + +# Tests for modular tracker filtering + + +@pytest.mark.asyncio +async def test_check_zstream_not_affected_modular_match(): + """Modular tracker: clone with same module stream is matched.""" + search_result = [ + { + "key": "RHEL-111", + "fields": { + "fixVersions": [{"name": "rhel-9.7.z"}], + "summary": "postgresql:15/postgis: CVE-2026-12345 buffer overflow [rhel-9.7.z]", + }, + }, + ] + flexmock(SearchJiraIssuesTool).should_receive("run").and_return( + _create_async_return(JSONToolOutput(result=search_result)) + ).once() + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + # Current issue is modular: postgresql:15/postgis + not_affected = await _check_zstream_not_affected( + "CVE-2026-12345", + "postgis", + "RHEL-999", + "9", + "postgresql:15/postgis: CVE-2026-12345 buffer overflow [rhel-9.8]", + ) + assert not_affected == ["RHEL-111"] + + +@pytest.mark.asyncio +async def test_check_zstream_not_affected_modular_mismatch(): + """Modular tracker: clone with different module stream is filtered out.""" + search_result = [ + { + "key": "RHEL-111", + "fields": { + "fixVersions": [{"name": "rhel-9.7.z"}], + # Different module stream: postgresql:16 vs postgresql:15 + "summary": "postgresql:16/postgis: CVE-2026-12345 buffer overflow [rhel-9.7.z]", + }, + }, + ] + flexmock(SearchJiraIssuesTool).should_receive("run").and_return( + _create_async_return(JSONToolOutput(result=search_result)) + ).once() + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + # Current issue is modular: postgresql:15/postgis + not_affected = await _check_zstream_not_affected( + "CVE-2026-12345", + "postgis", + "RHEL-999", + "9", + "postgresql:15/postgis: CVE-2026-12345 buffer overflow [rhel-9.8]", + ) + assert not_affected == [] + + +@pytest.mark.asyncio +async def test_check_zstream_not_affected_modular_vs_nonmodular(): + """Modular tracker doesn't match non-modular clone (and vice versa).""" + search_result = [ + { + "key": "RHEL-111", + "fields": { + "fixVersions": [{"name": "rhel-9.7.z"}], + # Non-modular summary + "summary": "CVE-2026-12345 buffer overflow in postgis [rhel-9.7.z]", + }, + }, + ] + flexmock(SearchJiraIssuesTool).should_receive("run").and_return( + _create_async_return(JSONToolOutput(result=search_result)) + ).once() + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + # Current issue is modular: postgresql:15/postgis + not_affected = await _check_zstream_not_affected( + "CVE-2026-12345", + "postgis", + "RHEL-999", + "9", + "postgresql:15/postgis: CVE-2026-12345 buffer overflow [rhel-9.8]", + ) + assert not_affected == [] + + +@pytest.mark.asyncio +async def test_check_zstream_pending_triage_modular_match(): + """Modular tracker: pending clone with same module stream is matched.""" + search_result = [ + { + "key": "RHEL-111", + "fields": { + "fixVersions": [{"name": "rhel-9.7.z"}], + "summary": "postgresql:15/postgis: CVE-2026-12345 buffer overflow [rhel-9.7.z]", + "labels": ["SecurityTracking", "ymir_triage_in_progress"], + }, + }, + ] + flexmock(SearchJiraIssuesTool).should_receive("run").and_return( + _create_async_return(JSONToolOutput(result=search_result)) + ).once() + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + # Current issue is modular: postgresql:15/postgis + pending = await _check_zstream_pending_triage( + "CVE-2026-12345", + "postgis", + "RHEL-999", + "9", + "postgresql:15/postgis: CVE-2026-12345 buffer overflow [rhel-9.8]", + ) + assert pending == ["RHEL-111"] + + +@pytest.mark.asyncio +async def test_check_zstream_pending_triage_modular_mismatch(): + """Modular tracker: pending clone with different module stream is filtered out.""" + search_result = [ + { + "key": "RHEL-111", + "fields": { + "fixVersions": [{"name": "rhel-9.7.z"}], + # Different module stream: postgresql:16 vs postgresql:15 + "summary": "postgresql:16/postgis: CVE-2026-12345 buffer overflow [rhel-9.7.z]", + "labels": ["SecurityTracking", "ymir_triage_in_progress"], + }, + }, + ] + flexmock(SearchJiraIssuesTool).should_receive("run").and_return( + _create_async_return(JSONToolOutput(result=search_result)) + ).once() + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + # Current issue is modular: postgresql:15/postgis + pending = await _check_zstream_pending_triage( + "CVE-2026-12345", + "postgis", + "RHEL-999", + "9", + "postgresql:15/postgis: CVE-2026-12345 buffer overflow [rhel-9.8]", + ) + assert pending == [] From c23720f8a25c99c3a9bc4557f8cda2b4c8ef08a7 Mon Sep 17 00:00:00 2001 From: Maja Massarini Date: Wed, 2 Sep 2026 14:37:23 +0200 Subject: [PATCH 4/6] Fix: Add missing dependencies and restore pipeline targets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Container fixes: - Containerfile.supervisor: - Added git binary (fixes GitPython "Bad git executable" error) - Added sentry-sdk>=2.13.0 (fixes import error in ymir.agents.observability) - Added GitPython>=3.1.0 (fixes ModuleNotFoundError: No module named 'git') - Containerfile.c10s (triage-agent): - Added typer (fixes import error in ymir.cli.main) - Containerfile.c9s: - Added typer for consistency with c10s Makefile targets: - Added `triage-issue`: Run triage agent only (AUTO_CHAIN=false) Usage: make triage-issue ISSUE=RHEL-252788 - Added `process`: Run full pipeline without supervisor (AUTO_CHAIN=true) Usage: make process ISSUE=RHEL-252788 Runs triage → backport/rebase/rebuild chain - Kept `process-issue`: Run supervisor-managed pipeline Usage: make process-issue ISSUE=RHEL-252788 These changes fix ModuleNotFoundError and restore the old pipeline workflow. Related: PACKIT-5281 Assisted-by: Claude Sonnet 4.5 (1M context) --- Containerfile.c10s | 1 + Containerfile.c9s | 1 + Containerfile.supervisor | 3 +++ Makefile | 16 ++++++++++++++++ 4 files changed, 21 insertions(+) diff --git a/Containerfile.c10s b/Containerfile.c10s index 46b9038e9..a81462cc6 100644 --- a/Containerfile.c10s +++ b/Containerfile.c10s @@ -84,6 +84,7 @@ RUN pip3 install --no-cache-dir \ unidiff \ PyYAML>=5.1 \ sentry-sdk>=2.13.0 \ + typer \ && cd /usr/local/lib/python3.12/site-packages \ && patch -p5 -i /tmp/openinference-reasoning.patch \ && patch -p5 -i /tmp/openinference-streaming.patch diff --git a/Containerfile.c9s b/Containerfile.c9s index dee995e65..a01a8bb8a 100644 --- a/Containerfile.c9s +++ b/Containerfile.c9s @@ -87,6 +87,7 @@ RUN python3.11 -m venv --system-site-packages /opt/beeai-venv \ unidiff \ PyYAML>=5.1 \ sentry-sdk>=2.13.0 \ + typer \ && cd /opt/beeai-venv/lib/python3.11/site-packages \ && patch -p5 -i /tmp/openinference-reasoning.patch \ && patch -p5 -i /tmp/openinference-streaming.patch diff --git a/Containerfile.supervisor b/Containerfile.supervisor index 0f9e19b01..8a31630f0 100644 --- a/Containerfile.supervisor +++ b/Containerfile.supervisor @@ -10,6 +10,7 @@ RUN update-ca-trust RUN dnf -y install --allowerasing \ # Run time dependencies + git \ krb5-workstation \ python3 \ python3-backoff \ @@ -33,6 +34,8 @@ RUN dnf -y install --allowerasing \ arize-phoenix-otel \ redis \ specfile \ + sentry-sdk>=2.13.0 \ + GitPython>=3.1.0 \ && dnf -y remove gcc gcc-c++ python3-devel \ && dnf clean all diff --git a/Makefile b/Makefile index d8a8f2ca3..15cefd363 100644 --- a/Makefile +++ b/Makefile @@ -430,6 +430,22 @@ supervisor-collect: $(COMPOSE_SUPERVISOR) run --rm \ supervisor python -m ymir.supervisor.main $(DEBUG_FLAG) collect --no-repeat +.PHONY: triage-issue +triage-issue: + $(COMPOSE_AGENTS) run --rm \ + -e JIRA_ISSUE=$(ISSUE) \ + -e DRY_RUN=$(DRY_RUN) \ + -e AUTO_CHAIN=false \ + triage-agent + +.PHONY: process +process: + $(COMPOSE_AGENTS) run --rm \ + -e JIRA_ISSUE=$(ISSUE) \ + -e DRY_RUN=$(DRY_RUN) \ + -e AUTO_CHAIN=true \ + triage-agent + .PHONY: process-issue process-issue: $(COMPOSE_SUPERVISOR) run --rm \ From 9ad48d3854131152496fbc69ea958a571317fa9a Mon Sep 17 00:00:00 2001 From: Maja Massarini Date: Thu, 3 Sep 2026 08:53:26 +0200 Subject: [PATCH 5/6] Fix: Resolve rebase conflicts from upstream API changes After rebasing onto upstream/main (commit 0276b385), the _check_zstream_clones_shipped function signature changed from returning tuple[bool, list[str]] to returning ZStreamDependencyResult object. Updated test mocks to match the new API: - test_eligibility_dependency_blocker_zstream_not_affected - test_eligibility_dependency_blocker_zstream_not_affected_error These tests were using the old tuple format (False, ["RHEL-777"]) which caused TypeError when the code tried to access the ZStreamDependencyResult attributes. Also fixed _check_dependency_blocker return statements to properly return tuples matching the function signature tuple[JSONToolOutput | None, list[ShippedZStreamCandidate]]: - Line 1217 (NOT_AFFECTED check exception): Now returns (JSONToolOutput(...), []) - Line 1232 (NOT_AFFECTED clones found): Now returns (None, []) These were returning single values, causing 'cannot unpack non-iterable' errors. Related: PACKIT-5281 Assisted-by: Claude Sonnet 4.5 (1M context) --- ymir/tools/privileged/jira.py | 19 +++++++++++-------- ymir/tools/privileged/tests/unit/test_jira.py | 8 ++++++-- 2 files changed, 17 insertions(+), 10 deletions(-) diff --git a/ymir/tools/privileged/jira.py b/ymir/tools/privileged/jira.py index 122492560..7b041024d 100644 --- a/ymir/tools/privileged/jira.py +++ b/ymir/tools/privileged/jira.py @@ -1214,13 +1214,16 @@ async def _check_for_dependency_blocker( ) except Exception as e: logger.warning(f"Z-stream NOT_AFFECTED check failed for {cve_id}: {e}") - return JSONToolOutput( - CVEEligibilityResult( - is_cve=True, - eligibility=TriageEligibility.NEVER, - reason=f"CVE {cve_id} ({target_version}): NOT_AFFECTED check failed: {e}", - error=str(e), - ).model_dump() + return ( + JSONToolOutput( + CVEEligibilityResult( + is_cve=True, + eligibility=TriageEligibility.NEVER, + reason=f"CVE {cve_id} ({target_version}): NOT_AFFECTED check failed: {e}", + error=str(e), + ).model_dump() + ), + [], ) if not_affected_clones: @@ -1229,7 +1232,7 @@ async def _check_for_dependency_blocker( "Y-stream should also be triaged" ) # Return None to proceed with triage (same as if a clone had shipped) - return None + return None, [] logger.info( f"Dependency check for {issue_key} ({target_version}): PENDING_DEPENDENCIES " diff --git a/ymir/tools/privileged/tests/unit/test_jira.py b/ymir/tools/privileged/tests/unit/test_jira.py index bd390c151..8dfe46e27 100644 --- a/ymir/tools/privileged/tests/unit/test_jira.py +++ b/ymir/tools/privileged/tests/unit/test_jira.py @@ -1595,7 +1595,9 @@ async def test_eligibility_dependency_blocker_zstream_not_affected(): # Has Z-stream clones but none shipped yet (would normally block) flexmock(jira_tools).should_receive("_check_zstream_clones_shipped").with_args( "CVE-2025-12345", "curl", "RHEL-12345" - ).and_return(_create_async_return((False, ["RHEL-777"]))).once() + ).and_return( + _create_async_return(ZStreamDependencyResult(any_shipped=False, pending_keys=["RHEL-777"])) + ).once() # But Z-stream was NOT_AFFECTED, so should proceed anyway flexmock(jira_tools).should_receive("_check_zstream_not_affected").with_args( "CVE-2025-12345", "curl", "RHEL-12345", "9", "CVE-2025-12345 buffer overflow in curl [rhel-9.8]" @@ -1628,7 +1630,9 @@ async def test_eligibility_dependency_blocker_zstream_not_affected_error(): # Has Z-stream clones but none shipped yet flexmock(jira_tools).should_receive("_check_zstream_clones_shipped").with_args( "CVE-2025-12345", "curl", "RHEL-12345" - ).and_return(_create_async_return((False, ["RHEL-777"]))).once() + ).and_return( + _create_async_return(ZStreamDependencyResult(any_shipped=False, pending_keys=["RHEL-777"])) + ).once() # Simulate Jira search failure async def raise_error(*args, **kwargs): From 9dc981a5ac828c3ba33cb350602efd2236421a8b Mon Sep 17 00:00:00 2001 From: Maja Massarini Date: Thu, 3 Sep 2026 10:58:07 +0200 Subject: [PATCH 6/6] Fix: Code review findings 1. _check_zstream_pending_triage(): Exclude SUCCESS labels only (backported/rebased/rebuilt). Failed/errored labels NOT excluded (Z-stream path blocked, Y-stream may be needed). 2. Containerfile shell redirection: Quote pip requirements with >= operators. Fixed: PyYAML>=5.1, sentry-sdk>=2.13.0, GitPython>=3.1.0 (in Containerfile.supervisor, .c10s, .c9s) 3. NOT_AFFECTED reason: Return specific 'Z-stream clone RHEL-XXX was NOT_AFFECTED' message. 4. Pagination fix: Add fixVersion filter to JQL (SearchJiraIssuesTool max 50 results, no pagination). 5. Duplicate preservation: Pass duplicate_of through _check_for_dependency_blocker(). 6. Postponed labels: Exclude ymir_postponed_{dependency,no_patch,pr_pending} from pending-triage JQL. 7. Makefile: Change $(ISSUE) to $(JIRA_ISSUE) in triage-issue/process targets, add guards. Related: PACKIT-5281 Assisted-by: Claude Sonnet 4.5 (1M context) --- Containerfile.c10s | 6 +- Containerfile.c9s | 6 +- Containerfile.supervisor | 4 +- Makefile | 12 ++- ymir/tools/privileged/jira.py | 57 ++++++++++-- ymir/tools/privileged/tests/unit/test_jira.py | 42 +++++++++ .../tests/unit/test_jira_zstream_status.py | 90 +++++++++++++++++++ 7 files changed, 198 insertions(+), 19 deletions(-) diff --git a/Containerfile.c10s b/Containerfile.c10s index a81462cc6..eb50ec0fd 100644 --- a/Containerfile.c10s +++ b/Containerfile.c10s @@ -80,10 +80,10 @@ RUN pip3 install --no-cache-dir \ specfile \ pytest \ pytest-asyncio \ - GitPython>=3.1.0 \ + "GitPython>=3.1.0" \ unidiff \ - PyYAML>=5.1 \ - sentry-sdk>=2.13.0 \ + "PyYAML>=5.1" \ + "sentry-sdk>=2.13.0" \ typer \ && cd /usr/local/lib/python3.12/site-packages \ && patch -p5 -i /tmp/openinference-reasoning.patch \ diff --git a/Containerfile.c9s b/Containerfile.c9s index a01a8bb8a..6e0891285 100644 --- a/Containerfile.c9s +++ b/Containerfile.c9s @@ -83,10 +83,10 @@ RUN python3.11 -m venv --system-site-packages /opt/beeai-venv \ redis \ specfile \ koji \ - GitPython>=3.1.0 \ + "GitPython>=3.1.0" \ unidiff \ - PyYAML>=5.1 \ - sentry-sdk>=2.13.0 \ + "PyYAML>=5.1" \ + "sentry-sdk>=2.13.0" \ typer \ && cd /opt/beeai-venv/lib/python3.11/site-packages \ && patch -p5 -i /tmp/openinference-reasoning.patch \ diff --git a/Containerfile.supervisor b/Containerfile.supervisor index 8a31630f0..43c4d8f80 100644 --- a/Containerfile.supervisor +++ b/Containerfile.supervisor @@ -34,8 +34,8 @@ RUN dnf -y install --allowerasing \ arize-phoenix-otel \ redis \ specfile \ - sentry-sdk>=2.13.0 \ - GitPython>=3.1.0 \ + "sentry-sdk>=2.13.0" \ + "GitPython>=3.1.0" \ && dnf -y remove gcc gcc-c++ python3-devel \ && dnf clean all diff --git a/Makefile b/Makefile index 15cefd363..0b466b8c6 100644 --- a/Makefile +++ b/Makefile @@ -432,16 +432,24 @@ supervisor-collect: .PHONY: triage-issue triage-issue: + @if [ -z "$(JIRA_ISSUE)" ]; then \ + echo "Usage: make triage-issue JIRA_ISSUE=RHEL-12345 [DRY_RUN=true]"; \ + exit 1; \ + fi $(COMPOSE_AGENTS) run --rm \ - -e JIRA_ISSUE=$(ISSUE) \ + -e JIRA_ISSUE=$(JIRA_ISSUE) \ -e DRY_RUN=$(DRY_RUN) \ -e AUTO_CHAIN=false \ triage-agent .PHONY: process process: + @if [ -z "$(JIRA_ISSUE)" ]; then \ + echo "Usage: make process JIRA_ISSUE=RHEL-12345 [DRY_RUN=true]"; \ + exit 1; \ + fi $(COMPOSE_AGENTS) run --rm \ - -e JIRA_ISSUE=$(ISSUE) \ + -e JIRA_ISSUE=$(JIRA_ISSUE) \ -e DRY_RUN=$(DRY_RUN) \ -e AUTO_CHAIN=true \ triage-agent diff --git a/ymir/tools/privileged/jira.py b/ymir/tools/privileged/jira.py index 7b041024d..6885797c7 100644 --- a/ymir/tools/privileged/jira.py +++ b/ymir/tools/privileged/jira.py @@ -850,9 +850,12 @@ async def _check_zstream_not_affected( escaped_cve_id = cve_id.replace('"', '\\"') escaped_component = component.replace('"', '\\"') + # Build fixVersion filter from variants to narrow the search and avoid hitting max_results limit + fv_filter = " OR ".join(f'fixVersion = "{fv}"' for fv in relevant_z_streams) jql = ( f'summary ~ "{escaped_cve_id}" AND component = "{escaped_component}"' f' AND labels = "SecurityTracking" AND labels = "ymir_triaged_not_affected"' + f" AND ({fv_filter})" f' AND key != "{exclude_key}"' ) logger.info( @@ -931,12 +934,25 @@ async def _check_zstream_pending_triage( escaped_cve_id = cve_id.replace('"', '\\"') escaped_component = component.replace('"', '\\"') - # Search for Z-stream clones without any terminal labels. - # Terminal labels indicate triage completion (success or blocked state): - # - ymir_triaged_* (backport/rebase/rebuild/postponed/not_affected/generic) - # - ymir_needs_attention (clarification needed - blocked) - # - ymir_triage_errored (exhausted retries - blocked) - # Non-terminal labels like ymir_triage_in_progress are not excluded. + # Build fixVersion filter from variants to narrow the search and avoid hitting max_results limit + fv_filter = " OR ".join(f'fixVersion = "{fv}"' for fv in relevant_z_streams) + # Search for Z-stream clones without any terminal SUCCESS labels. + # Terminal success labels indicate the Z-stream fix path is working: + # - ymir_triaged_* (backport/rebase/rebuild/not_affected - triage decision made) + # - ymir_postponed_* (dependency/no_patch/pr_pending - postponed with reason, also terminal) + # - ymir_*ed (backported/rebased/rebuilt - action completed successfully) + # - ymir_needs_attention (clarification needed - terminal blocked state) + # - ymir_triage_errored (exhausted retries - terminal error state) + # + # Note: Failed/errored action labels (ymir_*_failed, ymir_*_errored) are NOT + # excluded because they indicate the Z-stream path is blocked/stuck, so the + # Y-stream should not be skipped (it might be needed as a fallback or the + # failure might be retried). + # + # ymir_triaged_postponed is deprecated (never applied by current code, replaced + # by ymir_postponed_* labels with specific reasons). + # + # Non-terminal labels like ymir_triage_in_progress are also not excluded. jql = ( f'summary ~ "{escaped_cve_id}" AND component = "{escaped_component}"' f' AND labels = "SecurityTracking"' @@ -946,8 +962,15 @@ async def _check_zstream_pending_triage( f' AND labels != "ymir_triaged_postponed"' f' AND labels != "ymir_triaged_not_affected"' f' AND labels != "ymir_triaged"' + f' AND labels != "ymir_postponed_dependency"' + f' AND labels != "ymir_postponed_no_patch"' + f' AND labels != "ymir_postponed_pr_pending"' + f' AND labels != "ymir_backported"' + f' AND labels != "ymir_rebased"' + f' AND labels != "ymir_rebuilt"' f' AND labels != "ymir_needs_attention"' f' AND labels != "ymir_triage_errored"' + f" AND ({fv_filter})" f' AND key != "{exclude_key}"' ) logger.info( @@ -1146,6 +1169,7 @@ async def _check_for_dependency_blocker( issue_key: str, fields: dict[str, Any], target_version: str, + duplicate_of: str | None = None, ) -> tuple[JSONToolOutput[dict[str, Any]] | None, list[ShippedZStreamCandidate]]: """Return a blocker response and any shipped inheritance candidates.""" summary = fields.get("summary", "") @@ -1231,8 +1255,23 @@ async def _check_for_dependency_blocker( f"Z-stream clone(s) {not_affected_clones} for {cve_id} were NOT_AFFECTED, " "Y-stream should also be triaged" ) - # Return None to proceed with triage (same as if a clone had shipped) - return None, [] + # Return a result with NOT_AFFECTED-specific reason (don't return None) + return ( + JSONToolOutput( + CVEEligibilityResult( + is_cve=True, + eligibility=TriageEligibility.IMMEDIATELY, + reason=( + f"Y-stream CVE ({target_version}): " + f"Z-stream clone {not_affected_clones[0]} was NOT_AFFECTED, " + "checking if Y-stream is also not affected" + ), + needs_internal_fix=False, + duplicate_of=duplicate_of, + ).model_dump() + ), + [], + ) logger.info( f"Dependency check for {issue_key} ({target_version}): PENDING_DEPENDENCIES " @@ -1328,7 +1367,7 @@ async def _check_ystream_eligibility( logger.info(f"Severity is {severity or 'unset'}, checking Z-stream dependencies") blocker, shipped_candidates = await self._check_for_dependency_blocker( - issue_key, fields, target_version + issue_key, fields, target_version, duplicate_of=duplicate_of ) if blocker is not None: return blocker diff --git a/ymir/tools/privileged/tests/unit/test_jira.py b/ymir/tools/privileged/tests/unit/test_jira.py index 8dfe46e27..946fff7a7 100644 --- a/ymir/tools/privileged/tests/unit/test_jira.py +++ b/ymir/tools/privileged/tests/unit/test_jira.py @@ -1608,6 +1608,9 @@ async def test_eligibility_dependency_blocker_zstream_not_affected(): # needs_internal_fix is False because Z-stream was NOT_AFFECTED (no fix needed) assert result["eligibility"] == TriageEligibility.IMMEDIATELY assert result["needs_internal_fix"] is False + # Verify the reason mentions NOT_AFFECTED, not "clone shipped" + assert "NOT_AFFECTED" in result["reason"] + assert "RHEL-777" in result["reason"] @pytest.mark.asyncio @@ -1647,6 +1650,45 @@ async def raise_error(*args, **kwargs): assert "Jira server unavailable" in result["error"] +@pytest.mark.asyncio +async def test_eligibility_dependency_blocker_zstream_not_affected_with_duplicate(): + """Y-stream with rejected duplicate + NOT_AFFECTED Z-stream — preserve duplicate_of field.""" + issue = _make_jira_issue( + labels=["SecurityTracking"], + fix_versions=[{"name": "rhel-9.8"}], + summary="CVE-2025-12345 buffer overflow in curl [rhel-9.8]", + severity="Important", + components=[{"name": "curl"}], + ) + flexmock(aiohttp.ClientSession).should_receive("get").replace_with(_mock_jira_get(issue)) + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + # Has a rejected/closed duplicate tracker (flag only, don't block) + flexmock(jira_tools).should_receive("_check_duplicate_tracker").and_return( + _create_async_return(("RHEL-99999", False)) + ).once() + # Has Z-stream clones but none shipped yet (would normally block) + flexmock(jira_tools).should_receive("_check_zstream_clones_shipped").with_args( + "CVE-2025-12345", "curl", "RHEL-12345" + ).and_return( + _create_async_return(ZStreamDependencyResult(any_shipped=False, pending_keys=["RHEL-777"])) + ).once() + # But Z-stream was NOT_AFFECTED, so should proceed anyway + flexmock(jira_tools).should_receive("_check_zstream_not_affected").with_args( + "CVE-2025-12345", "curl", "RHEL-12345", "9", "CVE-2025-12345 buffer overflow in curl [rhel-9.8]" + ).and_return(_create_async_return(["RHEL-777"])).once() + + result = (await CheckCveTriageEligibilityTool().run(input={"issue_key": "RHEL-12345"})).result + # Should proceed to triage with NOT_AFFECTED reason + assert result["eligibility"] == TriageEligibility.IMMEDIATELY + assert result["needs_internal_fix"] is False + assert "NOT_AFFECTED" in result["reason"] + assert "RHEL-777" in result["reason"] + # CRITICAL: duplicate_of must be preserved for workflow notification + assert result["duplicate_of"] == "RHEL-99999" + + @pytest.mark.parametrize("severity", ["Low", "Moderate"]) @pytest.mark.asyncio async def test_eligibility_ystream_low_moderate_rhel_first(severity): diff --git a/ymir/tools/privileged/tests/unit/test_jira_zstream_status.py b/ymir/tools/privileged/tests/unit/test_jira_zstream_status.py index db1b8e0ef..ebe5d87ff 100644 --- a/ymir/tools/privileged/tests/unit/test_jira_zstream_status.py +++ b/ymir/tools/privileged/tests/unit/test_jira_zstream_status.py @@ -310,6 +310,96 @@ async def test_check_zstream_pending_triage_clone_is_affected(): assert pending == [] +@pytest.mark.asyncio +async def test_check_zstream_pending_triage_action_completed(): + """Z-stream clone with successful action completion (ymir_backported/rebased/rebuilt) is excluded. + + Successful completions indicate the Z-stream path is working and delivering the fix, + so the Y-stream should be skipped (CS-first approach succeeded). + + Note: When actions complete successfully, they REMOVE the ymir_triaged_* label + and replace it with the success label (ymir_backported, etc.). + """ + # JQL excludes success labels, so search returns empty + search_result = [] + flexmock(SearchJiraIssuesTool).should_receive("run").and_return( + _create_async_return(JSONToolOutput(result=search_result)) + ).once() + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + pending = await _check_zstream_pending_triage( + "CVE-2026-12345", "curl", "RHEL-999", "9", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) + assert pending == [] + + +@pytest.mark.asyncio +async def test_check_zstream_pending_triage_action_failed(): + """Z-stream clone with action failure label (ymir_backport_failed, etc.) is treated as pending. + + Failed/errored actions indicate the Z-stream path is blocked, so Y-stream should not + be skipped. These issues need attention or might be retried. + + Note: When backport/rebase/rebuild agents finish, they REMOVE the ymir_triaged_* label + and replace it with the outcome label (ymir_backport_failed, etc.). + """ + # JQL does NOT exclude failure labels, so they appear in search results + # The ymir_triaged_backport label was removed by the backport agent when it failed + search_result = [ + { + "key": "RHEL-111", + "fields": { + "fixVersions": [{"name": "rhel-9.7.z"}], + "labels": ["SecurityTracking", "ymir_backport_failed"], + }, + }, + ] + flexmock(SearchJiraIssuesTool).should_receive("run").and_return( + _create_async_return(JSONToolOutput(result=search_result)) + ).once() + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + pending = await _check_zstream_pending_triage( + "CVE-2026-12345", "curl", "RHEL-999", "9", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) + # Failed action is treated as pending (Y-stream should not be skipped) + assert pending == ["RHEL-111"] + + +@pytest.mark.parametrize( + "postponed_label", + ["ymir_postponed_dependency", "ymir_postponed_no_patch", "ymir_postponed_pr_pending"], +) +@pytest.mark.asyncio +async def test_check_zstream_pending_triage_postponed_excluded(postponed_label): + """Z-stream clone with postponed label (dependency/no_patch/pr_pending) is excluded. + + Postponed resolutions are terminal triage decisions. The Z-stream has been triaged + and a decision was made to postpone it (waiting for dependency, no patch available, + or PR pending). These are complete triage outcomes, not pending states. + + If these aren't excluded, Y-stream CVEs will wait indefinitely for "Z-stream triage + results" that already exist. + """ + # JQL excludes postponed labels, so search returns empty + search_result = [] + flexmock(SearchJiraIssuesTool).should_receive("run").and_return( + _create_async_return(JSONToolOutput(result=search_result)) + ).once() + flexmock(jira_tools).should_receive("load_rhel_config").and_return( + _create_async_return(RHEL_CONFIG) + ).once() + + pending = await _check_zstream_pending_triage( + "CVE-2026-12345", "curl", "RHEL-999", "9", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) + assert pending == [] + + @pytest.mark.asyncio async def test_check_zstream_pending_triage_old_current_ignored(): """Old current Z-stream clone is ignored when upcoming exists."""