Skip to content

Commit 06b6550

Browse files
committed
Add possibility to read and edit Jira issues from files
Allows devs to execute agents with static files saved in mcp_servers/tests/data directory. Usage: Execute triage agent on mock of Jira issue without writing. make run-triage-agent-standalone JIRA_ISSUE=RHEL-15216 DRY_RUN=true MOCK_JIRA=true Execute triage agent on mock of Jira issue and allow it to add comments to file or change the issue fields. make run-triage-agent-standalone JIRA_ISSUE=RHEL-15216 MOCK_JIRA=true Related: packit/jotnar#233
1 parent ad23d5d commit 06b6550

File tree

8 files changed

+183
-9
lines changed

8 files changed

+183
-9
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,6 @@ noarch/
3838

3939
# Project specific
4040
.secrets
41+
42+
# Testing Jira files
43+
mcp_server/tests/data/*

Containerfile.mcp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ RUN dnf -y install \
1111
python3-redis \
1212
python3-requests \
1313
python3-GitPython \
14+
python3-flexmock \
1415
krb5-libs \
1516
krb5-workstation \
1617
centpkg \

Makefile

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
IMAGE_NAME ?= beeai-agent
22
COMPOSE_FILE ?= compose.yaml
33
DRY_RUN ?= false
4+
MOCK_JIRA ?= false
45
LOKI_URL ?= http://loki.tft.osci.redhat.com/
56
LOKI_SINCE ?= 24h
67
LOKI_LIMIT ?= 3000
@@ -23,6 +24,7 @@ run-triage-agent-standalone:
2324
$(COMPOSE_AGENTS) run --rm \
2425
-e JIRA_ISSUE=$(JIRA_ISSUE) \
2526
-e DRY_RUN=$(DRY_RUN) \
27+
-e MOCK_JIRA=$(MOCK_JIRA) \
2628
triage-agent
2729

2830

@@ -36,6 +38,7 @@ run-rebase-agent-c9s-standalone:
3638
-e JIRA_ISSUE=$(JIRA_ISSUE) \
3739
-e BRANCH=$(BRANCH) \
3840
-e DRY_RUN=$(DRY_RUN) \
41+
-e MOCK_JIRA=$(MOCK_JIRA) \
3942
rebase-agent-c9s
4043

4144
.PHONY: run-rebase-agent-c10s-standalone
@@ -46,6 +49,7 @@ run-rebase-agent-c10s-standalone:
4649
-e JIRA_ISSUE=$(JIRA_ISSUE) \
4750
-e BRANCH=$(BRANCH) \
4851
-e DRY_RUN=$(DRY_RUN) \
52+
-e MOCK_JIRA=$(MOCK_JIRA) \
4953
rebase-agent-c10s
5054

5155
.PHONY: run-rebase-agent-standalone
@@ -63,6 +67,7 @@ run-backport-agent-c9s-standalone:
6367
-e JIRA_ISSUE=$(JIRA_ISSUE) \
6468
-e BRANCH=$(BRANCH) \
6569
-e DRY_RUN=$(DRY_RUN) \
70+
-e MOCK_JIRA=$(MOCK_JIRA) \
6671
-e CVE_ID=$(CVE_ID) \
6772
backport-agent-c9s
6873

@@ -74,6 +79,7 @@ run-backport-agent-c10s-standalone:
7479
-e JIRA_ISSUE=$(JIRA_ISSUE) \
7580
-e BRANCH=$(BRANCH) \
7681
-e DRY_RUN=$(DRY_RUN) \
82+
-e MOCK_JIRA=$(MOCK_JIRA) \
7783
-e CVE_ID=$(CVE_ID) \
7884
backport-agent-c10s
7985

@@ -105,11 +111,11 @@ build-jira-issue-fetcher:
105111

106112
.PHONY: start
107113
start:
108-
DRY_RUN=$(DRY_RUN) $(COMPOSE_AGENTS) up
114+
DRY_RUN=$(DRY_RUN) MOCK_JIRA=$(MOCK_JIRA) $(COMPOSE_AGENTS) up
109115

110116
.PHONY: start-detached
111117
start-detached:
112-
DRY_RUN=$(DRY_RUN) $(COMPOSE_AGENTS) up -d
118+
DRY_RUN=$(DRY_RUN) MOCK_JIRA=$(MOCK_JIRA) $(COMPOSE_AGENTS) up -d
113119

114120
.PHONY: stop
115121
stop:

compose.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,14 @@ services:
8383
# default cache location is a keyring
8484
- KRB5CCNAME=FILE:/tmp/krb5cc
8585
- DRY_RUN=${DRY_RUN:-false}
86+
- MOCK_JIRA=${MOCK_JIRA:-false}
8687
- GIT_REPO_BASEPATH=/git-repos
88+
- JIRA_MOCK_FILES=mcp_server/tests/data
8789
env_file:
8890
- .secrets/mcp-gateway.env
8991
volumes:
9092
- ./mcp_server:/home/mcp/mcp_server:ro,z
93+
- ./mcp_server/tests/data:/home/mcp/mcp_server/tests/data:rw,z
9194
- ./common:/home/mcp/common:ro,z
9295
- .secrets/keytab:/home/mcp/keytab:ro,z,U
9396
- git-repos:/git-repos
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
from contextlib import asynccontextmanager
2+
from urllib.parse import urljoin
3+
from functools import partial
4+
import datetime
5+
import aiofiles
6+
import json
7+
import re
8+
import os
9+
10+
from fastmcp.exceptions import ToolError
11+
from flexmock import flexmock
12+
13+
async def _get_transitions():
14+
return {"transitions": [{"to": {"name": "In Progress"}, "id": 1}, {"to": {"name": "Closed"}, "id": 2}]}
15+
16+
17+
async def _get_verified_user():
18+
return {"groups": {"items": [{"name": "Red Hat Employee"}]}}
19+
20+
21+
async def _get_unverified_user():
22+
return {"groups": {"items": []}}
23+
24+
25+
async def _read_jira_mock(issue_key: str, remote_link = False) -> dict:
26+
try:
27+
async with aiofiles.open(f"{os.environ['JIRA_MOCK_FILES']}/{issue_key}", "r") as jira_file:
28+
if remote_link:
29+
return json.loads(await jira_file.read())["remote_links"]
30+
return json.loads(await jira_file.read())
31+
except (FileNotFoundError, json.JSONDecodeError, IOError) as e:
32+
raise ToolError(f"Error while reading mock up Jira issue {e}") from e
33+
34+
35+
async def _write_jira_mock(issue_key: str, data: dict):
36+
try:
37+
async with aiofiles.open(f"{os.environ['JIRA_MOCK_FILES']}/{issue_key}", "w") as jira_file:
38+
await jira_file.write(json.dumps(data, indent=2))
39+
except IOError as e:
40+
raise ToolError(f"Error while writing mock up Jira issue {e}") from e
41+
42+
43+
class aiohttpClientSessionMock:
44+
# mocking endpoint providing information about issue
45+
issue_get_regex = re.compile(
46+
re.escape(urljoin(os.getenv("JIRA_URL"), f"rest/api/2/issue"))+"/([A-Z0-9-]+)")
47+
# mocking endpoint providing available transitions
48+
transitions_get_regex = re.compile(
49+
re.escape(urljoin(os.getenv("JIRA_URL"), f"rest/api/2/issue"))+"/([A-Z0-9-]+)/transitions")
50+
# mocking endpoint providing remote links present in issues
51+
remote_link_get_regex = re.compile(
52+
re.escape(urljoin(os.getenv("JIRA_URL"), f"rest/api/2/issue"))+"/([A-Z0-9-]+)/remotelink")
53+
# mocking endpoint for posting comments
54+
comment_post_regex = re.compile(
55+
re.escape(urljoin(os.getenv("JIRA_URL"), f"rest/api/2/issue"))+"/([A-Z0-9-]+)/comment")
56+
# mocking endpoint for retrieval of information about users
57+
user_get_regex = re.compile(
58+
re.escape(urljoin(os.getenv("JIRA_URL"), f"rest/api/2/user")))
59+
60+
async def __aenter__(self):
61+
return self
62+
63+
async def __aexit__(self, exc_type, exc_val, exc_tb):
64+
pass
65+
66+
@asynccontextmanager
67+
async def get(self, *args, **kwargs):
68+
if match_data := self.issue_get_regex.fullmatch(args[0]):
69+
yield flexmock(raise_for_status=lambda: None,
70+
json=partial(_read_jira_mock,
71+
issue_key=match_data.group(1),
72+
remote_link = False))
73+
elif match_data:= self.remote_link_get_regex.fullmatch(args[0]):
74+
yield flexmock(raise_for_status=lambda: None,
75+
json=partial(_read_jira_mock,
76+
issue_key=match_data.group(1)),
77+
remote_link=True)
78+
elif match_data:= self.transitions_get_regex.fullmatch(args[0]):
79+
yield flexmock(raise_for_status=lambda: None,
80+
json=_get_transitions)
81+
elif match_data:= self.user_get_regex.fullmatch(args[0]):
82+
if (kwargs["params"].get("key") == "verified_user" or
83+
kwargs["params"].get("accountId") == "verified_user"):
84+
yield flexmock(raise_for_status=lambda: None,
85+
json=_get_verified_user)
86+
yield flexmock(raise_for_status=lambda: None,
87+
json=_get_unverified_user)
88+
else:
89+
raise NotImplementedError()
90+
91+
@asynccontextmanager
92+
async def put(self, *args, **kwargs):
93+
if match_data := self.issue_get_regex.fullmatch(args[0]):
94+
issue_data = await _read_jira_mock(match_data.group(1), remote_link=False)
95+
if "fields" in kwargs["json"]:
96+
issue_data["fields"].update(kwargs["json"]["fields"])
97+
elif "update" in kwargs["json"]:
98+
current_labels = set(issue_data["fields"]["labels"])
99+
labels_to_add = [action_dict["add"] for action_dict
100+
in kwargs["json"]["update"]["labels"]
101+
if "add" in action_dict]
102+
labels_to_remove = [action_dict["remove"] for action_dict
103+
in kwargs["json"]["update"]["labels"]
104+
if "remove" in action_dict]
105+
if labels_to_remove:
106+
current_labels.difference_update(labels_to_remove)
107+
if labels_to_add:
108+
current_labels.update(labels_to_add)
109+
issue_data["fields"]["labels"] = list(current_labels)
110+
else:
111+
raise NotImplementedError()
112+
await _write_jira_mock(match_data.group(1), issue_data)
113+
yield flexmock(raise_for_status=lambda: None)
114+
else:
115+
raise NotImplementedError()
116+
117+
@asynccontextmanager
118+
async def post(self, *args, **kwargs):
119+
if match_data := self.comment_post_regex.fullmatch(args[0]):
120+
current_issue = await _read_jira_mock(match_data.group(1))
121+
comment_dict = kwargs["json"]
122+
comment_dict["created"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
123+
comment_dict["updated"] = datetime.datetime.now(datetime.timezone.utc).isoformat()
124+
comment_dict["author"] = {"name": "jotnar-project",
125+
"key": "JIRAUSER288184",
126+
"displayName": "Jotnar Project"}
127+
current_issue["fields"]["comment"]["comments"].append(comment_dict)
128+
current_issue["fields"]["comment"]["maxResults"] += 1
129+
current_issue["fields"]["comment"]["total"] += 1
130+
await _write_jira_mock(match_data.group(1), current_issue)
131+
yield flexmock(raise_for_status=lambda: None)
132+
elif match_data := self.transitions_get_regex.fullmatch(args[0]):
133+
jira_data = await _read_jira_mock(match_data.group(1))
134+
if kwargs["json"]["transition"]["id"] == 1:
135+
jira_data["fields"]["status"] = {"name": "In Progress"}
136+
jira_data["fields"]["status"]["description"] = "Work has started"
137+
elif kwargs["json"]["transition"]["id"] == 2:
138+
jira_data["fields"]["status"] = {"name": "Closed"}
139+
jira_data["fields"]["status"]["description"] = "The issue is closed. See the" \
140+
"resolution for context regarding why" \
141+
"(for example Done, Abandoned, Duplicate, etc)"
142+
else:
143+
raise NotImplementedError()
144+
await _write_jira_mock(match_data.group(1), jira_data)
145+
yield flexmock(raise_for_status=lambda: None)
146+
else:
147+
raise NotImplementedError()

mcp_server/jira_tools.py

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,12 @@
88
from urllib.parse import urljoin
99

1010
import aiohttp
11+
12+
if os.getenv("MOCK_JIRA", "False").lower() == "true":
13+
from aiohttp_client_session_mock import aiohttpClientSessionMock as aiohttpClientSession
14+
else:
15+
from aiohttp import ClientSession as aiohttpClientSession
16+
1117
from fastmcp.exceptions import ToolError
1218
from pydantic import Field
1319

@@ -54,7 +60,7 @@ async def get_jira_details(
5460
"""
5561
headers = _get_jira_headers(os.getenv("JIRA_TOKEN"))
5662

57-
async with aiohttp.ClientSession() as session:
63+
async with aiohttpClientSession() as session:
5864
# Get main issue data
5965
try:
6066
async with session.get(
@@ -98,7 +104,7 @@ async def set_jira_fields(
98104
if os.getenv("DRY_RUN", "False").lower() == "true":
99105
return "Dry run, not updating Jira fields (this is expected, not an error)"
100106

101-
async with aiohttp.ClientSession() as session:
107+
async with aiohttpClientSession() as session:
102108
# First, get the current issue to check existing field values
103109
try:
104110
async with session.get(
@@ -131,6 +137,7 @@ async def set_jira_fields(
131137
if not fields:
132138
return f"No fields needed updating in {issue_key}"
133139

140+
async with aiohttpClientSession() as session:
134141
try:
135142
async with session.put(
136143
urljoin(os.getenv("JIRA_URL"), f"rest/api/2/issue/{issue_key}"),
@@ -152,7 +159,7 @@ async def add_jira_comment(
152159
"""
153160
Adds a comment to the specified Jira issue.
154161
"""
155-
async with aiohttp.ClientSession() as session:
162+
async with aiohttpClientSession() as session:
156163
try:
157164
async with session.post(
158165
urljoin(os.getenv("JIRA_URL"), f"rest/api/2/issue/{issue_key}/comment"),
@@ -179,7 +186,7 @@ async def check_cve_triage_eligibility(
179186
"""
180187
headers = _get_jira_headers(os.getenv("JIRA_TOKEN"))
181188

182-
async with aiohttp.ClientSession() as session:
189+
async with aiohttpClientSession() as session:
183190
try:
184191
async with session.get(
185192
urljoin(os.getenv("JIRA_URL"), f"rest/api/2/issue/{issue_key}"),
@@ -265,7 +272,7 @@ async def change_jira_status(
265272
headers = _get_jira_headers(os.getenv("JIRA_TOKEN"))
266273
jira_url = urljoin(os.getenv("JIRA_URL"), f"rest/api/2/issue/{issue_key}/transitions")
267274

268-
async with aiohttp.ClientSession() as session:
275+
async with aiohttpClientSession() as session:
269276
try:
270277
async with session.get(
271278
urljoin(os.getenv("JIRA_URL"), f"rest/api/2/issue/{issue_key}"),
@@ -335,7 +342,7 @@ async def edit_jira_labels(
335342

336343
payload = {"update": {"labels": update_payload}}
337344

338-
async with aiohttp.ClientSession() as session:
345+
async with aiohttpClientSession() as session:
339346
try:
340347
async with session.put(
341348
jira_url,
@@ -358,7 +365,7 @@ async def verify_issue_author(
358365
"""
359366
headers = _get_jira_headers(os.getenv("JIRA_TOKEN"))
360367

361-
async with aiohttp.ClientSession() as session:
368+
async with aiohttpClientSession() as session:
362369
try:
363370
async with session.get(
364371
urljoin(os.getenv("JIRA_URL"), f"rest/api/2/issue/{issue_key}"),

mcp_server/tests/data/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
# Where are the Jira Files?
2+
This directory is meant to contain Jira files from
3+
`[email protected]:jotnar-project/testing-jiras.git` if you have the access.
4+
5+
Clone them in here and if you want agents to be able to write to them, add writing permission
6+
bit to all users.

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ dependencies = [
2929
"typer>=0.16.0",
3030
"backoff>=2.2.1",
3131
"tomli-w>=1.2.0",
32+
"flexmock>=0.12.2",
3233
]
3334

3435
[project.optional-dependencies]

0 commit comments

Comments
 (0)