@rishibhushan/jenkins-mcp-server 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +575 -0
- package/bin/jenkins-mcp.js +327 -0
- package/package.json +51 -0
- package/requirements.txt +12 -0
- package/src/jenkins_mcp_server/__init__.py +133 -0
- package/src/jenkins_mcp_server/__main__.py +19 -0
- package/src/jenkins_mcp_server/config.py +278 -0
- package/src/jenkins_mcp_server/jenkins_client.py +516 -0
- package/src/jenkins_mcp_server/server.py +1037 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Jenkins MCP Server Client Module
|
|
3
|
+
|
|
4
|
+
Provides a clean interface to Jenkins API operations with automatic fallback
|
|
5
|
+
between python-jenkins library and direct REST API calls.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import time
|
|
10
|
+
import xml.etree.ElementTree as ET
|
|
11
|
+
from typing import Any, Dict, List, Optional
|
|
12
|
+
|
|
13
|
+
import jenkins
|
|
14
|
+
import requests
|
|
15
|
+
import urllib3
|
|
16
|
+
from requests.auth import HTTPBasicAuth
|
|
17
|
+
|
|
18
|
+
from .config import JenkinsSettings, get_default_settings
|
|
19
|
+
|
|
20
|
+
# Disable SSL warnings
|
|
21
|
+
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
|
22
|
+
|
|
23
|
+
# Configure logging
|
|
24
|
+
logger = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class JenkinsConnectionError(Exception):
|
|
28
|
+
"""Raised when unable to connect to Jenkins"""
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class JenkinsClient:
|
|
33
|
+
"""
|
|
34
|
+
Client for interacting with Jenkins API.
|
|
35
|
+
|
|
36
|
+
Supports both python-jenkins library and direct REST API calls
|
|
37
|
+
with automatic fallback for reliability.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
def __init__(self, settings: Optional[JenkinsSettings] = None):
|
|
41
|
+
"""
|
|
42
|
+
Initialize Jenkins client.
|
|
43
|
+
|
|
44
|
+
Args:
|
|
45
|
+
settings: JenkinsSettings instance. If None, uses default settings.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
JenkinsConnectionError: If unable to connect to Jenkins
|
|
49
|
+
ValueError: If required settings are missing
|
|
50
|
+
"""
|
|
51
|
+
self.settings = settings or get_default_settings()
|
|
52
|
+
|
|
53
|
+
# Validate required settings
|
|
54
|
+
if not self.settings.is_configured:
|
|
55
|
+
raise ValueError(
|
|
56
|
+
"Jenkins settings incomplete. Required: url, username, and (token or password)"
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
self.base_url = self.settings.url
|
|
60
|
+
username, auth_value = self.settings.get_credentials()
|
|
61
|
+
self.auth = HTTPBasicAuth(username, auth_value)
|
|
62
|
+
|
|
63
|
+
# Cache for python-jenkins server instance
|
|
64
|
+
self._server: Optional[jenkins.Jenkins] = None
|
|
65
|
+
|
|
66
|
+
# Test connection
|
|
67
|
+
self._test_connection()
|
|
68
|
+
|
|
69
|
+
def _test_connection(self) -> None:
|
|
70
|
+
"""Test connection to Jenkins server"""
|
|
71
|
+
try:
|
|
72
|
+
response = requests.get(
|
|
73
|
+
f"{self.base_url}/api/json",
|
|
74
|
+
auth=self.auth,
|
|
75
|
+
verify=False,
|
|
76
|
+
timeout=10
|
|
77
|
+
)
|
|
78
|
+
response.raise_for_status()
|
|
79
|
+
|
|
80
|
+
data = response.json()
|
|
81
|
+
logger.info(f"Connected to Jenkins: {self.base_url}")
|
|
82
|
+
logger.info(f"Jenkins version: {data.get('_class', 'unknown')}")
|
|
83
|
+
logger.debug(f"Found {len(data.get('jobs', []))} jobs")
|
|
84
|
+
|
|
85
|
+
except requests.RequestException as e:
|
|
86
|
+
raise JenkinsConnectionError(
|
|
87
|
+
f"Failed to connect to Jenkins at {self.base_url}: {e}"
|
|
88
|
+
) from e
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def server(self) -> jenkins.Jenkins:
|
|
92
|
+
"""Get or create python-jenkins server instance (lazy initialization)"""
|
|
93
|
+
if self._server is None:
|
|
94
|
+
username, password = self.settings.get_credentials()
|
|
95
|
+
self._server = jenkins.Jenkins(
|
|
96
|
+
self.base_url,
|
|
97
|
+
username=username,
|
|
98
|
+
password=password
|
|
99
|
+
)
|
|
100
|
+
return self._server
|
|
101
|
+
|
|
102
|
+
def _api_call(self, method: str, endpoint: str, **kwargs) -> requests.Response:
|
|
103
|
+
"""
|
|
104
|
+
Make a direct REST API call to Jenkins.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
method: HTTP method (GET, POST, etc.)
|
|
108
|
+
endpoint: API endpoint (e.g., '/job/myjob/api/json')
|
|
109
|
+
**kwargs: Additional arguments for requests
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
requests.Response object
|
|
113
|
+
"""
|
|
114
|
+
url = f"{self.base_url}{endpoint}"
|
|
115
|
+
kwargs.setdefault('auth', self.auth)
|
|
116
|
+
kwargs.setdefault('verify', False)
|
|
117
|
+
kwargs.setdefault('timeout', 30)
|
|
118
|
+
|
|
119
|
+
response = requests.request(method, url, **kwargs)
|
|
120
|
+
response.raise_for_status()
|
|
121
|
+
return response
|
|
122
|
+
|
|
123
|
+
# ==================== Job Information ====================
|
|
124
|
+
|
|
125
|
+
def get_jobs(self) -> List[Dict[str, Any]]:
|
|
126
|
+
"""Get list of all Jenkins jobs"""
|
|
127
|
+
try:
|
|
128
|
+
return self.server.get_jobs()
|
|
129
|
+
except Exception as e:
|
|
130
|
+
logger.debug(f"python-jenkins failed, using REST API: {e}")
|
|
131
|
+
response = self._api_call('GET', '/api/json')
|
|
132
|
+
return response.json().get('jobs', [])
|
|
133
|
+
|
|
134
|
+
def get_job_info(self, job_name: str) -> Dict[str, Any]:
|
|
135
|
+
"""Get detailed information about a specific job"""
|
|
136
|
+
try:
|
|
137
|
+
return self.server.get_job_info(job_name)
|
|
138
|
+
except Exception as e:
|
|
139
|
+
logger.debug(f"python-jenkins failed, using REST API: {e}")
|
|
140
|
+
response = self._api_call('GET', f'/job/{job_name}/api/json')
|
|
141
|
+
return response.json()
|
|
142
|
+
|
|
143
|
+
def get_last_build_number(self, job_name: str) -> Optional[int]:
|
|
144
|
+
"""Get the last build number for a job"""
|
|
145
|
+
try:
|
|
146
|
+
info = self.get_job_info(job_name)
|
|
147
|
+
|
|
148
|
+
# Try lastBuild first
|
|
149
|
+
if info.get('lastBuild') and 'number' in info['lastBuild']:
|
|
150
|
+
return int(info['lastBuild']['number'])
|
|
151
|
+
|
|
152
|
+
# Fall back to lastCompletedBuild
|
|
153
|
+
if info.get('lastCompletedBuild') and 'number' in info['lastCompletedBuild']:
|
|
154
|
+
return int(info['lastCompletedBuild']['number'])
|
|
155
|
+
|
|
156
|
+
return None
|
|
157
|
+
except Exception as e:
|
|
158
|
+
logger.error(f"Error getting last build number for {job_name}: {e}")
|
|
159
|
+
return None
|
|
160
|
+
|
|
161
|
+
def get_last_build_timestamp(self, job_name: str) -> Optional[int]:
|
|
162
|
+
"""Get timestamp (ms since epoch) of the last build"""
|
|
163
|
+
try:
|
|
164
|
+
last_num = self.get_last_build_number(job_name)
|
|
165
|
+
if last_num is None:
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
build_info = self.get_build_info(job_name, last_num)
|
|
169
|
+
return build_info.get('timestamp')
|
|
170
|
+
except Exception as e:
|
|
171
|
+
logger.error(f"Error getting last build timestamp for {job_name}: {e}")
|
|
172
|
+
return None
|
|
173
|
+
|
|
174
|
+
# ==================== Build Information ====================
|
|
175
|
+
|
|
176
|
+
def get_build_info(self, job_name: str, build_number: int) -> Dict[str, Any]:
|
|
177
|
+
"""Get information about a specific build"""
|
|
178
|
+
try:
|
|
179
|
+
return self.server.get_build_info(job_name, build_number)
|
|
180
|
+
except Exception as e:
|
|
181
|
+
logger.debug(f"python-jenkins failed, using REST API: {e}")
|
|
182
|
+
response = self._api_call('GET', f'/job/{job_name}/{build_number}/api/json')
|
|
183
|
+
return response.json()
|
|
184
|
+
|
|
185
|
+
def get_build_console_output(self, job_name: str, build_number: int) -> str:
|
|
186
|
+
"""Get console output from a build (alias for get_build_log)"""
|
|
187
|
+
return self.get_build_log(job_name, build_number)
|
|
188
|
+
|
|
189
|
+
def get_build_log(self, job_name: str, build_number: int) -> str:
|
|
190
|
+
"""Get console log output from a build"""
|
|
191
|
+
try:
|
|
192
|
+
return self.server.get_build_console_output(job_name, build_number)
|
|
193
|
+
except Exception as e:
|
|
194
|
+
logger.debug(f"python-jenkins failed, using REST API: {e}")
|
|
195
|
+
response = self._api_call('GET', f'/job/{job_name}/{build_number}/consoleText')
|
|
196
|
+
return response.text
|
|
197
|
+
|
|
198
|
+
# ==================== Build Operations ====================
|
|
199
|
+
|
|
200
|
+
def build_job(
|
|
201
|
+
self,
|
|
202
|
+
job_name: str,
|
|
203
|
+
parameters: Optional[Dict[str, Any]] = None,
|
|
204
|
+
wait_for_start: bool = True,
|
|
205
|
+
timeout: int = 30,
|
|
206
|
+
poll_interval: float = 1.0
|
|
207
|
+
) -> Dict[str, Optional[int]]:
|
|
208
|
+
"""
|
|
209
|
+
Trigger a build and optionally wait for it to start.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
job_name: Name of the Jenkins job
|
|
213
|
+
parameters: Optional build parameters
|
|
214
|
+
wait_for_start: Wait for build to start and return build number
|
|
215
|
+
timeout: Maximum seconds to wait for build start
|
|
216
|
+
poll_interval: Seconds between polling attempts
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
Dict with 'queue_id' and 'build_number' (if wait_for_start=True)
|
|
220
|
+
"""
|
|
221
|
+
try:
|
|
222
|
+
# Trigger build using python-jenkins
|
|
223
|
+
queue_id = self.server.build_job(job_name, parameters or {})
|
|
224
|
+
queue_id = int(queue_id) if queue_id else None
|
|
225
|
+
|
|
226
|
+
build_number = None
|
|
227
|
+
if wait_for_start and queue_id:
|
|
228
|
+
build_number = self._wait_for_build_start(
|
|
229
|
+
queue_id, timeout, poll_interval
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return {"queue_id": queue_id, "build_number": build_number}
|
|
233
|
+
|
|
234
|
+
except Exception as e:
|
|
235
|
+
logger.debug(f"python-jenkins failed, using REST API: {e}")
|
|
236
|
+
return self._build_job_rest(
|
|
237
|
+
job_name, parameters, wait_for_start, timeout, poll_interval
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
def _build_job_rest(
|
|
241
|
+
self,
|
|
242
|
+
job_name: str,
|
|
243
|
+
parameters: Optional[Dict[str, Any]],
|
|
244
|
+
wait_for_start: bool,
|
|
245
|
+
timeout: int,
|
|
246
|
+
poll_interval: float
|
|
247
|
+
) -> Dict[str, Optional[int]]:
|
|
248
|
+
"""Build job using REST API (fallback method)"""
|
|
249
|
+
endpoint = f'/job/{job_name}/buildWithParameters' if parameters else f'/job/{job_name}/build'
|
|
250
|
+
|
|
251
|
+
response = self._api_call('POST', endpoint, params=parameters)
|
|
252
|
+
|
|
253
|
+
# Extract queue ID from Location header
|
|
254
|
+
location = response.headers.get('Location', '')
|
|
255
|
+
queue_id = self._extract_queue_id(location)
|
|
256
|
+
|
|
257
|
+
build_number = None
|
|
258
|
+
if wait_for_start and queue_id:
|
|
259
|
+
build_number = self._wait_for_build_start(queue_id, timeout, poll_interval)
|
|
260
|
+
|
|
261
|
+
return {"queue_id": queue_id, "build_number": build_number}
|
|
262
|
+
|
|
263
|
+
def _wait_for_build_start(
|
|
264
|
+
self,
|
|
265
|
+
queue_id: int,
|
|
266
|
+
timeout: int,
|
|
267
|
+
poll_interval: float
|
|
268
|
+
) -> Optional[int]:
|
|
269
|
+
"""Wait for a queued build to start and return its build number"""
|
|
270
|
+
elapsed = 0.0
|
|
271
|
+
|
|
272
|
+
while elapsed < timeout:
|
|
273
|
+
try:
|
|
274
|
+
# Try python-jenkins first
|
|
275
|
+
item = self.server.get_queue_item(queue_id)
|
|
276
|
+
if item and item.get('executable'):
|
|
277
|
+
return int(item['executable']['number'])
|
|
278
|
+
except Exception:
|
|
279
|
+
# Fall back to REST API
|
|
280
|
+
try:
|
|
281
|
+
response = self._api_call('GET', f'/queue/item/{queue_id}/api/json')
|
|
282
|
+
item = response.json()
|
|
283
|
+
if item.get('executable'):
|
|
284
|
+
return int(item['executable']['number'])
|
|
285
|
+
except Exception:
|
|
286
|
+
pass
|
|
287
|
+
|
|
288
|
+
time.sleep(poll_interval)
|
|
289
|
+
elapsed += poll_interval
|
|
290
|
+
|
|
291
|
+
logger.warning(f"Timeout waiting for build {queue_id} to start")
|
|
292
|
+
return None
|
|
293
|
+
|
|
294
|
+
@staticmethod
|
|
295
|
+
def _extract_queue_id(location: str) -> Optional[int]:
|
|
296
|
+
"""Extract queue ID from Jenkins Location header"""
|
|
297
|
+
if not location:
|
|
298
|
+
return None
|
|
299
|
+
|
|
300
|
+
parts = location.rstrip('/').split('/')
|
|
301
|
+
for part in reversed(parts):
|
|
302
|
+
if part.isdigit():
|
|
303
|
+
return int(part)
|
|
304
|
+
return None
|
|
305
|
+
|
|
306
|
+
def stop_build(self, job_name: str, build_number: int) -> None:
|
|
307
|
+
"""Stop a running build"""
|
|
308
|
+
self._api_call('POST', f'/job/{job_name}/{build_number}/stop')
|
|
309
|
+
logger.info(f"Stopped build {job_name} #{build_number}")
|
|
310
|
+
|
|
311
|
+
# ==================== Job Management ====================
|
|
312
|
+
|
|
313
|
+
def create_job(self, job_name: str, config_xml: str) -> bool:
|
|
314
|
+
"""Create a new Jenkins job with XML configuration"""
|
|
315
|
+
try:
|
|
316
|
+
self.server.create_job(job_name, config_xml)
|
|
317
|
+
self.server.reconfig_job(job_name, config_xml)
|
|
318
|
+
logger.info(f"Created job: {job_name}")
|
|
319
|
+
return True
|
|
320
|
+
except Exception as e:
|
|
321
|
+
logger.debug(f"python-jenkins failed, using REST API: {e}")
|
|
322
|
+
self._api_call(
|
|
323
|
+
'POST',
|
|
324
|
+
'/createItem',
|
|
325
|
+
params={'name': job_name},
|
|
326
|
+
data=config_xml,
|
|
327
|
+
headers={'Content-Type': 'application/xml'}
|
|
328
|
+
)
|
|
329
|
+
logger.info(f"Created job: {job_name}")
|
|
330
|
+
return True
|
|
331
|
+
|
|
332
|
+
def create_job_from_copy(self, new_job_name: str, source_job_name: str) -> bool:
|
|
333
|
+
"""Create a new job by copying an existing one"""
|
|
334
|
+
# Get source config
|
|
335
|
+
config_xml = self.get_job_config(source_job_name)
|
|
336
|
+
|
|
337
|
+
# Update job references in XML
|
|
338
|
+
config_xml = self._update_job_references(config_xml, source_job_name, new_job_name)
|
|
339
|
+
|
|
340
|
+
# Create new job
|
|
341
|
+
return self.create_job(new_job_name, config_xml)
|
|
342
|
+
|
|
343
|
+
@staticmethod
|
|
344
|
+
def _update_job_references(config_xml: str, old_name: str, new_name: str) -> str:
|
|
345
|
+
"""Update job name references in XML configuration"""
|
|
346
|
+
try:
|
|
347
|
+
root = ET.fromstring(config_xml)
|
|
348
|
+
|
|
349
|
+
# Update projectName and projectFullName elements
|
|
350
|
+
for elem in root.iter():
|
|
351
|
+
if elem.tag in ['projectName', 'projectFullName'] and elem.text == old_name:
|
|
352
|
+
elem.text = new_name
|
|
353
|
+
|
|
354
|
+
return ET.tostring(root, encoding='unicode', method='xml')
|
|
355
|
+
except Exception as e:
|
|
356
|
+
logger.warning(f"Failed to update job references in XML: {e}")
|
|
357
|
+
return config_xml
|
|
358
|
+
|
|
359
|
+
def create_job_from_dict(
|
|
360
|
+
self,
|
|
361
|
+
job_name: str,
|
|
362
|
+
config_data: Dict[str, Any],
|
|
363
|
+
root_tag: str = 'project'
|
|
364
|
+
) -> bool:
|
|
365
|
+
"""
|
|
366
|
+
Create a job from a dictionary (simplified XML generation).
|
|
367
|
+
|
|
368
|
+
Note: For complex configurations, use create_job() with full XML
|
|
369
|
+
or create_job_from_copy() instead.
|
|
370
|
+
"""
|
|
371
|
+
config_xml = self._dict_to_xml(root_tag, config_data)
|
|
372
|
+
return self.create_job(job_name, config_xml)
|
|
373
|
+
|
|
374
|
+
@staticmethod
|
|
375
|
+
def _dict_to_xml(root_tag: str, data: Dict[str, Any]) -> str:
|
|
376
|
+
"""Convert dictionary to XML (basic implementation)"""
|
|
377
|
+
|
|
378
|
+
def build_elem(parent: ET.Element, obj: Any) -> None:
|
|
379
|
+
if isinstance(obj, dict):
|
|
380
|
+
for key, value in obj.items():
|
|
381
|
+
child = ET.SubElement(parent, key)
|
|
382
|
+
build_elem(child, value)
|
|
383
|
+
elif isinstance(obj, list):
|
|
384
|
+
for item in obj:
|
|
385
|
+
item_el = ET.SubElement(parent, 'item')
|
|
386
|
+
build_elem(item_el, item)
|
|
387
|
+
else:
|
|
388
|
+
parent.text = str(obj)
|
|
389
|
+
|
|
390
|
+
root = ET.Element(root_tag)
|
|
391
|
+
build_elem(root, data)
|
|
392
|
+
return ET.tostring(root, encoding='unicode')
|
|
393
|
+
|
|
394
|
+
def delete_job(self, job_name: str) -> bool:
|
|
395
|
+
"""Delete an existing job"""
|
|
396
|
+
try:
|
|
397
|
+
self.server.delete_job(job_name)
|
|
398
|
+
logger.info(f"Deleted job: {job_name}")
|
|
399
|
+
return True
|
|
400
|
+
except Exception as e:
|
|
401
|
+
logger.debug(f"python-jenkins failed, using REST API: {e}")
|
|
402
|
+
self._api_call('POST', f'/job/{job_name}/doDelete')
|
|
403
|
+
logger.info(f"Deleted job: {job_name}")
|
|
404
|
+
return True
|
|
405
|
+
|
|
406
|
+
def enable_job(self, job_name: str) -> bool:
|
|
407
|
+
"""Enable a disabled job"""
|
|
408
|
+
self._api_call('POST', f'/job/{job_name}/enable')
|
|
409
|
+
logger.info(f"Enabled job: {job_name}")
|
|
410
|
+
return True
|
|
411
|
+
|
|
412
|
+
def disable_job(self, job_name: str) -> bool:
|
|
413
|
+
"""Disable a job"""
|
|
414
|
+
self._api_call('POST', f'/job/{job_name}/disable')
|
|
415
|
+
logger.info(f"Disabled job: {job_name}")
|
|
416
|
+
return True
|
|
417
|
+
|
|
418
|
+
def rename_job(self, job_name: str, new_name: str) -> bool:
|
|
419
|
+
"""Rename a job"""
|
|
420
|
+
self._api_call(
|
|
421
|
+
'POST',
|
|
422
|
+
f'/job/{job_name}/doRename',
|
|
423
|
+
params={'newName': new_name}
|
|
424
|
+
)
|
|
425
|
+
logger.info(f"Renamed job: {job_name} -> {new_name}")
|
|
426
|
+
return True
|
|
427
|
+
|
|
428
|
+
# ==================== Job Configuration ====================
|
|
429
|
+
|
|
430
|
+
def get_job_config(self, job_name: str) -> str:
|
|
431
|
+
"""Get job configuration XML"""
|
|
432
|
+
try:
|
|
433
|
+
return self.server.get_job_config(job_name)
|
|
434
|
+
except Exception as e:
|
|
435
|
+
logger.debug(f"python-jenkins failed, using REST API: {e}")
|
|
436
|
+
response = self._api_call('GET', f'/job/{job_name}/config.xml')
|
|
437
|
+
return response.text
|
|
438
|
+
|
|
439
|
+
def update_job_config(self, job_name: str, config_xml: str) -> bool:
|
|
440
|
+
"""Update job configuration XML"""
|
|
441
|
+
try:
|
|
442
|
+
self.server.reconfig_job(job_name, config_xml)
|
|
443
|
+
logger.info(f"Updated config for job: {job_name}")
|
|
444
|
+
return True
|
|
445
|
+
except Exception as e:
|
|
446
|
+
logger.debug(f"python-jenkins failed, using REST API: {e}")
|
|
447
|
+
self._api_call(
|
|
448
|
+
'POST',
|
|
449
|
+
f'/job/{job_name}/config.xml',
|
|
450
|
+
data=config_xml,
|
|
451
|
+
headers={'Content-Type': 'application/xml'}
|
|
452
|
+
)
|
|
453
|
+
logger.info(f"Updated config for job: {job_name}")
|
|
454
|
+
return True
|
|
455
|
+
|
|
456
|
+
# ==================== Queue & Node Information ====================
|
|
457
|
+
|
|
458
|
+
def get_queue_info(self) -> List[Dict[str, Any]]:
|
|
459
|
+
"""Get information about the build queue"""
|
|
460
|
+
try:
|
|
461
|
+
response = self._api_call('GET', '/queue/api/json')
|
|
462
|
+
return response.json().get('items', [])
|
|
463
|
+
except Exception as e:
|
|
464
|
+
logger.error(f"Error getting queue info: {e}")
|
|
465
|
+
return []
|
|
466
|
+
|
|
467
|
+
def get_nodes(self) -> List[Dict[str, Any]]:
|
|
468
|
+
"""Get list of all Jenkins nodes"""
|
|
469
|
+
try:
|
|
470
|
+
response = self._api_call('GET', '/computer/api/json')
|
|
471
|
+
return response.json().get('computer', [])
|
|
472
|
+
except Exception as e:
|
|
473
|
+
logger.error(f"Error getting nodes: {e}")
|
|
474
|
+
return []
|
|
475
|
+
|
|
476
|
+
def get_node_info(self, node_name: str) -> Dict[str, Any]:
|
|
477
|
+
"""Get information about a specific node"""
|
|
478
|
+
response = self._api_call('GET', f'/computer/{node_name}/api/json')
|
|
479
|
+
return response.json()
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
# ==================== Client Factory ====================
|
|
483
|
+
|
|
484
|
+
_default_client: Optional[JenkinsClient] = None
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def get_jenkins_client(settings: Optional[JenkinsSettings] = None) -> JenkinsClient:
|
|
488
|
+
"""
|
|
489
|
+
Get Jenkins client instance.
|
|
490
|
+
|
|
491
|
+
Args:
|
|
492
|
+
settings: Optional JenkinsSettings. If None, uses default settings.
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
JenkinsClient instance
|
|
496
|
+
|
|
497
|
+
Note: If no settings provided, returns a cached default client (singleton).
|
|
498
|
+
If settings are provided, always returns a new client instance.
|
|
499
|
+
"""
|
|
500
|
+
global _default_client
|
|
501
|
+
|
|
502
|
+
if settings is not None:
|
|
503
|
+
# Always create new client with custom settings
|
|
504
|
+
return JenkinsClient(settings)
|
|
505
|
+
|
|
506
|
+
# Use cached default client
|
|
507
|
+
if _default_client is None:
|
|
508
|
+
_default_client = JenkinsClient()
|
|
509
|
+
|
|
510
|
+
return _default_client
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def reset_default_client() -> None:
|
|
514
|
+
"""Reset the default client (useful for testing or reconfiguration)"""
|
|
515
|
+
global _default_client
|
|
516
|
+
_default_client = None
|