Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ This guide is designed for AI agents working on the Ymir AI workflows project. I
**Consult these first:**
- **[README-agents.md](README-agents.md)** — Full setup, running agents, environment variables, Jira mocking
- **[README.md](README.md)** — Project overview, development environment setup
- **[CONTRIBUTING.md](CONTRIBUTING.md)** — Code merge policy
- **[CONTRIBUTING.md](CONTRIBUTING.md)** — Code merge policy, Mocking in tests

## Agent Architecture

Expand Down Expand Up @@ -121,7 +121,7 @@ If you introduce a new service as a dependency to our agents, make sure to read

## Code Changes Checklist

- [ ] Write tests first (especially for tools/git operations)
- [ ] Write tests first (especially for tools/git operations) — make sure they use `flexmock` for mocking.
- [ ] Run `make check-in-container` — all tests pass
- [ ] Test with `DRY_RUN=true` — don't touch real Jira/git
- [ ] Use rebase merge (see [CONTRIBUTING.md](CONTRIBUTING.md))
Expand Down
4 changes: 3 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,4 +42,6 @@ Prefer rebase-merging over creating a merge commit, unless preserving the branch

## Mocking

`flexmock` is the preferred framework for mocking in tests.
`flexmock` is the preferred mock framework in tests ahead of `pytest-mock` and `unittest.mock`.
Do not use `AsyncMock`, `MagicMock` and `patch` constructs, use `flexmock` instead.
Since `pytest` is used as the general testing framework, `monkeypatch` may be used in some mock cases (environment variables, etc).
42 changes: 13 additions & 29 deletions ymir/agents/tests/unit/test_rebase_consolidation.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import pytest
from flexmock import flexmock

from ymir.agents import rebase_consolidation
from ymir.agents.rebase_agent import _consolidated_issue_keys
from ymir.agents.rebase_consolidation import (
add_jira_tickets_to_latest_changelog_entry,
Expand All @@ -9,7 +11,8 @@
has_new_latest_changelog_entry,
uses_autochangelog,
)
from ymir.common.models import ConsolidatedIssue
from ymir.common.constants import JiraLabels
from ymir.common.models import ConsolidatedIssue, RebaseData
from ymir.common.utils import extract_text_from_adf


Expand Down Expand Up @@ -391,8 +394,6 @@ class TestTerminalLabels:

def test_jql_excludes_all_triage_decision_labels(self):
"""JQL must exclude all triage decision labels to avoid re-queueing decided siblings."""
from ymir.common.constants import JiraLabels

jql = build_rebase_siblings_jql("RHEL-100", "postgresql", "rhel-9.8")

# Verify each triage decision label appears in the JQL exclusion
Expand All @@ -412,8 +413,6 @@ def test_jql_excludes_all_completion_labels(self):

Regression test for RHEL-248139 where ymir_backported was not excluded.
"""
from ymir.common.constants import JiraLabels

jql = build_rebase_siblings_jql("RHEL-100", "postgresql", "rhel-9.8")

# These were the missing labels that caused RHEL-248139
Expand All @@ -430,8 +429,6 @@ def test_jql_excludes_errored_labels(self):
Per jira_label_workflow_routing.md: ERRORED labels (triage/backport/rebase_errored)
block retry and need human attention, so they're terminal for sibling queueing.
"""
from ymir.common.constants import JiraLabels

jql = build_rebase_siblings_jql("RHEL-100", "postgresql", "rhel-9.8")

# ERRORED labels block retry → must exclude
Expand All @@ -452,8 +449,6 @@ def test_jql_includes_failed_labels(self):
"May auto-retry", so excluding them breaks the retry mechanism where a new
sibling triggers re-queueing of failed issues.
"""
from ymir.common.constants import JiraLabels

jql = build_rebase_siblings_jql("RHEL-100", "postgresql", "rhel-9.8")

# FAILED labels may auto-retry → must NOT exclude
Expand All @@ -475,8 +470,6 @@ def test_jql_does_not_exclude_sibling_marker(self):

queue_siblings_for_triage() handles the re-queueing check in its defensive filter.
"""
from ymir.common.constants import JiraLabels

jql = build_rebase_siblings_jql("RHEL-100", "postgresql", "rhel-9.8")

assert f'"{JiraLabels.REBASE_SIBLING.value}"' not in jql, (
Expand All @@ -492,8 +485,6 @@ def test_jql_exclusion_applies_before_50_result_limit(self):

This test verifies the exclusion is in the JQL string (server-side filtering).
"""
from ymir.common.constants import JiraLabels

jql = build_rebase_siblings_jql("RHEL-100", "postgresql", "rhel-9.8")

# Critical: the exclusion MUST be in the JQL query string itself
Expand Down Expand Up @@ -552,10 +543,6 @@ async def test_find_triaged_rebase_siblings_no_unbound_error_on_jira_failure():
The code then checks ``if downstream_component is None and primary_details:``.
Without ``primary_details = None`` before try, this raises UnboundLocalError.
"""
from unittest.mock import AsyncMock, patch

from ymir.common.models import RebaseData

rebase_data = RebaseData(
package="postgis",
version="3.5.2",
Expand All @@ -564,17 +551,14 @@ async def test_find_triaged_rebase_siblings_no_unbound_error_on_jira_failure():
)

# Simulate get_jira_details failure (MCP gateway down, network error, etc.)
with patch(
"ymir.agents.rebase_consolidation.run_tool",
new_callable=AsyncMock,
side_effect=Exception("MCP gateway unreachable"),
):
# Should NOT raise UnboundLocalError — should return empty results
result, summary = await find_triaged_rebase_siblings(
jira_issue="RHEL-250764",
rebase_data=rebase_data,
available_tools=[],
downstream_component=None,
)
flexmock(rebase_consolidation).should_receive("run_tool").and_raise(Exception("MCP Gateway unreachable"))

# Should NOT raise UnboundLocalError — should return empty results
result, summary = await find_triaged_rebase_siblings(
jira_issue="RHEL-250764",
rebase_data=rebase_data,
available_tools=[],
downstream_component=None,
)
assert result == []
assert summary == ""
163 changes: 72 additions & 91 deletions ymir/agents/tests/unit/test_triage_agent.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
from contextlib import asynccontextmanager
from unittest.mock import AsyncMock, patch

import pytest
from flexmock import flexmock
Expand Down Expand Up @@ -33,6 +32,7 @@
Resolution,
Task,
TriageEligibility,
TriageInputSchema,
TriageOutputSchema,
)
from ymir.common.version_utils import extract_downstream_package, is_modular, parse_module_stream
Expand Down Expand Up @@ -406,65 +406,61 @@ def _cve_eligibility(*, needs_internal_fix: bool) -> CVEEligibilityResult:
)


async def _older_zstream_true(*_args, **_kwargs):
return True


async def _older_zstream_false(*_args, **_kwargs):
return False


@pytest.mark.asyncio
async def test_determine_target_branch_modular_internal_fix_no_ystream_uses_cs():
"""RHEL 8 has no Y-stream, so even CVEs needing internal fix go to centos-stream."""
with (
patch(
"ymir.agents.triage_agent.is_older_zstream",
new_callable=AsyncMock,
return_value=False,
),
patch(
"ymir.agents.triage_agent.load_rhel_config",
new_callable=AsyncMock,
return_value={
"current_y_streams": {"9": "rhel-9.9", "10": "rhel-10.3"},
"current_z_streams": {"8": "rhel-8.10.z", "9": "rhel-9.8.z", "10": "rhel-10.2.z"},
},
),
):
branch, namespace = await determine_target_branch(
_cve_eligibility(needs_internal_fix=True),
_modular_backport_data(),
jira_summary=_MODULAR_SUMMARY,
downstream_component="squid",
)

async def _mock_load_rhel_config(*_args, **_kwargs):
return {
"current_y_streams": {"9": "rhel-9.9", "10": "rhel-10.3"},
"current_z_streams": {"8": "rhel-8.10.z", "9": "rhel-9.8.z", "10": "rhel-10.2.z"},
}

flexmock(t_agent).should_receive("is_older_zstream").replace_with(_older_zstream_false)
flexmock(t_agent).should_receive("load_rhel_config").replace_with(_mock_load_rhel_config)

branch, namespace = await determine_target_branch(
_cve_eligibility(needs_internal_fix=True),
_modular_backport_data(),
jira_summary=_MODULAR_SUMMARY,
downstream_component="squid",
)
assert branch == "stream-squid-4-rhel-8.10.0"
assert namespace == "centos-stream"


@pytest.mark.asyncio
async def test_determine_target_branch_modular_internal_fix_with_ystream_uses_rhel():
"""RHEL 9 has a Y-stream, so CVEs needing internal fix go to rhel."""

async def _mock_load_rhel_config(*_args, **_kwargs):
return {"current_y_streams": {"9": "rhel-9.9", "10": "rhel-10.3"}}

summary = "CVE-2026-32748 squid:4/squid: Squid: Denial of Service [rhel-9.8.z]"
with (
patch(
"ymir.agents.triage_agent.is_older_zstream",
new_callable=AsyncMock,
return_value=False,
),
patch(
"ymir.agents.triage_agent.load_rhel_config",
new_callable=AsyncMock,
return_value={"current_y_streams": {"9": "rhel-9.9", "10": "rhel-10.3"}},
),
):
branch, namespace = await determine_target_branch(
_cve_eligibility(needs_internal_fix=True),
_modular_backport_data(fix_version="rhel-9.8.z"),
jira_summary=summary,
downstream_component="squid",
)

flexmock(t_agent).should_receive("is_older_zstream").replace_with(_older_zstream_false)
flexmock(t_agent).should_receive("load_rhel_config").replace_with(_mock_load_rhel_config)

branch, namespace = await determine_target_branch(
_cve_eligibility(needs_internal_fix=True),
_modular_backport_data(fix_version="rhel-9.8.z"),
jira_summary=summary,
downstream_component="squid",
)
assert branch == "stream-squid-4-rhel-9.8.0"
assert namespace == "rhel"


@pytest.mark.asyncio
async def test_determine_target_branch_modular_cs_eligible_uses_centos_stream():
async def _older_zstream_false(*_args, **_kwargs):
return False

flexmock(t_agent).should_receive("is_older_zstream").replace_with(_older_zstream_false)

branch, namespace = await determine_target_branch(
Expand All @@ -479,9 +475,6 @@ async def _older_zstream_false(*_args, **_kwargs):

@pytest.mark.asyncio
async def test_determine_target_branch_modular_older_zstream_uses_rhel():
async def _older_zstream_true(*_args, **_kwargs):
return True

flexmock(t_agent).should_receive("is_older_zstream").replace_with(_older_zstream_true)

branch, namespace = await determine_target_branch(
Expand All @@ -500,32 +493,26 @@ async def test_render_prompt_modular_rhel8_no_internal_fix():
for modular issues even when CVE eligibility says needs_internal_fix=True.
Otherwise the prompt tells the LLM to clone from rhel namespace instead of
centos-stream."""
from ymir.common.models import TriageInputSchema as InputSchema

input_data = InputSchema(issue="RHEL-999")
async def _mock_load_rhel_config(*_args, **_kwargs):
return {
"current_y_streams": {"9": "rhel-9.9", "10": "rhel-10.3"},
"current_z_streams": {"8": "rhel-8.10.z", "9": "rhel-9.8.z", "10": "rhel-10.2.z"},
}

input_data = TriageInputSchema(issue="RHEL-999")
summary = "CVE-2026-32748 squid:4/squid: Denial of Service [rhel-8.10.z]"
with (
patch(
"ymir.agents.triage_agent.is_older_zstream",
new_callable=AsyncMock,
return_value=False,
),
patch(
"ymir.agents.triage_agent.load_rhel_config",
new_callable=AsyncMock,
return_value={
"current_y_streams": {"9": "rhel-9.9", "10": "rhel-10.3"},
"current_z_streams": {"8": "rhel-8.10.z", "9": "rhel-9.8.z", "10": "rhel-10.2.z"},
},
),
):
prompt = await render_prompt(
input_data,
fix_version="rhel-8.10.z",
cve_eligibility_result=_cve_eligibility(needs_internal_fix=True),
jira_summary=summary,
downstream_component="squid",
)

flexmock(t_agent).should_receive("is_older_zstream").replace_with(_older_zstream_false)
flexmock(t_agent).should_receive("load_rhel_config").replace_with(_mock_load_rhel_config)

prompt = await render_prompt(
input_data,
fix_version="rhel-8.10.z",
cve_eligibility_result=_cve_eligibility(needs_internal_fix=True),
jira_summary=summary,
downstream_component="squid",
)
assert "stream-squid-4-rhel-8.10.0" not in prompt
assert "redhat/rhel/rpms" not in prompt

Expand All @@ -534,29 +521,23 @@ async def test_render_prompt_modular_rhel8_no_internal_fix():
async def test_render_prompt_modular_rhel9_has_internal_fix():
"""RHEL 9 has a Y-stream, so render_prompt SHOULD set needs_internal_fix
for modular issues when CVE eligibility says needs_internal_fix=True."""
from ymir.common.models import TriageInputSchema as InputSchema

input_data = InputSchema(issue="RHEL-999")
async def _mock_load_rhel_config(*_args, **_kwargs):
return {"current_y_streams": {"9": "rhel-9.9", "10": "rhel-10.3"}}

input_data = TriageInputSchema(issue="RHEL-999")
summary = "CVE-2026-32748 squid:4/squid: Denial of Service [rhel-9.8.z]"
with (
patch(
"ymir.agents.triage_agent.is_older_zstream",
new_callable=AsyncMock,
return_value=False,
),
patch(
"ymir.agents.triage_agent.load_rhel_config",
new_callable=AsyncMock,
return_value={"current_y_streams": {"9": "rhel-9.9", "10": "rhel-10.3"}},
),
):
prompt = await render_prompt(
input_data,
fix_version="rhel-9.8.z",
cve_eligibility_result=_cve_eligibility(needs_internal_fix=True),
jira_summary=summary,
downstream_component="squid",
)

flexmock(t_agent).should_receive("is_older_zstream").replace_with(_older_zstream_false)
flexmock(t_agent).should_receive("load_rhel_config").replace_with(_mock_load_rhel_config)

prompt = await render_prompt(
input_data,
fix_version="rhel-9.8.z",
cve_eligibility_result=_cve_eligibility(needs_internal_fix=True),
jira_summary=summary,
downstream_component="squid",
)
assert "stream-squid-4-rhel-9.8.0" in prompt


Expand Down
Loading
Loading