Skip to content

Commit 4ee008e

Browse files
committed
Run a reproducer in a simple way
1 parent 9906236 commit 4ee008e

10 files changed

Lines changed: 461 additions & 11 deletions

File tree

Containerfile.mcp

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
FROM fedora:42
22

3+
RUN dnf -y copr enable @testing-farm/stable
4+
35
# Install system dependencies
46
RUN dnf -y install \
57
python3 \
@@ -16,6 +18,7 @@ RUN dnf -y install \
1618
krb5-workstation \
1719
centpkg \
1820
git \
21+
testing-farm \
1922
&& dnf clean all
2023

2124
# Install FastMCP

Makefile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ run-backport-agent-c9s-standalone:
6969
-e DRY_RUN=$(DRY_RUN) \
7070
-e MOCK_JIRA=$(MOCK_JIRA) \
7171
-e CVE_ID=$(CVE_ID) \
72+
-e REPRODUCER_INFO_REPO_URL=$(REPRODUCER_INFO_REPO_URL) \
73+
-e REPRODUCER_INFO_REPO_REF=$(REPRODUCER_INFO_REPO_REF) \
74+
-e REPRODUCER_INFO_TEST=$(REPRODUCER_INFO_TEST) \
7275
backport-agent-c9s
7376

7477
.PHONY: run-backport-agent-c10s-standalone
@@ -81,6 +84,9 @@ run-backport-agent-c10s-standalone:
8184
-e DRY_RUN=$(DRY_RUN) \
8285
-e MOCK_JIRA=$(MOCK_JIRA) \
8386
-e CVE_ID=$(CVE_ID) \
87+
-e REPRODUCER_INFO_REPO_URL=$(REPRODUCER_INFO_REPO_URL) \
88+
-e REPRODUCER_INFO_REPO_REF=$(REPRODUCER_INFO_REPO_REF) \
89+
-e REPRODUCER_INFO_TEST=$(REPRODUCER_INFO_TEST) \
8490
backport-agent-c10s
8591

8692
.PHONY: run-backport-agent-standalone

agents/backport_agent.py

Lines changed: 221 additions & 7 deletions
Large diffs are not rendered by default.

agents/build_agent.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ def get_instructions() -> str:
3535
just return the error message. Otherwise, start with `builder-live.log` and try to identify
3636
the build failure. If not found, try the same with `root.log`. Summarize the findings
3737
and return them as `error`. If the build failed due to a build timeout, set `is_timeout` to `true` in your output.
38+
If the build succeed, choose the main package URL (no devel, static, debug packages) from `artifacts_urls` returned by `build_package` tool
39+
and return it as `build_url` in your output.
40+
3841
3942
General instructions:
4043

agents/triage_agent.py

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,22 @@ def render_prompt(input: InputSchema) -> str:
253253
is clarification-needed
254254
* This is the correct choice when you are sure a problem exists but cannot find the solution yourself
255255
256-
2.5 Set the Jira fields as per the instructions below.
256+
2.5. If the chosen action is backport then search for the issue reproducer test case in the issue comments.
257+
You need to extract the following information to say you have found the issue reproducer:
258+
- Git URL to the issue reproducer; a git repo stored in https://gitlab.com/redhat/rhel/tests/
259+
- Git reference, it can be omitted and in this case you will use "main" as the reference.
260+
- Test to run, usually a path like folder/test_name
261+
- You need to say you have found the issue reproducer test case and provide the information in the JSON format.
262+
The JSON format should be like this:
263+
```json
264+
{
265+
"git_url": "https://gitlab.com/redhat/rhel/tests/test_repo.git",
266+
"git_ref": "main",
267+
"test": "folder/test_name"
268+
}
269+
```
270+
271+
2.6. Set the Jira fields as per the instructions below.
257272
258273
3. **No Action**
259274
A No Action decision is appropriate for issues that are NOT bugs or CVEs requiring code fixes:
@@ -410,7 +425,12 @@ async def run_triage_analysis(state):
410425
"justification": "This patch fixes the bug by doing X, Y, and Z.",
411426
"jira_issue": "RHEL-12345",
412427
"cve_id": "CVE-1234-98765",
413-
"fix_version": "rhel-X.Y.Z"
428+
"fix_version": "rhel-X.Y.Z",
429+
"reproducer_info": {{
430+
"git_url": "https://gitlab.com/redhat/rhel/tests/test_repo.git",
431+
"git_ref": "main",
432+
"test": "folder/test_name"
433+
}}
414434
}}
415435
}}
416436
```

common/models.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,12 @@ class RebaseOutputSchema(BaseModel):
8888
# Backport Agent Schemas
8989
# ============================================================================
9090

91+
class ReproducerInfo(BaseModel):
92+
"""Data for TMT resolution."""
93+
git_url: str = Field(description="Git URL to the TMT reproducer")
94+
git_ref: str = Field(description="Git reference to the TMT reproducer")
95+
test: str = Field(description="Test to run the TMT reproducer")
96+
9197
class BackportInputSchema(BaseModel):
9298
"""Input schema for the backport agent."""
9399
local_clone: Path = Field(description="Path to the local clone of forked dist-git repository")
@@ -99,13 +105,18 @@ class BackportInputSchema(BaseModel):
99105
upstream_patches: list[str] = Field(
100106
description="List of URLs to upstream patches that were validated using the PatchValidator tool")
101107
build_error: str | None = Field(description="Error encountered during package build")
108+
build_url: str | None = Field(description="URL to the principal built package", default=None)
109+
reproducer_info: ReproducerInfo | None = Field(description="Information about the TMT reproducer", default=None)
110+
reproducer_success: bool = Field(default=False, description="Whether the reproducer test case passed")
111+
reproducer_result: str | None = Field(description="Result of the reproducer test case", default=None)
102112

103113

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

111122

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

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

143154

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

mcp_server/gateway.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@
99
import gitlab_tools
1010
import jira_tools
1111
import lookaside_tools
12+
import testing_farm_tools
1213

1314

1415
mcp = FastMCP(
1516
name="MCP Gateway",
1617
tools=[
1718
coroutine
18-
for module in [copr_tools, distgit_tools, gitlab_tools, jira_tools, lookaside_tools]
19+
for module in [copr_tools, distgit_tools, gitlab_tools, jira_tools, lookaside_tools, testing_farm_tools]
1920
for name, coroutine in inspect.getmembers(module, inspect.iscoroutinefunction)
2021
if coroutine.__module__ == module.__name__
2122
and not name.startswith("_")

mcp_server/testing_farm_tools.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import asyncio
2+
import logging
3+
import re
4+
import shlex
5+
from typing import Annotated, Tuple
6+
7+
from fastmcp.exceptions import ToolError
8+
from pydantic import Field
9+
10+
from common.utils import init_kerberos_ticket, KerberosError
11+
12+
logger = logging.getLogger(__name__)
13+
14+
async def get_copr_repo(issue: Annotated[str, Field(description="Jira issue to get the Copr repo for")]) -> str:
15+
"""Gets the Copr repo for the package"""
16+
try:
17+
principal = await init_kerberos_ticket()
18+
except KerberosError as e:
19+
raise ToolError(f"Failed to initialize Kerberos ticket: {e}") from e
20+
copr_user = principal.split("@", maxsplit=1)[0]
21+
return f"copr.devel.redhat.com/{copr_user}/{issue}"
22+
23+
async def get_compose_from_branch(dist_git_branch: Annotated[str, Field(description="Branch to get the compose from")]) -> str:
24+
"""Gets the compose from the branch"""
25+
if dist_git_branch == "rhel-8.10":
26+
# There is one more .0 needed only for the RHEL 8.10 compose (not for 10.X branches)
27+
return "RHEL-8.10.0-Nightly"
28+
elif dist_git_branch.startswith("rhel-"):
29+
return dist_git_branch.upper() + "-Nightly"
30+
else:
31+
match = re.match(r'^c(\d+)s$', dist_git_branch)
32+
if match:
33+
number = match.group(1)
34+
return f"CentOS-Stream-{number}"
35+
else:
36+
raise ToolError(f"Invalid branch format, can't get compose from branch: {dist_git_branch}")
37+
38+
async def run_testing_farm_test(
39+
git_url: Annotated[str, Field(description="Git URL to the test repository")],
40+
git_ref: Annotated[str, Field(description="Git reference to the test repository")],
41+
path_to_test: Annotated[str, Field(description="Path to the test to run")],
42+
package: Annotated[str, Field(description="Package URL to be installed in the test environment")],
43+
dist_git_branch: Annotated[str, Field(description="Dist Git branch to use to get the compose")],
44+
) -> Tuple[bool, str]:
45+
"""Runs the specified testing-farm test and returns True if the test passed, False otherwise."""
46+
47+
tmt_prepare = f'--insert --how install --package {shlex.quote(package)}'
48+
compose = await get_compose_from_branch(dist_git_branch)
49+
50+
# Build the command arguments
51+
cmd = [
52+
"testing-farm",
53+
"request",
54+
"--tmt-prepare", tmt_prepare,
55+
"--compose", compose,
56+
"--git-ref", git_ref,
57+
"--git-url", git_url,
58+
"--test", path_to_test,
59+
]
60+
61+
logger.info(f"Running testing-farm command: {' '.join(shlex.quote(arg) for arg in cmd)}")
62+
63+
try:
64+
process = await asyncio.create_subprocess_exec(
65+
*cmd,
66+
stdout=asyncio.subprocess.PIPE,
67+
stderr=asyncio.subprocess.PIPE,
68+
)
69+
70+
stdout, stderr = await process.communicate()
71+
72+
if process.returncode == 0:
73+
msg = f"Testing Farm test passed: \n stdout {'='*60}\n {stdout.decode()}\n {'='*60}\n stderr {'='*60}\n {stderr.decode()}\n {'='*60}"
74+
logger.info(msg)
75+
return True, msg
76+
else:
77+
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}"
78+
msg += f"\n Ran command: {' '.join(shlex.quote(arg) for arg in cmd)}"
79+
logger.error(msg)
80+
return False, msg
81+
82+
except Exception as e:
83+
logger.error(f"Failed to run testing-farm command: {e}")
84+
raise ToolError(f"Failed to run testing-farm test: {e}") from e
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""Tests for testing_farm_tools module."""
2+
3+
import asyncio
4+
import shlex
5+
import pytest
6+
from unittest.mock import AsyncMock, patch
7+
from mcp_server.testing_farm_tools import run_testing_farm_test, get_compose_from_branch
8+
9+
10+
def test_tmt_prepare_quoting():
11+
"""Test that package URLs with special characters are properly quoted."""
12+
# Test with a package URL that has special characters
13+
package = 'http://example.com/package-1.0-1.el9.x86_64.rpm'
14+
tmt_prepare = f'--insert --how install --package {shlex.quote(package)}'
15+
16+
# Should be properly quoted
17+
assert '--insert' in tmt_prepare
18+
assert '--how' in tmt_prepare
19+
assert 'install' in tmt_prepare
20+
assert '--package' in tmt_prepare
21+
assert package in tmt_prepare
22+
23+
24+
@pytest.mark.asyncio
25+
async def test_get_compose_from_branch():
26+
"""Test get_compose_from_branch function with various branch formats."""
27+
test_cases = [
28+
("rhel-9.7.0", "RHEL-9.7.0-Nightly"),
29+
("rhel-10.2", "RHEL-10.2-Nightly"),
30+
("rhel-8.10", "RHEL-8.10.0-Nightly"),
31+
("c8s", "CentOS-Stream-8"),
32+
]
33+
34+
for branch, expected_compose in test_cases:
35+
result = await get_compose_from_branch(branch)
36+
assert result == expected_compose, (
37+
f"Branch '{branch}' should return '{expected_compose}', "
38+
f"but got '{result}'"
39+
)
40+
41+
42+
@pytest.mark.asyncio
43+
async def test_run_testing_farm_test_success():
44+
"""Test run_testing_farm_test with successful test execution."""
45+
git_url = "https://gitlab.com/redhat/rhel/tests/expat.git"
46+
git_ref = "master"
47+
path_to_test = "Security/RHEL-114639-CVE-2025-59375-expat-libexpat-in-Expat-allows"
48+
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"
49+
dist_git_branch = "rhel-9.7.0"
50+
51+
# Mock the subprocess to return success
52+
mock_process = AsyncMock()
53+
mock_process.returncode = 0
54+
mock_process.communicate = AsyncMock(return_value=(b"Test passed", b""))
55+
56+
with patch('mcp_server.testing_farm_tools.asyncio.create_subprocess_exec', return_value=mock_process):
57+
success, result = await run_testing_farm_test(
58+
git_url=git_url,
59+
git_ref=git_ref,
60+
path_to_test=path_to_test,
61+
package=package,
62+
dist_git_branch=dist_git_branch,
63+
)
64+
65+
assert success is True
66+
assert "Test passed" in result
67+
assert "Testing Farm test passed" in result
68+
69+
70+
@pytest.mark.asyncio
71+
async def test_run_testing_farm_test_failure():
72+
"""Test run_testing_farm_test with failed test execution."""
73+
git_url = "https://gitlab.com/redhat/rhel/tests/expat.git"
74+
git_ref = "master"
75+
path_to_test = "Security/RHEL-114639-CVE-2025-59375-expat-libexpat-in-Expat-allows"
76+
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"
77+
dist_git_branch = "rhel-9.7.0"
78+
79+
# Mock the subprocess to return failure
80+
mock_process = AsyncMock()
81+
mock_process.returncode = 1
82+
mock_process.communicate = AsyncMock(return_value=(b"", b"Test failed with error"))
83+
84+
with patch('mcp_server.testing_farm_tools.asyncio.create_subprocess_exec', return_value=mock_process):
85+
success, result = await run_testing_farm_test(
86+
git_url=git_url,
87+
git_ref=git_ref,
88+
path_to_test=path_to_test,
89+
package=package,
90+
dist_git_branch=dist_git_branch,
91+
)
92+
93+
assert success is False
94+
assert "Test failed" in result or "exit code 1" in result
95+
assert "Testing Farm test failed" in result
96+
97+
98+
if __name__ == '__main__':
99+
# Run tests if executed directly
100+
test_tmt_prepare_quoting()
101+
# Run async tests
102+
asyncio.run(test_get_compose_from_branch())
103+
asyncio.run(test_run_testing_farm_test_success())
104+
asyncio.run(test_run_testing_farm_test_failure())
105+
print("All tests passed!")

templates/mcp-gateway.env

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,5 @@ GITLAB_TOKEN=
66
JIRA_URL=https://issues.redhat.com
77
# JIRA profile -> Create token
88
JIRA_TOKEN=
9+
10+
TESTING_FARM_API_TOKEN=

0 commit comments

Comments
 (0)