Skip to content
Open
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
1 change: 1 addition & 0 deletions ymir/agents/backport_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -1449,6 +1449,7 @@ async def submit_consolidation_job(state):
state.dist_git_branch,
gateway_tools,
redis_conn,
jira_issue=state.jira_issue,
)
except InvalidConsolidationConfigError as e:
logger.warning("Invalid consolidation config for %s: %s", state.package, e)
Expand Down
260 changes: 207 additions & 53 deletions ymir/agents/mr_consolidation_agent.py

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions ymir/agents/rebuild_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,7 @@ async def submit_consolidation_job(state):
state.dist_git_branch,
gateway_tools,
redis_conn,
jira_issue=state.jira_issue,
)
except InvalidConsolidationConfigError as e:
logger.warning("Invalid consolidation config for %s: %s", state.package, e)
Expand Down
10 changes: 9 additions & 1 deletion ymir/agents/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -1075,11 +1075,19 @@ async def try_submit_consolidation_job(
dist_git_branch: str,
gateway_tools: list,
redis_conn,
jira_issue: str | None = None,
) -> None:
"""Fetch consolidation config and submit a job if enabled.

Shared logic used by both the backport and rebuild agents after
creating an MR.
creating an MR. Posts a Jira comment when consolidation is triggered.

Args:
package: The package name
dist_git_branch: The dist-git branch
gateway_tools: List of available MCP tools
redis_conn: Redis connection for job submission
jira_issue: Optional Jira issue key to post consolidation notification

Raises:
InvalidConsolidationConfigError: When ymir.yaml exists but the
Expand Down
141 changes: 140 additions & 1 deletion ymir/agents/tests/unit/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,10 @@
post_user_ack_once,
push_changes,
request_mr_qe_reviews,
try_submit_consolidation_job,
)
from ymir.common.constants import JiraLabels, RedisQueues
from ymir.common.models import Task
from ymir.common.models import PackageConsolidationConfig, Task


@asynccontextmanager
Expand Down Expand Up @@ -812,3 +813,141 @@ async def test_fetch_release_bumping_config_returns_default_when_no_release_bump
assert config.abandon_autorelease is False
assert config.treat_maintenance_rhel_as_zstream is False
assert config.disregard_zstream_nvr_policy is False


# -- try_submit_consolidation_job ---------------------------------------------


def _consolidation_config(merge_mrs: bool = True) -> PackageConsolidationConfig:
return PackageConsolidationConfig(merge_mrs=merge_mrs)


@pytest.mark.asyncio
async def test_try_submit_no_jira_comment_when_issue_is_none():
"""When jira_issue=None no Jira comment is posted, even if the job was submitted."""
with (
patch(
"ymir.agents.tasks.fetch_consolidation_config",
new_callable=AsyncMock,
return_value=_consolidation_config(),
),
patch(
"ymir.agents.tasks.submit_merge_job",
new_callable=AsyncMock,
return_value=True,
),
patch("ymir.agents.tasks.run_tool", new_callable=AsyncMock) as mock_run_tool,
):
await try_submit_consolidation_job(
package="bash",
dist_git_branch="c10s",
gateway_tools=[],
redis_conn=AsyncMock(),
jira_issue=None,
)

mock_run_tool.assert_not_awaited()


@pytest.mark.asyncio
async def test_try_submit_no_jira_comment_when_already_queued():
"""When the job is already queued (submit_merge_job returns False) no comment is posted."""
with (
patch(
"ymir.agents.tasks.fetch_consolidation_config",
new_callable=AsyncMock,
return_value=_consolidation_config(),
),
patch(
"ymir.agents.tasks.submit_merge_job",
new_callable=AsyncMock,
return_value=False,
),
patch("ymir.agents.tasks.run_tool", new_callable=AsyncMock) as mock_run_tool,
):
await try_submit_consolidation_job(
package="bash",
dist_git_branch="c10s",
gateway_tools=[],
redis_conn=AsyncMock(),
jira_issue="RHEL-12345",
)

mock_run_tool.assert_not_awaited()


@pytest.mark.asyncio
async def test_try_submit_jira_comment_failure_is_swallowed():
"""A failure posting the Jira comment must not propagate — it is logged and ignored."""
with (
patch(
"ymir.agents.tasks.fetch_consolidation_config",
new_callable=AsyncMock,
return_value=_consolidation_config(),
),
patch(
"ymir.agents.tasks.submit_merge_job",
new_callable=AsyncMock,
return_value=True,
),
patch(
"ymir.agents.tasks.run_tool",
new_callable=AsyncMock,
side_effect=RuntimeError("Jira down"),
),
):
await try_submit_consolidation_job(
package="bash",
dist_git_branch="c10s",
gateway_tools=[],
redis_conn=AsyncMock(),
jira_issue="RHEL-12345",
)


@pytest.mark.asyncio
async def test_try_submit_skips_when_consolidation_disabled():
"""When merge_mrs=False no job is submitted and no Jira comment is posted."""
with (
patch(
"ymir.agents.tasks.fetch_consolidation_config",
new_callable=AsyncMock,
return_value=_consolidation_config(merge_mrs=False),
),
patch("ymir.agents.tasks.submit_merge_job", new_callable=AsyncMock) as mock_submit,
patch("ymir.agents.tasks.run_tool", new_callable=AsyncMock) as mock_run_tool,
):
await try_submit_consolidation_job(
package="bash",
dist_git_branch="c10s",
gateway_tools=[],
redis_conn=AsyncMock(),
jira_issue="RHEL-12345",
)

mock_submit.assert_not_awaited()
mock_run_tool.assert_not_awaited()


@pytest.mark.asyncio
async def test_try_submit_skips_when_redis_is_none():
"""Without a Redis connection (direct mode) no job is submitted and no comment is posted."""
with (
patch(
"ymir.agents.tasks.fetch_consolidation_config",
new_callable=AsyncMock,
return_value=_consolidation_config(),
),
patch("ymir.agents.tasks.submit_merge_job", new_callable=AsyncMock) as mock_submit,
patch("ymir.agents.tasks.run_tool", new_callable=AsyncMock) as mock_run_tool,
):
await try_submit_consolidation_job(
package="bash",
dist_git_branch="c10s",
gateway_tools=[],
redis_conn=None,
jira_issue="RHEL-12345",
)

mock_submit.assert_not_awaited()
mock_run_tool.assert_not_awaited()
14 changes: 13 additions & 1 deletion ymir/common/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -899,11 +899,23 @@ class MRConsolidationInputSchema(BaseModel):
build_error: str | None = Field(default=None, description="Error encountered during package build")


ConsolidationStatus = Literal[
"nothing_to_consolidate", # Fewer than 2 MRs, or no backport MR found
"mr_not_found", # Could not find MR for specified issue
"consolidation_complete", # Successfully consolidated and created MR
"failed", # Consolidation failed (see error field for details)
]


class MRConsolidationOutputSchema(BaseModel):
"""Output schema for the MR consolidation agent."""

success: bool = Field(description="Whether the consolidation was successfully completed")
status: str = Field(description="Consolidation status with details of how the merge was performed")
status: ConsolidationStatus = Field(description="Consolidation status indicating the outcome type")
status_detail: str | None = Field(
default=None,
description="Human-readable details about the status",
)
srpm_path: Path | None = Field(default=None, description="Absolute path to generated SRPM")
error: str | None = Field(default=None, description="Specific details about an error")
files_to_git_add: list[str] | None = Field(
Expand Down
71 changes: 71 additions & 0 deletions ymir/common/tests/unit/test_models.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import pytest
from pydantic import ValidationError

from ymir.common.models import (
AUTOMATED_RESOLUTION_NOT_SUPPORTED,
TRIAGE_DISCLAIMER,
Expand All @@ -6,6 +9,7 @@
ClarificationNeededData,
ConsolidatedIssue,
ErrorData,
MRConsolidationOutputSchema,
NotAffectedData,
OpenEndedAnalysisData,
PostponedData,
Expand Down Expand Up @@ -670,3 +674,70 @@ def test_reproducer_output_retryable_error():
assert data.test_already_exists is False
restored = ReproducerOutputSchema.model_validate_json(data.model_dump_json())
assert restored.retryable_error is True


# --- MRConsolidationOutputSchema tests ---


class TestMRConsolidationOutputSchema:
@pytest.mark.parametrize(
"status",
[
"nothing_to_consolidate",
"mr_not_found",
"consolidation_complete",
"failed",
],
)
def test_valid_status_values_accepted(self, status):
schema = MRConsolidationOutputSchema(success=True, status=status)
assert schema.status == status

def test_invalid_status_raises_validation_error(self):
with pytest.raises(ValidationError):
MRConsolidationOutputSchema(success=True, status="in_progress")

def test_status_detail_defaults_to_none(self):
schema = MRConsolidationOutputSchema(success=True, status="consolidation_complete")
assert schema.status_detail is None

def test_status_detail_is_set(self):
schema = MRConsolidationOutputSchema(
success=False,
status="failed",
status_detail="Agent error during consolidation",
)
assert schema.status_detail == "Agent error during consolidation"

def test_failed_status_with_error_field(self):
schema = MRConsolidationOutputSchema(
success=False,
status="failed",
status_detail="Unexpected error",
error="Traceback (most recent call last): ...",
)
assert schema.success is False
assert schema.status == "failed"
assert schema.status_detail == "Unexpected error"
assert schema.error is not None

def test_nothing_to_consolidate_is_successful(self):
"""A nothing_to_consolidate result is a success (not a failure)."""
schema = MRConsolidationOutputSchema(
success=True,
status="nothing_to_consolidate",
status_detail="Fewer than 2 unique MRs resolved; nothing to do.",
)
assert schema.success is True
assert schema.error is None

def test_mr_not_found_with_error(self):
schema = MRConsolidationOutputSchema(
success=False,
status="mr_not_found",
status_detail="Could not find an open MR for RHEL-99999",
error="No open MR matching RHEL-99999 in rpms/bash",
)
assert schema.status == "mr_not_found"
assert "RHEL-99999" in schema.status_detail
assert "RHEL-99999" in schema.error
Loading