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
9 changes: 7 additions & 2 deletions ymir/agents/backport_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
import ymir.agents.tasks as tasks
from ymir.agents.build_agent import create_build_agent
from ymir.agents.build_agent import get_prompt as get_build_prompt
from ymir.agents.constants import I_AM_YMIR, mr_description_footer
from ymir.agents.constants import I_AM_YMIR, ZSTREAM_TARGET_LABEL, mr_description_footer
from ymir.agents.log_agent import create_log_agent
from ymir.agents.log_agent import get_prompt as get_log_prompt
from ymir.agents.observability import setup_observability
Expand Down Expand Up @@ -705,7 +705,12 @@ async def commit_push_and_open_mr(state):
),
available_tools=gateway_tools,
commit_only=dry_run,
labels=["ymir_backport"],
labels=["ymir_backport"]
+ (
[ZSTREAM_TARGET_LABEL]
if await tasks.needs_zstream_target_label(state.dist_git_branch, state.fix_version)
else []
),
)
except Exception as e:
logger.warning(f"Error committing and opening MR: {e}")
Expand Down
1 change: 1 addition & 0 deletions ymir/agents/constants.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from string import Template

BRANCH_PREFIX = "automated-package-update"
ZSTREAM_TARGET_LABEL = "target::zstream"

AGENT_WARNING = (
"Warning: This is an AI-Generated contribution and may contain mistakes. "
Expand Down
21 changes: 16 additions & 5 deletions ymir/agents/mr_consolidation_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
import ymir.agents.tasks as tasks
from ymir.agents.build_agent import create_build_agent
from ymir.agents.build_agent import get_prompt as get_build_prompt
from ymir.agents.constants import I_AM_YMIR, mr_description_footer
from ymir.agents.constants import I_AM_YMIR, ZSTREAM_TARGET_LABEL, mr_description_footer
from ymir.agents.log_agent import create_log_agent
from ymir.agents.log_agent import get_prompt as get_log_prompt
from ymir.agents.observability import setup_observability
Expand Down Expand Up @@ -980,30 +980,41 @@ async def push_and_open_mr(state):
available_tools=gateway_tools,
)

source_labels = {
label
for mr in state.all_open_mrs
if mr["url"] in state.mr_urls
for label in (mr.get("labels") or [])
}
labels = ["ymir_backport"]
if ZSTREAM_TARGET_LABEL in source_labels:
labels.append(ZSTREAM_TARGET_LABEL)

mr_result_raw = await run_tool(
"open_merge_request",
fork_url=state.fork_url,
title=title,
description=combined_description,
target=dist_git_branch,
source=state.update_branch,
labels=labels,
available_tools=gateway_tools,
)
mr_result = json.loads(mr_result_raw) if isinstance(mr_result_raw, str) else mr_result_raw
state.merge_request_url = mr_result.get("url", mr_result_raw)
state.merge_request_newly_created = mr_result.get("is_new", True)
state.merge_request_newly_created = mr_result.get("is_new_mr", True)
logger.info("Consolidated MR created: %s", state.merge_request_url)

if state.merge_request_url:
if not state.merge_request_newly_created and state.merge_request_url and labels:
try:
await run_tool(
"add_merge_request_labels",
merge_request_url=state.merge_request_url,
labels=["ymir_backport"],
labels=labels,
available_tools=gateway_tools,
)
except Exception as e:
logger.warning("Failed to label consolidated MR: %s", e)
logger.warning("Failed to label reused consolidated MR: %s", e)
except Exception as e:
logger.error("Failed to create consolidated MR: %s", e)
state.consolidation_result = MRConsolidationOutputSchema(
Expand Down
16 changes: 14 additions & 2 deletions ymir/agents/rebase_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
import ymir.agents.tasks as tasks
from ymir.agents.build_agent import create_build_agent
from ymir.agents.build_agent import get_prompt as get_build_prompt
from ymir.agents.constants import I_AM_YMIR, mr_description_footer
from ymir.agents.constants import I_AM_YMIR, ZSTREAM_TARGET_LABEL, mr_description_footer
from ymir.agents.log_agent import create_log_agent
from ymir.agents.log_agent import get_prompt as get_log_prompt
from ymir.agents.observability import setup_observability
Expand Down Expand Up @@ -129,6 +129,7 @@ async def main() -> None:

class State(PackageUpdateState):
version: str
fix_version: str | None = Field(default=None)
justification: str | None = Field(default=None)
triage_summary: str | None = Field(default=None)
fedora_clone: Path | None = Field(default=None)
Expand All @@ -143,6 +144,7 @@ async def run_workflow(
dist_git_branch,
version,
jira_issue,
fix_version=None,
justification=None,
triage_summary=None,
redis_conn=None,
Expand Down Expand Up @@ -348,7 +350,14 @@ async def commit_push_and_open_mr(state):
),
available_tools=gateway_tools,
commit_only=dry_run,
labels=["ymir_rebase"],
labels=["ymir_rebase"]
+ (
[ZSTREAM_TARGET_LABEL]
if await tasks.needs_zstream_target_label(
state.dist_git_branch, state.fix_version
)
else []
),
)
except Exception as e:
logger.warning(f"Error committing and opening MR: {e}")
Expand Down Expand Up @@ -394,6 +403,7 @@ async def comment_in_jira(state):
dist_git_branch=dist_git_branch,
version=version,
jira_issue=jira_issue,
fix_version=fix_version,
justification=justification,
triage_summary=triage_summary,
),
Expand All @@ -413,6 +423,7 @@ async def comment_in_jira(state):
dist_git_branch=branch,
version=version,
jira_issue=jira_issue,
fix_version=os.getenv("FIX_VERSION"),
justification=os.getenv("JUSTIFICATION", None),
triage_summary=os.getenv("TRIAGE_SUMMARY", None),
redis_conn=None,
Expand Down Expand Up @@ -510,6 +521,7 @@ async def retry(
dist_git_branch=dist_git_branch,
version=rebase_data.version,
jira_issue=rebase_data.jira_issue,
fix_version=rebase_data.fix_version,
justification=rebase_data.justification,
triage_summary=rebase_data.triage_summary,
redis_conn=redis,
Expand Down
16 changes: 14 additions & 2 deletions ymir/agents/rebuild_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
from pydantic import Field

import ymir.agents.tasks as tasks
from ymir.agents.constants import I_AM_YMIR, mr_description_footer
from ymir.agents.constants import I_AM_YMIR, ZSTREAM_TARGET_LABEL, mr_description_footer
from ymir.agents.log_agent import create_log_agent
from ymir.agents.log_agent import get_prompt as get_log_prompt
from ymir.agents.observability import setup_observability
Expand Down Expand Up @@ -55,6 +55,7 @@ async def main() -> None:
class State(PackageUpdateState):
rebuild_success: bool = Field(default=False)
rebuild_error: str | None = Field(default=None)
fix_version: str | None = Field(default=None)
justification: str | None = Field(default=None)
triage_summary: str | None = Field(default=None)
dependency_issue: str | None = Field(default=None)
Expand All @@ -67,6 +68,7 @@ async def run_workflow(
package,
dist_git_branch,
jira_issue,
fix_version=None,
justification=None,
triage_summary=None,
dependency_issue=None,
Expand Down Expand Up @@ -248,7 +250,14 @@ async def commit_push_and_open_mr(state):
available_tools=gateway_tools,
commit_only=dry_run,
allow_empty=is_empty_commit,
labels=["ymir_rebuild"],
labels=["ymir_rebuild"]
+ (
[ZSTREAM_TARGET_LABEL]
if await tasks.needs_zstream_target_label(
state.dist_git_branch, state.fix_version
)
else []
),
)
state.rebuild_success = True
except Exception as e:
Expand Down Expand Up @@ -300,6 +309,7 @@ async def comment_in_jira(state):
package=package,
dist_git_branch=dist_git_branch,
jira_issue=jira_issue,
fix_version=fix_version,
justification=justification,
triage_summary=triage_summary,
dependency_issue=dependency_issue,
Expand Down Expand Up @@ -327,6 +337,7 @@ async def comment_in_jira(state):
package=package,
dist_git_branch=branch,
jira_issue=jira_issue,
fix_version=os.getenv("FIX_VERSION"),
justification=os.getenv("JUSTIFICATION", None),
triage_summary=os.getenv("TRIAGE_SUMMARY", None),
dependency_issue=dependency_issue,
Expand Down Expand Up @@ -448,6 +459,7 @@ async def retry(
package=rebuild_data.package,
dist_git_branch=dist_git_branch,
jira_issue=rebuild_data.jira_issue,
fix_version=rebuild_data.fix_version,
justification=rebuild_data.justification,
triage_summary=rebuild_data.triage_summary,
dependency_issue=rebuild_data.dependency_issue,
Expand Down
37 changes: 31 additions & 6 deletions ymir/agents/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from ymir.agents.constants import BRANCH_PREFIX, JIRA_COMMENT_TEMPLATE
from ymir.agents.utils import check_subprocess, mcp_tools, run_subprocess, run_tool
from ymir.common.base_utils import is_cs_branch
from ymir.common.config import load_rhel_config
from ymir.common.merge_queue import ( # noqa: F401 — re-exported for agents and tests
_CONSOLIDATION_HASH_KEY,
_consolidation_field_key,
Expand All @@ -29,12 +30,31 @@
Task,
)
from ymir.common.utils import get_all_sources
from ymir.common.version_utils import parse_rhel_version
from ymir.tools.unprivileged.specfile import UpdateReleaseTool
from ymir.tools.unprivileged.wicked_git import RunPackagePrepTool

logger = logging.getLogger(__name__)


async def needs_zstream_target_label(dist_git_branch: str, fix_version: str | None) -> bool:
"""Check if the fix targets a z-stream on an active CentOS Stream.

Maintenance streams (e.g. c8s / RHEL 8) are excluded — all builds there are
z-stream by default, so the label would add no information.
"""
if not fix_version or not is_cs_branch(dist_git_branch):
return False
parsed = parse_rhel_version(fix_version)
if not parsed or not parsed[2]:
return False

config = await load_rhel_config()
major = parsed[0]
y_streams = config.get("current_y_streams", {})
return major in y_streams


async def _clone_fedora_dist_git(package: str, destination: Path) -> bool:
try:
if destination.is_dir():
Expand Down Expand Up @@ -252,17 +272,22 @@ async def commit_push_and_open_mr(
allow_empty,
):
return None, False
tool_kwargs = {
"fork_url": fork_url,
"title": mr_title,
"description": mr_description,
"target": dist_git_branch,
"source": update_branch,
}
if labels:
tool_kwargs["labels"] = labels
result = await run_tool(
"open_merge_request",
fork_url=fork_url,
title=mr_title,
description=mr_description,
target=dist_git_branch,
source=update_branch,
**tool_kwargs,
available_tools=available_tools,
)
mr = OpenMergeRequestResult.model_validate(result)
if mr.url and labels:
if not mr.is_new_mr and mr.url and labels:
try:
await run_tool(
"add_merge_request_labels",
Expand Down
28 changes: 28 additions & 0 deletions ymir/agents/tests/unit/test_tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
change_jira_status,
fork_and_prepare_dist_git,
get_jira_issue_metadata,
needs_zstream_target_label,
post_user_ack_once,
)
from ymir.common.models import Task
Expand Down Expand Up @@ -243,3 +244,30 @@ async def test_get_jira_issue_metadata_returns_defaults_on_failure():

assert labels == []
assert status is None


MOCK_RHEL_CONFIG = {
"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"},
}


@pytest.mark.asyncio
@pytest.mark.parametrize(
"branch, fix_version, expected",
[
("c10s", "rhel-10.0.z", True),
("c9s", "rhel-9.7.z", True),
("c10s", "rhel-10.1", False),
("c9s", None, False),
("rhel-9.7.0", "rhel-9.7.z", False),
("c10s", "rhel-9.0.0.z", True),
("c8s", "rhel-8.10.z", False),
],
)
async def test_needs_zstream_target_label(branch, fix_version, expected):
async def _mock_config():
return MOCK_RHEL_CONFIG

with patch("ymir.agents.tasks.load_rhel_config", _mock_config):
assert await needs_zstream_target_label(branch, fix_version) == expected
27 changes: 26 additions & 1 deletion ymir/tools/privileged/gitlab.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from urllib.parse import quote, urlparse

import aiohttp
import gitlab
from beeai_framework.context import RunContext
from beeai_framework.emitter import Emitter
from beeai_framework.tools import (
Expand Down Expand Up @@ -321,6 +322,10 @@ class OpenMergeRequestToolInput(BaseModel):
description: str = Field(description="MR description")
target: str = Field(description="Target branch (in the original repository)")
source: str = Field(description="Source branch (in the fork)")
labels: list[str] | None = Field(
default=None,
description="Labels to set on the MR at creation time (atomic, avoids webhook race)",
)


class OpenMergeRequestTool(
Expand All @@ -344,6 +349,25 @@ def _create_emitter(self) -> Emitter:
creator=self,
)

@staticmethod
def _create_mr(project, title, description, target, source, labels):
parameters = {
"source_branch": source,
"target_branch": target,
"title": title,
"description": description,
}
if labels:
parameters["labels"] = ",".join(labels)
if project.is_fork:
parameters["target_project_id"] = project.parent.gitlab_repo.attributes["id"]
target_project = project.parent if project.is_fork else project
try:
raw_mr = project.gitlab_repo.mergerequests.create(parameters)
except gitlab.GitlabError as ex:
raise GitlabAPIException() from ex
Comment on lines +365 to +368

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

critical

In _create_mr, when catching gitlab.GitlabError, a new GitlabAPIException is raised without passing the original exception's response_code or message.

This causes ex.response_code to be None when handled in _run (line 390), which prevents the 409 Conflict check (if ex.response_code == 409:) from succeeding. As a result, the tool will fail with an unhandled exception instead of gracefully reusing the existing merge request.

Please propagate the response_code and message from the caught GitlabError to the GitlabAPIException.

Suggested change
try:
raw_mr = project.gitlab_repo.mergerequests.create(parameters)
except gitlab.GitlabError as ex:
raise GitlabAPIException() from ex
try:
raw_mr = project.gitlab_repo.mergerequests.create(parameters)
except gitlab.GitlabError as ex:
raise GitlabAPIException(message=str(ex), response_code=ex.response_code) from ex

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

GitlabAPIException.response_code is a property that reads from self.cause, which is set by the from ex clause. The 409 should work correctly through the exception chain

return GitlabPullRequest(raw_mr, target_project)

async def _run(
self,
tool_input: OpenMergeRequestToolInput,
Expand All @@ -355,13 +379,14 @@ async def _run(
description = tool_input.description
target = tool_input.target
source = tool_input.source
labels = tool_input.labels
logger.info(f"Connecting to GitLab API to open merge request from fork: {fork_url}")
project = await asyncio.to_thread(get_project, url=fork_url, token=os.getenv("GITLAB_TOKEN"))
if not project:
raise ToolError("Failed to get the specified fork")
is_new_mr = True
try:
pr = await asyncio.to_thread(project.create_pr, title, description, target, source)
pr = await asyncio.to_thread(self._create_mr, project, title, description, target, source, labels)
except GitlabAPIException as ex:
logger.info("Gitlab API exception: %s", ex)
if ex.response_code == 409:
Expand Down
Loading
Loading