diff --git a/Containerfile.c10s b/Containerfile.c10s index 46b9038e9..eb50ec0fd 100644 --- a/Containerfile.c10s +++ b/Containerfile.c10s @@ -80,10 +80,11 @@ 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 \ && patch -p5 -i /tmp/openinference-streaming.patch diff --git a/Containerfile.c9s b/Containerfile.c9s index dee995e65..6e0891285 100644 --- a/Containerfile.c9s +++ b/Containerfile.c9s @@ -83,10 +83,11 @@ 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 \ && patch -p5 -i /tmp/openinference-streaming.patch diff --git a/Containerfile.supervisor b/Containerfile.supervisor index 0f9e19b01..43c4d8f80 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..0b466b8c6 100644 --- a/Makefile +++ b/Makefile @@ -430,6 +430,30 @@ supervisor-collect: $(COMPOSE_SUPERVISOR) run --rm \ supervisor python -m ymir.supervisor.main $(DEBUG_FLAG) collect --no-repeat +.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=$(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=$(JIRA_ISSUE) \ + -e DRY_RUN=$(DRY_RUN) \ + -e AUTO_CHAIN=true \ + triage-agent + .PHONY: process-issue process-issue: $(COMPOSE_SUPERVISOR) run --rm \ diff --git a/ymir/tools/privileged/jira.py b/ymir/tools/privileged/jira.py index ebce8214c..6885797c7 100644 --- a/ymir/tools/privileged/jira.py +++ b/ymir/tools/privileged/jira.py @@ -795,6 +795,234 @@ 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, summary: 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. + + 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) + 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 [] + + # 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('"', '\\"') + # 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( + 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", "summary"], + "max_results": 50, + } + ) + issues = output.result or [] + + 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] + + # 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( + 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, summary: 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. + + 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) + 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 [] + + # 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('"', '\\"') + # 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"' + 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_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( + 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", "summary"], + "max_results": 50, + } + ) + issues = output.result or [] + + 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", []) + + # 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( + 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)") @@ -941,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", "") @@ -999,6 +1228,51 @@ 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, summary + ) + 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 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 " f"(no clones shipped yet, waiting for: {dependency.pending_keys})" @@ -1093,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 @@ -1177,22 +1451,94 @@ 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.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( + 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}") + 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() + ) - if approach is FixApproach.CS_FIRST: + # 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, summary + ) + 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() + ) + + # 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( is_cve=True, diff --git a/ymir/tools/privileged/tests/unit/test_jira.py b/ymir/tools/privileged/tests/unit/test_jira.py index 86cf965d5..946fff7a7 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", "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 buffer overflow in curl [rhel-9.8]" + ).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,314 @@ 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", "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 + 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", "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 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 + 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", "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 + 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", "CVE-2025-12345 buffer overflow in curl [rhel-9.8]" + ).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(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 (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 + # Verify the reason mentions NOT_AFFECTED, not "clone shipped" + assert "NOT_AFFECTED" in result["reason"] + assert "RHEL-777" in result["reason"] + + +@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(ZStreamDependencyResult(any_shipped=False, pending_keys=["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.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 new file mode 100644 index 000000000..ebe5d87ff --- /dev/null +++ b/ymir/tools/privileged/tests/unit/test_jira_zstream_status.py @@ -0,0 +1,650 @@ +"""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"}], + "summary": "CVE-2026-12345 buffer overflow in curl [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", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) + 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", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) + 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", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) + 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 + "summary": "CVE-2026-12345 buffer overflow in curl [rhel-8.10.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", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) + 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", "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"] + + +@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", "CVE-2026-12345 buffer overflow in curl [rhel-7.8]" + ) + 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", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) + 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", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) + 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", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) + 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", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) + 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.""" + 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", "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"] + + +@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", "CVE-2026-12345 buffer overflow in curl [rhel-7.8]" + ) + 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", "CVE-2026-12345 buffer overflow in curl [rhel-9.8]" + ) + + +@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", "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 == []