Skip to content

Commit f1ab22b

Browse files
committed
mcp: jira client
1 parent d12dd2f commit f1ab22b

File tree

6 files changed

+392
-18
lines changed

6 files changed

+392
-18
lines changed

README.md

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ BugZooka supports two complementary modes for monitoring Slack channels that can
7979
# Run with both polling AND socket mode
8080
make run ARGS="--product openshift --ci prow --enable-socket-mode"
8181
```
82-
82+
8383
**Socket Mode Requirements:**
8484
- An app-level token (`xapp-*`) must be configured as `SLACK_APP_TOKEN`
8585
- Socket Mode must be enabled in your Slack app settings
@@ -124,6 +124,12 @@ GENERIC_INFERENCE_URL="YOUR_INFERENCE_ENDPOINT"
124124
GENERIC_INFERENCE_TOKEN="YOUR_INFERENCE_TOKEN"
125125
GENERIC_MODEL="YOUR_INFERENCE_MODEL"
126126
```
127+
128+
# Jira fields
129+
JIRA_BASE_URL="YOUR_JIRA_URL"
130+
JIRA_TOKEN="YOUR_JIRA_TOKEN"
131+
JIRA_ALLOWED_PROJECTS="YOUR_JIRA_PROJECT"
132+
127133
**Note**: Please make sure to provide details for all the mandatory attributes and for the product that is intended to be used for testing along with fallback (i.e. GENERIC details) to handle failover use-cases.
128134
129135
Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
"""Jira integration tools."""
2+
3+
import logging
4+
import os
5+
import json
6+
from typing import List, Optional
7+
8+
from fastmcp import FastMCP
9+
import requests
10+
11+
logger = logging.getLogger(__name__)
12+
13+
mcp = FastMCP("jira_tools")
14+
15+
16+
class JiraClient:
17+
"""Jira API client for searching issues."""
18+
19+
def __init__(self, base_url: str, token: str):
20+
self.base_url = base_url.rstrip("/")
21+
self.token = token
22+
self.headers = {
23+
"Authorization": f"Bearer {token}",
24+
"Accept": "application/json",
25+
"Content-Type": "application/json",
26+
}
27+
28+
def search_issues(
29+
self, project_key: str, search_text: str, max_results: int = 10
30+
) -> List[dict]:
31+
"""Search for issues in a project by title or description."""
32+
jql = (
33+
f'project = "{project_key}" AND '
34+
f'(summary ~ "{search_text}" OR description ~ "{search_text}") '
35+
"ORDER BY updated DESC"
36+
)
37+
38+
payload = {
39+
"jql": jql,
40+
"maxResults": max_results,
41+
"fields": [
42+
"summary",
43+
"description",
44+
"status",
45+
"assignee",
46+
"reporter",
47+
"created",
48+
"updated",
49+
"key",
50+
],
51+
}
52+
53+
try:
54+
response = requests.post(
55+
f"{self.base_url}/rest/api/2/search",
56+
headers=self.headers,
57+
json=payload,
58+
timeout=30,
59+
)
60+
response.raise_for_status()
61+
62+
# Debug: log the response content
63+
logger.debug(f"Jira API response status: {response.status_code}")
64+
logger.debug(f"Jira API response content: {response.text[:500]}...")
65+
66+
try:
67+
data = response.json()
68+
except json.JSONDecodeError as json_error:
69+
logger.error(f"JSON decode error: {json_error}")
70+
logger.error(f"Response content: {response.text}")
71+
raise Exception(f"Invalid JSON response from Jira API: {json_error}")
72+
73+
# Check if data is None or empty
74+
if not data:
75+
logger.error("Empty response from Jira API")
76+
return []
77+
78+
issues = []
79+
80+
# Safely get issues list
81+
issues_list = data.get("issues", [])
82+
if not isinstance(issues_list, list):
83+
logger.error(f"Expected list of issues, got: {type(issues_list)}")
84+
return []
85+
86+
for issue in issues_list:
87+
if not isinstance(issue, dict):
88+
logger.warning(f"Skipping non-dict issue: {type(issue)}")
89+
continue
90+
91+
fields = issue.get("fields", {})
92+
if not isinstance(fields, dict):
93+
logger.warning(
94+
f"Skipping issue with non-dict fields: {type(fields)}"
95+
)
96+
continue
97+
98+
issues.append(
99+
{
100+
"key": issue.get("key"),
101+
"summary": fields.get("summary", ""),
102+
"description": fields.get("description", ""),
103+
"status": (fields.get("status") or {}).get("name", ""),
104+
"assignee": (fields.get("assignee") or {}).get(
105+
"displayName", ""
106+
),
107+
"reporter": (fields.get("reporter") or {}).get(
108+
"displayName", ""
109+
),
110+
"created": fields.get("created", ""),
111+
"updated": fields.get("updated", ""),
112+
}
113+
)
114+
115+
return issues
116+
117+
except requests.exceptions.RequestException as e:
118+
logger.error(f"Error searching Jira issues: {e}")
119+
if hasattr(e, "response") and e.response is not None:
120+
logger.error(f"Response status: {e.response.status_code}")
121+
logger.error(f"Response content: {e.response.text}")
122+
raise Exception(f"Failed to search Jira issues: {str(e)}")
123+
124+
def get_issue(self, issue_key: str) -> Optional[dict]:
125+
"""Get a specific issue by key."""
126+
try:
127+
response = requests.get(
128+
f"{self.base_url}/rest/api/2/issue/{issue_key}",
129+
headers=self.headers,
130+
timeout=30,
131+
)
132+
response.raise_for_status()
133+
134+
data = response.json()
135+
fields = data.get("fields", {})
136+
137+
return {
138+
"key": data.get("key"),
139+
"summary": fields.get("summary", ""),
140+
"description": fields.get("description", ""),
141+
"status": (fields.get("status") or {}).get("name", ""),
142+
"assignee": (fields.get("assignee") or {}).get("displayName", ""),
143+
"reporter": (fields.get("reporter") or {}).get("displayName", ""),
144+
"created": fields.get("created", ""),
145+
"updated": fields.get("updated", ""),
146+
}
147+
148+
except requests.exceptions.RequestException as e:
149+
logger.error(f"Error getting Jira issue {issue_key}: {e}")
150+
return None
151+
152+
153+
def get_allowed_projects() -> List[str]:
154+
"""Get allowed Jira projects from environment variable.
155+
Returns:
156+
List of allowed project keys
157+
Raises:
158+
ValueError: If no valid projects are configured
159+
"""
160+
projects_env = os.environ.get("JIRA_ALLOWED_PROJECTS", "OCPBUGS")
161+
allowed = [p.strip() for p in projects_env.split(",") if p.strip()]
162+
163+
logger.debug(f"Allowed Jira projects: {allowed}")
164+
165+
if not allowed:
166+
raise ValueError("JIRA_ALLOWED_PROJECTS must contain at least one project")
167+
168+
return allowed
169+
170+
171+
def get_jira_client() -> JiraClient:
172+
"""Get Jira client from environment variables."""
173+
base_url = os.environ.get("JIRA_BASE_URL")
174+
token = os.environ.get("JIRA_TOKEN")
175+
176+
logger.debug(f"JIRA_BASE_URL: {base_url}")
177+
logger.debug(f"JIRA_TOKEN: {token[:10] if token else 'None'}...")
178+
179+
if not base_url or not token:
180+
raise ValueError(
181+
"Missing Jira credentials: JIRA_BASE_URL and JIRA_TOKEN "
182+
"must be set in environment"
183+
)
184+
185+
return JiraClient(base_url, token)
186+
187+
188+
@mcp.tool()
189+
def search_jira_issues(
190+
project_key: str, search_text: str, max_results: int = 10
191+
) -> str:
192+
"""Search for Jira issues in a project by title or description.
193+
Use this tool to find related bugs or known issues that match the error patterns.
194+
Call list_jira_projects() first if you're unsure which projects are available.
195+
Args:
196+
project_key: The Jira project key to search in
197+
(must be one of the configured allowed projects)
198+
search_text: Text to search for in issue summary or description
199+
(e.g., "etcd degraded", "CrashLoopBackOff", component names)
200+
max_results: Maximum number of results to return (default: 10)
201+
Returns:
202+
JSON string containing matching issues with their details
203+
"""
204+
# Validate project key against allowed projects
205+
allowed_projects = get_allowed_projects()
206+
if project_key not in allowed_projects:
207+
return (
208+
f"Error: Project '{project_key}' is not in the allowed list. "
209+
f"Available projects: {', '.join(allowed_projects)}. "
210+
f"Please use one of these project keys."
211+
)
212+
213+
try:
214+
logger.debug(
215+
f"search_jira_issues called with project_key={project_key}, search_text={search_text}, max_results={max_results}"
216+
)
217+
client = get_jira_client()
218+
logger.debug(f"Got Jira client: {client}")
219+
issues = client.search_issues(project_key, search_text, max_results)
220+
logger.debug(f"Got issues: {issues}")
221+
222+
if not issues:
223+
return "No matching issues found."
224+
225+
# Format the results as a readable list
226+
formatted_result = f"Found {len(issues)} matching issues:\n\n"
227+
228+
for i, issue in enumerate(issues, 1):
229+
formatted_result += f"{i}. **{issue['key']}** - {issue['summary']}\n"
230+
formatted_result += f" - Status: {issue['status']}\n"
231+
formatted_result += f" - Assignee: {issue['assignee']}\n"
232+
formatted_result += f" - Reporter: {issue['reporter']}\n"
233+
formatted_result += f" - Created: {issue['created']}\n"
234+
formatted_result += f" - Updated: {issue['updated']}\n"
235+
236+
# Add description if available (truncated for readability)
237+
if issue["description"]:
238+
desc = (
239+
issue["description"][:200] + "..."
240+
if len(issue["description"]) > 500
241+
else issue["description"]
242+
)
243+
formatted_result += f" {desc}\n"
244+
245+
formatted_result += "\n"
246+
247+
return formatted_result
248+
249+
except Exception as e:
250+
logger.error(f"Error in search_jira_issues: {e}")
251+
return f"Error searching Jira issues: {str(e)}"
252+
253+
254+
@mcp.tool()
255+
def get_jira_issue(issue_key: str) -> str:
256+
"""Get a specific Jira issue by its key.
257+
Args:
258+
issue_key: The Jira issue key (e.g., 'PROJ-123')
259+
Returns:
260+
JSON string containing the issue details
261+
"""
262+
try:
263+
client = get_jira_client()
264+
issue = client.get_issue(issue_key)
265+
266+
if not issue:
267+
return f"Issue {issue_key} not found or access denied."
268+
269+
# Format the issue as a readable string
270+
formatted_result = f"**{issue['key']}** - {issue['summary']}\n\n"
271+
formatted_result += f"**Status:** {issue['status']}\n"
272+
formatted_result += f"**Assignee:** {issue['assignee']}\n"
273+
formatted_result += f"**Reporter:** {issue['reporter']}\n"
274+
formatted_result += f"**Created:** {issue['created']}\n"
275+
formatted_result += f"**Updated:** {issue['updated']}\n\n"
276+
277+
if issue["description"]:
278+
formatted_result += f"**Description:**\n{issue['description']}\n"
279+
280+
return formatted_result
281+
282+
except Exception as e:
283+
logger.error(f"Error in get_jira_issue: {e}")
284+
return f"Error getting Jira issue: {str(e)}"
285+
286+
287+
@mcp.tool()
288+
def list_jira_projects() -> str:
289+
"""List the Jira projects that are allowed/configured for searching.
290+
Use this tool first if you need to know which project keys are available
291+
before calling search_jira_issues. The list is configured via the
292+
JIRA_ALLOWED_PROJECTS environment variable.
293+
Returns:
294+
Formatted list of allowed project keys and their details
295+
"""
296+
try:
297+
# Get allowed projects from environment first
298+
allowed_projects = get_allowed_projects()
299+
300+
# Return allowed projects list as a minimum
301+
base_result = (
302+
f"Allowed Jira project keys for search: "
303+
f"{', '.join(allowed_projects)}\n\n"
304+
)
305+
306+
# Try to fetch additional details from Jira API
307+
try:
308+
client = get_jira_client()
309+
response = requests.get(
310+
f"{client.base_url}/rest/api/2/project",
311+
headers=client.headers,
312+
timeout=30,
313+
)
314+
response.raise_for_status()
315+
316+
projects = response.json()
317+
project_list = []
318+
319+
# Filter to only include allowed projects
320+
for project in projects:
321+
project_key = project.get("key")
322+
if project_key in allowed_projects:
323+
project_list.append(
324+
{
325+
"key": project_key,
326+
"name": project.get("name"),
327+
"id": project.get("id"),
328+
}
329+
)
330+
331+
# Format with full details
332+
if project_list:
333+
formatted_result = base_result
334+
formatted_result += "Project Details:\n\n"
335+
for i, project in enumerate(project_list, 1):
336+
formatted_result += (
337+
f"{i}. **{project['key']}** - {project['name']}\n"
338+
)
339+
formatted_result += f" - ID: {project['id']}\n\n"
340+
return formatted_result
341+
else:
342+
return base_result + (
343+
"Note: Could not fetch additional project details from Jira API."
344+
)
345+
346+
except Exception as api_error:
347+
logger.warning(f"Could not fetch project details: {api_error}")
348+
# Still return allowed projects list
349+
return base_result + (
350+
"Note: Could not fetch additional details from Jira API, "
351+
"but you can use any of the allowed project keys above."
352+
)
353+
354+
except Exception as e:
355+
logger.error(f"Error in list_jira_projects: {e}")
356+
return f"Error: {str(e)}"
357+
358+
359+
if __name__ == "__main__":
360+
mcp.run(transport="stdio", show_banner=False)

kustomize/base/configmap-prompts.yaml

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@ metadata:
88
data:
99
prompts.json: |
1010
{
11-
"OPENSHIFT_PROMPT": {
12-
"system": "You are an expert in OpenShift, Kubernetes, and cloud infrastructure. Your task is to analyze logs and summaries related to OpenShift environments. Given a log summary, identify the root cause, potential fixes, and affected components. Be as consise as possible (under 5000 characters), but precise and avoid generic troubleshooting steps. Prioritize OpenShift-specific debugging techniques. Keep in mind that the cluster is ephemeral and is destroyed after the build is complete, but all relevant logs and metrics are available. Use markdown formatting for the output with only one level of bullet points, do not use bold text except for the headers.",
13-
"user": "Here is the log summary from an OpenShift environment:\n\n{summary}\n\nBased on this summary, provide a structured breakdown of:\n- The OpenShift component likely affected (e.g., etcd, kube-apiserver, ingress, SDN, Machine API)\n- The probable root cause\n- Steps to verify the issue further\n- Suggested resolution, including OpenShift-specific commands or configurations.",
14-
"assistant": "**Affected Component:** <Identified component>\n\n**Probable Root Cause:** <Describe why this issue might be occurring>\n\n**Verification Steps:**\n- <Step 1>\n- <Step 2>\n- <Step 3>\n\n**Suggested Resolution:**\n- <OpenShift CLI commands>\n- <Relevant OpenShift configurations>"
15-
},
11+
"OPENSHIFT_PROMPT": {
12+
"system": "You are an expert in OpenShift, Kubernetes, and cloud infrastructure. Your task is to analyze logs and summaries related to OpenShift environments. Given a log summary, identify the root cause, potential fixes, and affected components. Be as consise as possible (under 5000 characters), but precise and avoid generic troubleshooting steps. Prioritize OpenShift-specific debugging techniques. Keep in mind that the cluster is ephemeral and is destroyed after the build is complete, but all relevant logs and metrics are available. Use markdown formatting for the output with only one level of bullet points, do not use bold text except for the headers.\n\nIMPORTANT: You have access to JIRA search tools. After analyzing the error, ALWAYS search for related issues in JIRA using the search_jira_issues tool with the OCPBUGS project. Extract key error terms, component names, or operators from the log summary to search for similar issues. Include the top 3 most relevant JIRA issues in your final response under a 'Related JIRA Issues' section.",
13+
"user": "Here is the log summary from an OpenShift environment:\n\n{summary}\n\nBased on this summary, provide a structured breakdown of:\n- The OpenShift component likely affected (e.g., etcd, kube-apiserver, ingress, SDN, Machine API)\n- The probable root cause\n- Steps to verify the issue further\n- Suggested resolution, including OpenShift-specific commands or configurations\n- Related JIRA issues (search using search_jira_issues tool with OCPBUGS project and include the top 3 most relevant issues)",
14+
"assistant": "**Affected Component:** <Identified component>\n\n**Probable Root Cause:** <Describe why this issue might be occurring>\n\n**Verification Steps:**\n- <Step 1>\n- <Step 2>\n- <Step 3>\n\n**Suggested Resolution:**\n- <OpenShift CLI commands>\n- <Relevant OpenShift configurations>\n\n**Related JIRA Issues:**\n- <Top 3 relevant issues from JIRA search>"
15+
},
1616
"ANSIBLE_PROMPT": {
1717
"system": "You are an expert in Ansible automation, playbook debugging, and infrastructure as code (IaC). Your task is to analyze log summaries related to Ansible execution, playbook failures, and task errors. Given a log summary, identify the root cause, affected tasks, and potential fixes. Prioritize Ansible-specific debugging techniques over generic troubleshooting.",
1818
"user": "Here is the log summary from an Ansible execution:\n\n{summary}\n\nBased on this summary, provide a structured breakdown of:\n- The failed Ansible task and module involved\n- The probable root cause\n- Steps to reproduce or verify the issue\n- Suggested resolution, including relevant playbook changes or command-line fixes.",

0 commit comments

Comments
 (0)