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
3 changes: 3 additions & 0 deletions Containerfile.mcp
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
FROM fedora:42

RUN dnf -y copr enable @testing-farm/stable

# Install system dependencies
RUN dnf -y install \
python3 \
Expand All @@ -16,6 +18,7 @@ RUN dnf -y install \
krb5-workstation \
centpkg \
git \
testing-farm \
&& dnf clean all

# Install FastMCP
Expand Down
6 changes: 6 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,9 @@ run-backport-agent-c9s-standalone:
-e DRY_RUN=$(DRY_RUN) \
-e MOCK_JIRA=$(MOCK_JIRA) \
-e CVE_ID=$(CVE_ID) \
-e REPRODUCER_INFO_REPO_URL=$(REPRODUCER_INFO_REPO_URL) \
-e REPRODUCER_INFO_REPO_REF=$(REPRODUCER_INFO_REPO_REF) \
-e REPRODUCER_INFO_TEST=$(REPRODUCER_INFO_TEST) \
backport-agent-c9s

.PHONY: run-backport-agent-c10s-standalone
Expand All @@ -81,6 +84,9 @@ run-backport-agent-c10s-standalone:
-e DRY_RUN=$(DRY_RUN) \
-e MOCK_JIRA=$(MOCK_JIRA) \
-e CVE_ID=$(CVE_ID) \
-e REPRODUCER_INFO_REPO_URL=$(REPRODUCER_INFO_REPO_URL) \
-e REPRODUCER_INFO_REPO_REF=$(REPRODUCER_INFO_REPO_REF) \
-e REPRODUCER_INFO_TEST=$(REPRODUCER_INFO_TEST) \
backport-agent-c10s

.PHONY: run-backport-agent-standalone
Expand Down
228 changes: 221 additions & 7 deletions agents/backport_agent.py

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions agents/build_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,9 @@ def get_instructions() -> str:
just return the error message. Otherwise, start with `builder-live.log` and try to identify
the build failure. If not found, try the same with `root.log`. Summarize the findings
and return them as `error`. If the build failed due to a build timeout, set `is_timeout` to `true` in your output.
If the build succeed, choose the main package URL (no devel, static, debug packages) from `artifacts_urls` returned by `build_package` tool
and return it as `build_url` in your output.


General instructions:

Expand Down
24 changes: 22 additions & 2 deletions agents/triage_agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,7 +253,22 @@ def render_prompt(input: InputSchema) -> str:
is clarification-needed
* This is the correct choice when you are sure a problem exists but cannot find the solution yourself

2.5 Set the Jira fields as per the instructions below.
2.5. If the chosen action is backport then search for the issue reproducer test case in the issue comments.
You need to extract the following information to say you have found the issue reproducer:
- Git URL to the issue reproducer; a git repo stored in https://gitlab.com/redhat/rhel/tests/
- Git reference, it can be omitted and in this case you will use "main" as the reference.
- Test to run, usually a path like folder/test_name
- You need to say you have found the issue reproducer test case and provide the information in the JSON format.
The JSON format should be like this:
```json
{
"git_url": "https://gitlab.com/redhat/rhel/tests/test_repo.git",
"git_ref": "main",
"test": "folder/test_name"
}
```

2.6. Set the Jira fields as per the instructions below.

3. **No Action**
A No Action decision is appropriate for issues that are NOT bugs or CVEs requiring code fixes:
Expand Down Expand Up @@ -410,7 +425,12 @@ async def run_triage_analysis(state):
"justification": "This patch fixes the bug by doing X, Y, and Z.",
"jira_issue": "RHEL-12345",
"cve_id": "CVE-1234-98765",
"fix_version": "rhel-X.Y.Z"
"fix_version": "rhel-X.Y.Z",
"reproducer_info": {{
"git_url": "https://gitlab.com/redhat/rhel/tests/test_repo.git",
"git_ref": "main",
"test": "folder/test_name"
}}
}}
}}
```
Expand Down
14 changes: 13 additions & 1 deletion common/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ class RebaseOutputSchema(BaseModel):
# Backport Agent Schemas
# ============================================================================

class ReproducerInfo(BaseModel):
"""Data for TMT resolution."""
git_url: str = Field(description="Git URL to the TMT reproducer")
git_ref: str = Field(description="Git reference to the TMT reproducer")
test: str = Field(description="Test to run the TMT reproducer")

class BackportInputSchema(BaseModel):
"""Input schema for the backport agent."""
local_clone: Path = Field(description="Path to the local clone of forked dist-git repository")
Expand All @@ -99,13 +105,18 @@ class BackportInputSchema(BaseModel):
upstream_patches: list[str] = Field(
description="List of URLs to upstream patches that were validated using the PatchValidator tool")
build_error: str | None = Field(description="Error encountered during package build")
build_url: str | None = Field(description="URL to the principal built package", default=None)
reproducer_info: ReproducerInfo | None = Field(description="Information about the TMT reproducer", default=None)
reproducer_success: bool = Field(default=False, description="Whether the reproducer test case passed")
reproducer_result: str | None = Field(description="Result of the reproducer test case", default=None)


class BackportOutputSchema(BaseModel):
"""Output schema for the backport agent."""
success: bool = Field(description="Whether the backport was successfully completed")
status: str = Field(description="Backport status with details of how the potential merge conflicts were resolved")
srpm_path: Path | None = Field(description="Absolute path to generated SRPM")
build_url: str | None = Field(description="URL to the principal built package", default=None)
error: str | None = Field(description="Specific details about an error")


Expand All @@ -129,7 +140,6 @@ class RebaseData(BaseModel):
jira_issue: str = Field(description="Jira issue identifier")
fix_version: str | None = Field(description="Fix version in Jira (e.g., 'rhel-9.8')", default=None)


class BackportData(BaseModel):
"""Data for backport resolution."""
package: str = Field(description="Package name")
Expand All @@ -139,6 +149,7 @@ class BackportData(BaseModel):
jira_issue: str = Field(description="Jira issue identifier")
cve_id: str | None = Field(description="CVE identifier", default=None)
fix_version: str | None = Field(description="Fix version in Jira (e.g., 'rhel-9.8')", default=None)
reproducer_info: ReproducerInfo | None = Field(description="Reproducer test case information", default=None)


class ClarificationNeededData(BaseModel):
Expand Down Expand Up @@ -246,6 +257,7 @@ class BuildInputSchema(BaseModel):
class BuildOutputSchema(BaseModel):
"""Output schema for the build agent."""
success: bool = Field(description="Whether the build was successfully completed")
build_url: str | None = Field(description="URL to the principal built package", default=None)
error: str | None = Field(description="Specific details about an error")
is_timeout: bool = Field(default=False, description="Whether the build failed due to a timeout")

Expand Down
3 changes: 2 additions & 1 deletion mcp_server/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@
import gitlab_tools
import jira_tools
import lookaside_tools
import testing_farm_tools


mcp = FastMCP(
name="MCP Gateway",
tools=[
coroutine
for module in [copr_tools, distgit_tools, gitlab_tools, jira_tools, lookaside_tools]
for module in [copr_tools, distgit_tools, gitlab_tools, jira_tools, lookaside_tools, testing_farm_tools]
for name, coroutine in inspect.getmembers(module, inspect.iscoroutinefunction)
if coroutine.__module__ == module.__name__
and not name.startswith("_")
Expand Down
84 changes: 84 additions & 0 deletions mcp_server/testing_farm_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import asyncio
import logging
import re
import shlex
from typing import Annotated, Tuple

from fastmcp.exceptions import ToolError
from pydantic import Field

from common.utils import init_kerberos_ticket, KerberosError

logger = logging.getLogger(__name__)

async def get_copr_repo(issue: Annotated[str, Field(description="Jira issue to get the Copr repo for")]) -> str:
"""Gets the Copr repo for the package"""
try:
principal = await init_kerberos_ticket()
except KerberosError as e:
raise ToolError(f"Failed to initialize Kerberos ticket: {e}") from e
copr_user = principal.split("@", maxsplit=1)[0]
return f"copr.devel.redhat.com/{copr_user}/{issue}"

async def get_compose_from_branch(dist_git_branch: Annotated[str, Field(description="Branch to get the compose from")]) -> str:
"""Gets the compose from the branch"""
if dist_git_branch == "rhel-8.10":
# There is one more .0 needed only for the RHEL 8.10 compose (not for 10.X branches)
return "RHEL-8.10.0-Nightly"
elif dist_git_branch.startswith("rhel-"):
return dist_git_branch.upper() + "-Nightly"
else:
match = re.match(r'^c(\d+)s$', dist_git_branch)
if match:
number = match.group(1)
return f"CentOS-Stream-{number}"
else:
raise ToolError(f"Invalid branch format, can't get compose from branch: {dist_git_branch}")

async def run_testing_farm_test(
git_url: Annotated[str, Field(description="Git URL to the test repository")],
git_ref: Annotated[str, Field(description="Git reference to the test repository")],
path_to_test: Annotated[str, Field(description="Path to the test to run")],
package: Annotated[str, Field(description="Package URL to be installed in the test environment")],
dist_git_branch: Annotated[str, Field(description="Dist Git branch to use to get the compose")],
) -> Tuple[bool, str]:
"""Runs the specified testing-farm test and returns True if the test passed, False otherwise."""

tmt_prepare = f'--insert --how install --package {shlex.quote(package)}'
compose = await get_compose_from_branch(dist_git_branch)

# Build the command arguments
cmd = [
"testing-farm",
"request",
"--tmt-prepare", tmt_prepare,
"--compose", compose,
"--git-ref", git_ref,
"--git-url", git_url,
"--test", path_to_test,
]

logger.info(f"Running testing-farm command: {' '.join(shlex.quote(arg) for arg in cmd)}")

try:
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
Comment on lines +64 to +68
Copy link
Member

Choose a reason for hiding this comment

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

I would rather send a request using aiohttp.


stdout, stderr = await process.communicate()

if process.returncode == 0:
msg = f"Testing Farm test passed: \n stdout {'='*60}\n {stdout.decode()}\n {'='*60}\n stderr {'='*60}\n {stderr.decode()}\n {'='*60}"
logger.info(msg)
return True, msg
else:
msg = f"Testing Farm test failed (exit code {process.returncode}): \n stdout {'='*60}\n {stdout.decode()}\n {'='*60}\n stderr {'='*60}\n {stderr.decode()}\n {'='*60}"
msg += f"\n Ran command: {' '.join(shlex.quote(arg) for arg in cmd)}"
logger.error(msg)
return False, msg

except Exception as e:
logger.error(f"Failed to run testing-farm command: {e}")
raise ToolError(f"Failed to run testing-farm test: {e}") from e
105 changes: 105 additions & 0 deletions mcp_server/tests/unit/test_testing_farm_tools.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
"""Tests for testing_farm_tools module."""

import asyncio
import shlex
import pytest
from unittest.mock import AsyncMock, patch
from mcp_server.testing_farm_tools import run_testing_farm_test, get_compose_from_branch


def test_tmt_prepare_quoting():
"""Test that package URLs with special characters are properly quoted."""
# Test with a package URL that has special characters
package = 'http://example.com/package-1.0-1.el9.x86_64.rpm'
tmt_prepare = f'--insert --how install --package {shlex.quote(package)}'

# Should be properly quoted
assert '--insert' in tmt_prepare
assert '--how' in tmt_prepare
assert 'install' in tmt_prepare
assert '--package' in tmt_prepare
assert package in tmt_prepare


@pytest.mark.asyncio
async def test_get_compose_from_branch():
"""Test get_compose_from_branch function with various branch formats."""
test_cases = [
("rhel-9.7.0", "RHEL-9.7.0-Nightly"),
("rhel-10.2", "RHEL-10.2-Nightly"),
("rhel-8.10", "RHEL-8.10.0-Nightly"),
("c8s", "CentOS-Stream-8"),
]

for branch, expected_compose in test_cases:
result = await get_compose_from_branch(branch)
assert result == expected_compose, (
f"Branch '{branch}' should return '{expected_compose}', "
f"but got '{result}'"
)


@pytest.mark.asyncio
async def test_run_testing_farm_test_success():
"""Test run_testing_farm_test with successful test execution."""
git_url = "https://gitlab.com/redhat/rhel/tests/expat.git"
git_ref = "master"
path_to_test = "Security/RHEL-114639-CVE-2025-59375-expat-libexpat-in-Expat-allows"
package = "http://coprbe.devel.redhat.com/results/mmassari/RHEL-114644/rhel-9-x86_64/00127295-expat/expat-2.5.0-5.el9.x86_64.rpm"
dist_git_branch = "rhel-9.7.0"

# Mock the subprocess to return success
mock_process = AsyncMock()
mock_process.returncode = 0
mock_process.communicate = AsyncMock(return_value=(b"Test passed", b""))

with patch('mcp_server.testing_farm_tools.asyncio.create_subprocess_exec', return_value=mock_process):
success, result = await run_testing_farm_test(
git_url=git_url,
git_ref=git_ref,
path_to_test=path_to_test,
package=package,
dist_git_branch=dist_git_branch,
)

assert success is True
assert "Test passed" in result
assert "Testing Farm test passed" in result


@pytest.mark.asyncio
async def test_run_testing_farm_test_failure():
"""Test run_testing_farm_test with failed test execution."""
git_url = "https://gitlab.com/redhat/rhel/tests/expat.git"
git_ref = "master"
path_to_test = "Security/RHEL-114639-CVE-2025-59375-expat-libexpat-in-Expat-allows"
package = "http://coprbe.devel.redhat.com/results/mmassari/RHEL-114644/rhel-9-x86_64/00127295-expat/expat-2.5.0-5.el9.x86_64.rpm"
dist_git_branch = "rhel-9.7.0"

# Mock the subprocess to return failure
mock_process = AsyncMock()
mock_process.returncode = 1
mock_process.communicate = AsyncMock(return_value=(b"", b"Test failed with error"))

with patch('mcp_server.testing_farm_tools.asyncio.create_subprocess_exec', return_value=mock_process):
success, result = await run_testing_farm_test(
git_url=git_url,
git_ref=git_ref,
path_to_test=path_to_test,
package=package,
dist_git_branch=dist_git_branch,
)

assert success is False
assert "Test failed" in result or "exit code 1" in result
assert "Testing Farm test failed" in result


if __name__ == '__main__':
# Run tests if executed directly
test_tmt_prepare_quoting()
# Run async tests
asyncio.run(test_get_compose_from_branch())
asyncio.run(test_run_testing_farm_test_success())
asyncio.run(test_run_testing_farm_test_failure())
print("All tests passed!")
2 changes: 2 additions & 0 deletions templates/mcp-gateway.env
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,5 @@ GITLAB_TOKEN=
JIRA_URL=https://issues.redhat.com
# JIRA profile -> Create token
JIRA_TOKEN=

TESTING_FARM_API_TOKEN=