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
14 changes: 11 additions & 3 deletions ymir/agents/backport_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -744,6 +744,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 All @@ -768,9 +769,16 @@ async def comment_in_jira(state):
if dry_run:
return Workflow.END
if state.backport_result.success:
comment_text = (
state.merge_request_url if state.merge_request_url else state.backport_result.status
)
if state.merge_request_url:
if not state.merge_request_newly_created:
comment_text = (
f"MR: {state.merge_request_url}\n\n"
"The existing MR was reused; MR consolidation will not run for this issue."
)
else:
comment_text = f"New merge request was created: {state.merge_request_url}"
else:
comment_text = state.backport_result.status
is_error = False
else:
comment_text = f"Agent failed to perform a backport: {state.backport_result.error}"
Expand Down
254 changes: 198 additions & 56 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 @@ -294,6 +294,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
31 changes: 30 additions & 1 deletion ymir/agents/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -937,11 +937,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 All @@ -965,5 +973,26 @@ async def try_submit_consolidation_job(
)
if submitted:
logger.info("Submitted consolidation job for %s/%s", package, dist_git_branch)

# Post a Jira comment notifying that consolidation has been triggered

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

iiuc the code here, this is run before the actual eligibility check (list_open_mrs checking >= 2 MRs). So users could often see:

"Your MR has been queued... a consolidated MR will be created automatically"

and later on:

"Fewer than 2 MRs; nothing to do."

I think we might want to skip this and comment about it just when we are 100% sure, to avoid spammy behaviour

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

instead, I would prefer if we had a dedicated comment once the pre-checks are run and satisfied with something like "Consolidation is starting", wdyt?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My ideal state, would be that no maintainer has to ask: "What is Ymir waiting for?" Obviously we can't control that, but we should at least be able to tell them: "Check Jira comments."

So I'm open to moving the triggered message to the point when all conditions are cleared, but we should notify on all cases when the consolidation is impossible.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My ideal state, would be that no maintainer has to ask: "What is Ymir waiting for?" Obviously we can't control that, but we should at least be able to tell them: "Check Jira comments."

I totally agree on this, but this needs to be also aligned with not spamming them, which commenting if there is just 1 MR seems like to me. Wondering if we could maybe utilise labels as well?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think labels may be worse. There is already a lot of them, and we should make people memorize them all. How about modifying the comment instead? That wouldn't create a new comment, and it would provide all the information.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that makes sense, but regarding the 1 MR scenario, I would still avoid commenting on that (what I described in #754 (comment))

if jira_issue:
try:
await run_tool(
"add_jira_comment",
issue_key=jira_issue,
comment=JIRA_COMMENT_TEMPLATE.substitute(
AGENT_TYPE="MR Consolidation",
JIRA_COMMENT=(
f"Your MR has been queued for consolidation with other open MRs "
f"for {package} on {dist_git_branch}. "
f"A consolidated MR will be created automatically once processing completes."
),
),
private=True,
available_tools=gateway_tools,
)
logger.info("Posted consolidation notification to %s", jira_issue)
except Exception as e:
logger.warning("Failed to post consolidation notification to %s: %s", jira_issue, e)
else:
logger.info("Consolidation job already queued for %s/%s", package, dist_git_branch)
170 changes: 169 additions & 1 deletion ymir/agents/tests/unit/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,10 @@
handle_zstream_branch_stale_error,
needs_zstream_target_label,
post_user_ack_once,
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 @@ -681,3 +682,170 @@ async def test_handle_zstream_branch_stale_error_skips_comment_on_dry_run():
mock_labels.assert_awaited_once()
mock_comment.assert_not_awaited()
redis.lpush.assert_awaited_once()


# -- 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_posts_jira_comment_when_submitted_with_issue():
"""When the job is newly submitted and jira_issue is given, a Jira 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=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="RHEL-12345",
)

mock_run_tool.assert_awaited_once()
assert mock_run_tool.call_args.args[0] == "add_jira_comment"
assert mock_run_tool.call_args.kwargs["issue_key"] == "RHEL-12345"


@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 @@ -817,11 +817,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 @@ -639,3 +643,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