agentek-youtrack-mcp 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/LICENSE +22 -0
- package/README.md +332 -0
- package/README.npm.md +436 -0
- package/dist/bin/youtrack-mcp.js +158 -0
- package/dist/index.js +129 -0
- package/dist/python/main.py +74 -0
- package/dist/python/requirements.txt +13 -0
- package/dist/python/youtrack_mcp/__init__.py +5 -0
- package/dist/python/youtrack_mcp/__pycache__/__init__.cpython-311.pyc +0 -0
- package/dist/python/youtrack_mcp/__pycache__/version.cpython-311.pyc +0 -0
- package/dist/python/youtrack_mcp/api/__init__.py +3 -0
- package/dist/python/youtrack_mcp/api/articles.py +317 -0
- package/dist/python/youtrack_mcp/api/client.py +466 -0
- package/dist/python/youtrack_mcp/api/issues.py +3008 -0
- package/dist/python/youtrack_mcp/api/mcp_wrappers.py +304 -0
- package/dist/python/youtrack_mcp/api/projects.py +1009 -0
- package/dist/python/youtrack_mcp/api/search.py +244 -0
- package/dist/python/youtrack_mcp/api/spaces.py +46 -0
- package/dist/python/youtrack_mcp/api/users.py +149 -0
- package/dist/python/youtrack_mcp/config.py +273 -0
- package/dist/python/youtrack_mcp/mcp_server.py +158 -0
- package/dist/python/youtrack_mcp/mcp_wrappers.py +324 -0
- package/dist/python/youtrack_mcp/tools/__init__.py +8 -0
- package/dist/python/youtrack_mcp/tools/articles.py +487 -0
- package/dist/python/youtrack_mcp/tools/create_project_tool.py +51 -0
- package/dist/python/youtrack_mcp/tools/issues/__init__.py +208 -0
- package/dist/python/youtrack_mcp/tools/issues/attachments.py +161 -0
- package/dist/python/youtrack_mcp/tools/issues/basic_operations.py +322 -0
- package/dist/python/youtrack_mcp/tools/issues/custom_fields.py +365 -0
- package/dist/python/youtrack_mcp/tools/issues/dedicated_updates.py +601 -0
- package/dist/python/youtrack_mcp/tools/issues/diagnostics.py +363 -0
- package/dist/python/youtrack_mcp/tools/issues/linking.py +283 -0
- package/dist/python/youtrack_mcp/tools/issues/utilities.py +291 -0
- package/dist/python/youtrack_mcp/tools/loader.py +383 -0
- package/dist/python/youtrack_mcp/tools/projects.py +826 -0
- package/dist/python/youtrack_mcp/tools/projects.py-e +411 -0
- package/dist/python/youtrack_mcp/tools/resources.py +744 -0
- package/dist/python/youtrack_mcp/tools/search.py +297 -0
- package/dist/python/youtrack_mcp/tools/spaces.py +69 -0
- package/dist/python/youtrack_mcp/tools/users.py +185 -0
- package/dist/python/youtrack_mcp/utils.py +87 -0
- package/dist/python/youtrack_mcp/version.py +5 -0
- package/package.json +61 -0
- package/requirements.txt +13 -0
|
@@ -0,0 +1,1009 @@
|
|
|
1
|
+
"""
|
|
2
|
+
YouTrack Projects API client.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import Any, Dict, List, Optional
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
from pydantic import BaseModel, Field
|
|
9
|
+
|
|
10
|
+
from youtrack_mcp.api.client import YouTrackClient
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Project(BaseModel):
|
|
17
|
+
"""Model for a YouTrack project."""
|
|
18
|
+
|
|
19
|
+
id: str
|
|
20
|
+
name: str
|
|
21
|
+
shortName: str
|
|
22
|
+
description: Optional[str] = None
|
|
23
|
+
archived: bool = False
|
|
24
|
+
created: Optional[int] = None
|
|
25
|
+
updated: Optional[int] = None
|
|
26
|
+
lead: Optional[Dict[str, Any]] = None
|
|
27
|
+
custom_fields: List[Dict[str, Any]] = Field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ProjectsClient:
|
|
31
|
+
"""Client for interacting with YouTrack Projects API."""
|
|
32
|
+
|
|
33
|
+
def __init__(self, client: YouTrackClient):
|
|
34
|
+
"""
|
|
35
|
+
Initialize the Projects API client.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
client: The YouTrack API client
|
|
39
|
+
"""
|
|
40
|
+
self.client = client
|
|
41
|
+
|
|
42
|
+
def get_projects(
|
|
43
|
+
self, include_archived: bool = False, page_size: int = 100
|
|
44
|
+
) -> List[Project]:
|
|
45
|
+
"""
|
|
46
|
+
Get all projects with pagination support.
|
|
47
|
+
|
|
48
|
+
This method fetches all projects by iterating through pages of results.
|
|
49
|
+
YouTrack API limits results per request, so pagination is required
|
|
50
|
+
for instances with many projects (>50).
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
include_archived: Whether to include archived projects
|
|
54
|
+
page_size: Number of projects to fetch per page (default: 100)
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
List of all projects
|
|
58
|
+
"""
|
|
59
|
+
all_projects: List[Project] = []
|
|
60
|
+
skip = 0
|
|
61
|
+
fields = "id,name,shortName,description,archived,created,updated,lead(id,name,login)"
|
|
62
|
+
|
|
63
|
+
while True:
|
|
64
|
+
params = {
|
|
65
|
+
"fields": fields,
|
|
66
|
+
"$top": page_size,
|
|
67
|
+
"$skip": skip,
|
|
68
|
+
}
|
|
69
|
+
if not include_archived:
|
|
70
|
+
params["$filter"] = "archived eq false"
|
|
71
|
+
|
|
72
|
+
logger.debug(f"Fetching projects page: skip={skip}, top={page_size}")
|
|
73
|
+
response = self.client.get("admin/projects", params=params)
|
|
74
|
+
|
|
75
|
+
if not response:
|
|
76
|
+
# No more projects
|
|
77
|
+
break
|
|
78
|
+
|
|
79
|
+
projects = [Project.model_validate(project) for project in response]
|
|
80
|
+
all_projects.extend(projects)
|
|
81
|
+
|
|
82
|
+
logger.debug(f"Fetched {len(projects)} projects (total: {len(all_projects)})")
|
|
83
|
+
|
|
84
|
+
if len(projects) < page_size:
|
|
85
|
+
# Last page - fewer results than requested
|
|
86
|
+
break
|
|
87
|
+
|
|
88
|
+
skip += page_size
|
|
89
|
+
|
|
90
|
+
logger.info(f"Retrieved {len(all_projects)} total projects")
|
|
91
|
+
return all_projects
|
|
92
|
+
|
|
93
|
+
def get_project(self, project_id: str) -> Project:
|
|
94
|
+
"""
|
|
95
|
+
Get a project by ID.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
project_id: The project ID
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
The project data
|
|
102
|
+
"""
|
|
103
|
+
response = self.client.get(
|
|
104
|
+
f"admin/projects/{project_id}",
|
|
105
|
+
params={
|
|
106
|
+
"fields": "id,name,shortName,description,archived,created,updated,lead(id,name,login)"
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
return Project.model_validate(response)
|
|
110
|
+
|
|
111
|
+
def get_project_by_name(self, project_name: str) -> Optional[Project]:
|
|
112
|
+
"""
|
|
113
|
+
Get a project by its name or short name.
|
|
114
|
+
|
|
115
|
+
This method uses an efficient lookup strategy:
|
|
116
|
+
1. First, try direct API lookup by shortName (most efficient)
|
|
117
|
+
2. If that fails, fetch all projects with pagination and match client-side
|
|
118
|
+
|
|
119
|
+
Args:
|
|
120
|
+
project_name: The project name or short name
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
The project data or None if not found
|
|
124
|
+
"""
|
|
125
|
+
# Strategy 1: Try direct API lookup by shortName (most efficient)
|
|
126
|
+
# YouTrack allows fetching a project directly by its shortName
|
|
127
|
+
try:
|
|
128
|
+
logger.debug(f"Attempting direct project lookup: {project_name}")
|
|
129
|
+
response = self.client.get(
|
|
130
|
+
f"admin/projects/{project_name}",
|
|
131
|
+
params={
|
|
132
|
+
"fields": "id,name,shortName,description,archived,created,updated,lead(id,name,login)"
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
if response:
|
|
136
|
+
project = Project.model_validate(response)
|
|
137
|
+
logger.info(f"Found project by direct lookup: {project.name} ({project.shortName})")
|
|
138
|
+
return project
|
|
139
|
+
except Exception as e:
|
|
140
|
+
# Direct lookup failed, this is expected for full names
|
|
141
|
+
logger.debug(f"Direct project lookup failed for '{project_name}': {e}")
|
|
142
|
+
|
|
143
|
+
# Strategy 2: Fetch all projects with pagination and match client-side
|
|
144
|
+
logger.debug(f"Falling back to full project search for: {project_name}")
|
|
145
|
+
projects = self.get_projects(include_archived=True)
|
|
146
|
+
|
|
147
|
+
# First try to match by short name (exact match, case-insensitive)
|
|
148
|
+
for project in projects:
|
|
149
|
+
if project.shortName.lower() == project_name.lower():
|
|
150
|
+
logger.info(f"Found project by shortName match: {project.name}")
|
|
151
|
+
return project
|
|
152
|
+
|
|
153
|
+
# Then try to match by full name (case-insensitive)
|
|
154
|
+
for project in projects:
|
|
155
|
+
if project.name.lower() == project_name.lower():
|
|
156
|
+
logger.info(f"Found project by name match: {project.name}")
|
|
157
|
+
return project
|
|
158
|
+
|
|
159
|
+
# Finally try to match if project_name is contained in the name
|
|
160
|
+
for project in projects:
|
|
161
|
+
if project_name.lower() in project.name.lower():
|
|
162
|
+
logger.info(f"Found project by partial name match: {project.name}")
|
|
163
|
+
return project
|
|
164
|
+
|
|
165
|
+
logger.warning(f"Project not found: {project_name}")
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
def get_project_issues(
|
|
169
|
+
self, project_id: str, limit: int = 10
|
|
170
|
+
) -> List[Dict[str, Any]]:
|
|
171
|
+
"""
|
|
172
|
+
Get issues for a specific project.
|
|
173
|
+
|
|
174
|
+
Args:
|
|
175
|
+
project_id: The project ID
|
|
176
|
+
limit: Maximum number of issues to return
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
List of issues in the project
|
|
180
|
+
"""
|
|
181
|
+
logger.info(f"Getting issues for project {project_id}, limit {limit}")
|
|
182
|
+
|
|
183
|
+
# Request more fields to get complete issue information
|
|
184
|
+
fields = "id,summary,description,created,updated,reporter(id,login,name),assignee(id,login,name),project(id,name,shortName),customFields(id,name,value($type,name,text,id),projectCustomField(field(name)))"
|
|
185
|
+
|
|
186
|
+
params = {
|
|
187
|
+
"$filter": f"project/id eq {project_id}",
|
|
188
|
+
"$top": limit,
|
|
189
|
+
"fields": fields,
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
issues = self.client.get("issues", params=params)
|
|
194
|
+
logger.info(
|
|
195
|
+
f"Retrieved {len(issues) if isinstance(issues, list) else 0} issues"
|
|
196
|
+
)
|
|
197
|
+
return issues
|
|
198
|
+
except Exception as e:
|
|
199
|
+
logger.error(
|
|
200
|
+
f"Error getting issues for project {project_id}: {str(e)}"
|
|
201
|
+
)
|
|
202
|
+
# Return empty list on error
|
|
203
|
+
return []
|
|
204
|
+
|
|
205
|
+
def create_project(
|
|
206
|
+
self,
|
|
207
|
+
name: str,
|
|
208
|
+
short_name: str,
|
|
209
|
+
description: Optional[str] = None,
|
|
210
|
+
lead_id: Optional[str] = None,
|
|
211
|
+
) -> Project:
|
|
212
|
+
"""
|
|
213
|
+
Create a new project.
|
|
214
|
+
|
|
215
|
+
Args:
|
|
216
|
+
name: The project name
|
|
217
|
+
short_name: The project short name (used in issue IDs)
|
|
218
|
+
description: Optional project description
|
|
219
|
+
lead_id: Optional project lead user ID
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
The created project data
|
|
223
|
+
"""
|
|
224
|
+
if not name:
|
|
225
|
+
raise ValueError("Project name is required")
|
|
226
|
+
if not short_name:
|
|
227
|
+
raise ValueError("Project short name is required")
|
|
228
|
+
|
|
229
|
+
data = {"name": name, "shortName": short_name}
|
|
230
|
+
|
|
231
|
+
if description:
|
|
232
|
+
data["description"] = description
|
|
233
|
+
|
|
234
|
+
if lead_id:
|
|
235
|
+
# The YouTrack API expects "leader", not "lead_id"
|
|
236
|
+
data["leader"] = {"id": lead_id}
|
|
237
|
+
|
|
238
|
+
# Debug logging
|
|
239
|
+
logger.info(f"Creating project with data: {json.dumps(data)}")
|
|
240
|
+
logger.info(
|
|
241
|
+
f"Base URL: {self.client.base_url}, API endpoint: admin/projects"
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
try:
|
|
245
|
+
response = self.client.post("admin/projects", data=data)
|
|
246
|
+
logger.info(f"Create project response: {json.dumps(response)}")
|
|
247
|
+
|
|
248
|
+
# The response might not include all required fields,
|
|
249
|
+
# Try to get the complete project now
|
|
250
|
+
if isinstance(response, dict) and "id" in response:
|
|
251
|
+
try:
|
|
252
|
+
# Get the full project details
|
|
253
|
+
created_project = self.get_project(response["id"])
|
|
254
|
+
logger.info(
|
|
255
|
+
f"Successfully retrieved full project details: {created_project.name}"
|
|
256
|
+
)
|
|
257
|
+
return created_project
|
|
258
|
+
except Exception as e:
|
|
259
|
+
logger.warning(
|
|
260
|
+
f"Could not retrieve full project details: {str(e)}"
|
|
261
|
+
)
|
|
262
|
+
# Fall back to creating a model with the available data
|
|
263
|
+
# We need to ensure shortName is present
|
|
264
|
+
if "shortName" not in response and short_name:
|
|
265
|
+
response["shortName"] = short_name
|
|
266
|
+
if "name" not in response and name:
|
|
267
|
+
response["name"] = name
|
|
268
|
+
|
|
269
|
+
# Try to validate the model, which might fail if fields are missing
|
|
270
|
+
try:
|
|
271
|
+
return Project.model_validate(response)
|
|
272
|
+
except Exception as e:
|
|
273
|
+
logger.warning(f"Could not validate project model: {str(e)}")
|
|
274
|
+
# As a last resort, create a minimal valid project
|
|
275
|
+
minimal_project = {
|
|
276
|
+
"id": response.get("id", "unknown"),
|
|
277
|
+
"name": name,
|
|
278
|
+
"shortName": short_name,
|
|
279
|
+
"description": description,
|
|
280
|
+
}
|
|
281
|
+
return Project.model_validate(minimal_project)
|
|
282
|
+
except Exception as e:
|
|
283
|
+
logger.error(f"Error creating project: {str(e)}")
|
|
284
|
+
raise
|
|
285
|
+
|
|
286
|
+
def update_project(
|
|
287
|
+
self,
|
|
288
|
+
project_id: str,
|
|
289
|
+
name: Optional[str] = None,
|
|
290
|
+
description: Optional[str] = None,
|
|
291
|
+
lead_id: Optional[str] = None,
|
|
292
|
+
archived: Optional[bool] = None,
|
|
293
|
+
) -> Project:
|
|
294
|
+
"""
|
|
295
|
+
Update an existing project.
|
|
296
|
+
|
|
297
|
+
Args:
|
|
298
|
+
project_id: The project ID
|
|
299
|
+
name: The new project name
|
|
300
|
+
description: The new project description
|
|
301
|
+
lead_id: The new project lead user ID
|
|
302
|
+
archived: Whether the project should be archived
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
The updated project data
|
|
306
|
+
"""
|
|
307
|
+
# First get the existing project data
|
|
308
|
+
logger.info(f"Getting existing project data for {project_id}")
|
|
309
|
+
|
|
310
|
+
try:
|
|
311
|
+
# Prepare data for update API call
|
|
312
|
+
data = {}
|
|
313
|
+
|
|
314
|
+
# Include any provided parameters
|
|
315
|
+
if name is not None:
|
|
316
|
+
data["name"] = name
|
|
317
|
+
if description is not None:
|
|
318
|
+
data["description"] = description
|
|
319
|
+
if lead_id is not None:
|
|
320
|
+
data["leader"] = {"id": lead_id}
|
|
321
|
+
if archived is not None:
|
|
322
|
+
data["archived"] = archived
|
|
323
|
+
|
|
324
|
+
# Make sure we have at least one parameter to update
|
|
325
|
+
if not data:
|
|
326
|
+
logger.info(
|
|
327
|
+
"No parameters to update, returning current project data"
|
|
328
|
+
)
|
|
329
|
+
return self.get_project(project_id)
|
|
330
|
+
|
|
331
|
+
logger.info(f"Updating project with data: {data}")
|
|
332
|
+
response = self.client.post(
|
|
333
|
+
f"admin/projects/{project_id}", data=data
|
|
334
|
+
)
|
|
335
|
+
logger.info(f"Update project response: {response}")
|
|
336
|
+
|
|
337
|
+
# The API response might not contain all required fields,
|
|
338
|
+
# so we need to get the full project data after the update
|
|
339
|
+
try:
|
|
340
|
+
# Get the updated project data
|
|
341
|
+
updated_project = self.get_project(project_id)
|
|
342
|
+
logger.info(
|
|
343
|
+
f"Successfully retrieved updated project: {updated_project.name}"
|
|
344
|
+
)
|
|
345
|
+
return updated_project
|
|
346
|
+
except Exception as e:
|
|
347
|
+
logger.error(f"Error getting updated project: {str(e)}")
|
|
348
|
+
# If we can't get the updated project, create a partial project with the data we have
|
|
349
|
+
if isinstance(response, dict) and "id" in response:
|
|
350
|
+
logger.info(
|
|
351
|
+
f"Creating partial project from response: {response}"
|
|
352
|
+
)
|
|
353
|
+
# Try to get the original project to fill in missing fields
|
|
354
|
+
try:
|
|
355
|
+
original_project = self.get_project(project_id)
|
|
356
|
+
# Update with new values
|
|
357
|
+
for key, value in data.items():
|
|
358
|
+
if key == "leader":
|
|
359
|
+
setattr(original_project, "lead", value)
|
|
360
|
+
else:
|
|
361
|
+
setattr(original_project, key, value)
|
|
362
|
+
return original_project
|
|
363
|
+
except Exception:
|
|
364
|
+
# If we can't get the original project either, just return the response
|
|
365
|
+
logger.warning(
|
|
366
|
+
f"Unable to get original project, returning response: {response}"
|
|
367
|
+
)
|
|
368
|
+
return response
|
|
369
|
+
else:
|
|
370
|
+
# If the response doesn't have an ID, just return it
|
|
371
|
+
return response
|
|
372
|
+
except Exception as e:
|
|
373
|
+
logger.error(f"Error updating project {project_id}: {str(e)}")
|
|
374
|
+
raise
|
|
375
|
+
|
|
376
|
+
def delete_project(self, project_id: str) -> None:
|
|
377
|
+
"""
|
|
378
|
+
Delete a project.
|
|
379
|
+
|
|
380
|
+
Args:
|
|
381
|
+
project_id: The project ID
|
|
382
|
+
"""
|
|
383
|
+
self.client.delete(f"admin/projects/{project_id}")
|
|
384
|
+
|
|
385
|
+
def get_custom_fields(self, project_id: str) -> List[Dict[str, Any]]:
|
|
386
|
+
"""
|
|
387
|
+
Get custom fields for a project.
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
project_id: The project ID
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
List of custom fields
|
|
394
|
+
"""
|
|
395
|
+
return self.client.get(f"admin/projects/{project_id}/customFields")
|
|
396
|
+
|
|
397
|
+
def add_custom_field(
|
|
398
|
+
self,
|
|
399
|
+
project_id: str,
|
|
400
|
+
field_id: str,
|
|
401
|
+
empty_field_text: Optional[str] = None,
|
|
402
|
+
) -> Dict[str, Any]:
|
|
403
|
+
"""
|
|
404
|
+
Add a custom field to a project.
|
|
405
|
+
|
|
406
|
+
Args:
|
|
407
|
+
project_id: The project ID
|
|
408
|
+
field_id: The custom field ID
|
|
409
|
+
empty_field_text: Optional text to show for empty fields
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
The added custom field
|
|
413
|
+
"""
|
|
414
|
+
data = {"field": {"id": field_id}}
|
|
415
|
+
|
|
416
|
+
if empty_field_text:
|
|
417
|
+
data["emptyFieldText"] = empty_field_text
|
|
418
|
+
|
|
419
|
+
return self.client.post(
|
|
420
|
+
f"admin/projects/{project_id}/customFields", data=data
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
def get_custom_field_schema(
|
|
424
|
+
self, project_id: str, field_name: str
|
|
425
|
+
) -> Optional[Dict[str, Any]]:
|
|
426
|
+
"""
|
|
427
|
+
Get detailed schema for a specific custom field.
|
|
428
|
+
|
|
429
|
+
Args:
|
|
430
|
+
project_id: The project ID
|
|
431
|
+
field_name: The custom field name
|
|
432
|
+
|
|
433
|
+
Returns:
|
|
434
|
+
Custom field schema with type information and constraints
|
|
435
|
+
"""
|
|
436
|
+
try:
|
|
437
|
+
# Use detailed fields query to get complete information
|
|
438
|
+
fields_query = "field(id,name,fieldType($type,valueType,id)),canBeEmpty,autoAttached"
|
|
439
|
+
fields = self.client.get(f"admin/projects/{project_id}/customFields?fields={fields_query}")
|
|
440
|
+
|
|
441
|
+
for field in fields:
|
|
442
|
+
if field.get("field", {}).get("name") == field_name:
|
|
443
|
+
field_schema = field.get("field", {})
|
|
444
|
+
field_type = field_schema.get("fieldType", {})
|
|
445
|
+
|
|
446
|
+
enhanced_schema = {
|
|
447
|
+
"name": field_name,
|
|
448
|
+
"type": field_type.get("valueType", "string"),
|
|
449
|
+
"bundle_type": field_type.get("$type", ""),
|
|
450
|
+
"required": field.get("canBeEmpty", True) == False,
|
|
451
|
+
"multi_value": field_schema.get("isMultiValue", False),
|
|
452
|
+
"auto_attach": field.get("autoAttached", False),
|
|
453
|
+
"field_id": field_schema.get("id"),
|
|
454
|
+
"bundle_id": field_type.get("id")
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
logger.info(f"Found schema for field '{field_name}': {enhanced_schema}")
|
|
458
|
+
|
|
459
|
+
# Add allowed values for enum/state fields
|
|
460
|
+
if field_type.get("valueType") in ["enum", "state"]:
|
|
461
|
+
try:
|
|
462
|
+
enhanced_schema["allowed_values"] = self.get_custom_field_allowed_values(project_id, field_name)
|
|
463
|
+
except Exception as e:
|
|
464
|
+
logger.warning(f"Could not get allowed values for {field_name}: {str(e)}")
|
|
465
|
+
enhanced_schema["allowed_values"] = []
|
|
466
|
+
|
|
467
|
+
return enhanced_schema
|
|
468
|
+
|
|
469
|
+
logger.warning(f"Field '{field_name}' not found in project {project_id} custom fields")
|
|
470
|
+
return None
|
|
471
|
+
except Exception as e:
|
|
472
|
+
logger.error(f"Error getting custom field schema for '{field_name}': {str(e)}")
|
|
473
|
+
return None
|
|
474
|
+
|
|
475
|
+
def get_custom_field_allowed_values(self, project_id: str, field_name: str) -> List[Dict[str, Any]]:
|
|
476
|
+
"""
|
|
477
|
+
Get allowed values for a custom field in a specific project.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
project_id: The project identifier
|
|
481
|
+
field_name: The custom field name
|
|
482
|
+
|
|
483
|
+
Returns:
|
|
484
|
+
List of allowed values with id, name, and other properties
|
|
485
|
+
"""
|
|
486
|
+
try:
|
|
487
|
+
# Get field information directly to avoid recursion with get_custom_field_schema
|
|
488
|
+
fields_query = "field(id,name,fieldType($type,valueType,id)),canBeEmpty,autoAttached"
|
|
489
|
+
fields = self.client.get(f"admin/projects/{project_id}/customFields?fields={fields_query}")
|
|
490
|
+
|
|
491
|
+
field_info = None
|
|
492
|
+
for field in fields:
|
|
493
|
+
if field.get("field", {}).get("name") == field_name:
|
|
494
|
+
field_info = field
|
|
495
|
+
break
|
|
496
|
+
|
|
497
|
+
if not field_info:
|
|
498
|
+
logger.warning(f"Field '{field_name}' not found in project {project_id}")
|
|
499
|
+
return []
|
|
500
|
+
|
|
501
|
+
# Extract field type information
|
|
502
|
+
field_schema = field_info.get("field", {})
|
|
503
|
+
field_type = field_schema.get("fieldType", {})
|
|
504
|
+
|
|
505
|
+
# More robust field type handling
|
|
506
|
+
if not isinstance(field_type, dict):
|
|
507
|
+
logger.warning(f"Field type for '{field_name}' is not a dictionary: {type(field_type)} - {field_type}")
|
|
508
|
+
return []
|
|
509
|
+
|
|
510
|
+
value_type = field_type.get("valueType", "") # enum, state, user, etc.
|
|
511
|
+
bundle_id = field_type.get("id") # enum[1], state[1], etc.
|
|
512
|
+
|
|
513
|
+
logger.info(f"Field '{field_name}' - valueType: {value_type}, bundleId: {bundle_id}")
|
|
514
|
+
|
|
515
|
+
if not bundle_id:
|
|
516
|
+
logger.warning(f"No bundle ID found for field '{field_name}'")
|
|
517
|
+
return []
|
|
518
|
+
|
|
519
|
+
# For enum fields, we need to resolve the correct bundle
|
|
520
|
+
if value_type == "enum":
|
|
521
|
+
# First, try to extract the actual bundle ID from bundle_id string (e.g., "enum[1]" -> "1")
|
|
522
|
+
actual_bundle_id = None
|
|
523
|
+
if "[" in bundle_id and "]" in bundle_id:
|
|
524
|
+
# Extract index from "enum[1]" format
|
|
525
|
+
bundle_index = bundle_id.split("[")[1].split("]")[0]
|
|
526
|
+
|
|
527
|
+
# Get all enum bundles and find the one at this index
|
|
528
|
+
try:
|
|
529
|
+
all_enum_bundles = self.client.get('admin/customFieldSettings/bundles/enum?fields=id,name,values(id,name,description)')
|
|
530
|
+
if bundle_index.isdigit():
|
|
531
|
+
index = int(bundle_index)
|
|
532
|
+
if 0 <= index < len(all_enum_bundles):
|
|
533
|
+
target_bundle = all_enum_bundles[index]
|
|
534
|
+
actual_bundle_id = target_bundle.get('id')
|
|
535
|
+
logger.info(f"Resolved bundle index {index} to bundle ID {actual_bundle_id} ({target_bundle.get('name')})")
|
|
536
|
+
|
|
537
|
+
# Return values from the correct bundle
|
|
538
|
+
values = target_bundle.get('values', [])
|
|
539
|
+
logger.info(f"Found {len(values)} values for field '{field_name}' in bundle '{target_bundle.get('name')}'")
|
|
540
|
+
return [
|
|
541
|
+
{
|
|
542
|
+
"name": value.get("name", ""),
|
|
543
|
+
"description": value.get("description", ""),
|
|
544
|
+
"id": value.get("id"),
|
|
545
|
+
**{k: v for k, v in value.items() if k not in ["name", "description", "id"]} # Include any additional fields like color
|
|
546
|
+
}
|
|
547
|
+
for value in values
|
|
548
|
+
]
|
|
549
|
+
except Exception as e:
|
|
550
|
+
logger.error(f"Error resolving enum bundle index: {str(e)}")
|
|
551
|
+
|
|
552
|
+
# Fallback: try the bundle_id directly
|
|
553
|
+
if not actual_bundle_id:
|
|
554
|
+
actual_bundle_id = bundle_id.replace("enum[", "").replace("]", "")
|
|
555
|
+
|
|
556
|
+
try:
|
|
557
|
+
bundle_data = self.client.get(f"admin/customFieldSettings/bundles/enum/{actual_bundle_id}?fields=id,name,values(id,name,description)")
|
|
558
|
+
values = bundle_data.get("values", [])
|
|
559
|
+
logger.info(f"Found {len(values)} values for enum field '{field_name}'")
|
|
560
|
+
return [
|
|
561
|
+
{
|
|
562
|
+
"name": value.get("name", ""),
|
|
563
|
+
"description": value.get("description", ""),
|
|
564
|
+
"id": value.get("id"),
|
|
565
|
+
**{k: v for k, v in value.items() if k not in ["name", "description", "id"]} # Include any additional fields like color
|
|
566
|
+
}
|
|
567
|
+
for value in values
|
|
568
|
+
]
|
|
569
|
+
except Exception as e:
|
|
570
|
+
logger.error(f"Error getting enum bundle {actual_bundle_id}: {str(e)}")
|
|
571
|
+
# Return enhanced guidance instead of empty array
|
|
572
|
+
return [
|
|
573
|
+
{
|
|
574
|
+
"name": "__ENUM_ACCESS_ERROR__",
|
|
575
|
+
"description": f"Could not access enum values for field '{field_name}'. This may be due to permissions or configuration issues.",
|
|
576
|
+
"id": "access-error",
|
|
577
|
+
"type": "guidance",
|
|
578
|
+
"bundle_id": actual_bundle_id,
|
|
579
|
+
"troubleshooting": [
|
|
580
|
+
"Check if you have admin permissions",
|
|
581
|
+
"Verify the field is properly configured",
|
|
582
|
+
"Try accessing through YouTrack UI: Administration → Custom Fields"
|
|
583
|
+
]
|
|
584
|
+
}
|
|
585
|
+
]
|
|
586
|
+
|
|
587
|
+
elif value_type == "state":
|
|
588
|
+
try:
|
|
589
|
+
# Handle both indexed format (state[1]) and direct bundle ID (state-bundle-123)
|
|
590
|
+
if "[" in bundle_id and "]" in bundle_id:
|
|
591
|
+
# Index-based format: state[1] means the first state bundle (0-based index)
|
|
592
|
+
all_bundles = self.client.get("admin/customFieldSettings/bundles/state?fields=id,name,values(id,name,description,isResolved,color)")
|
|
593
|
+
|
|
594
|
+
bundle_index = int(bundle_id.split("[")[1].split("]")[0]) - 1 # Convert to 0-based index
|
|
595
|
+
if 0 <= bundle_index < len(all_bundles):
|
|
596
|
+
target_bundle = all_bundles[bundle_index]
|
|
597
|
+
values = target_bundle.get("values", [])
|
|
598
|
+
logger.info(f"Found {len(values)} state values for field '{field_name}' from bundle '{target_bundle.get('name')}'")
|
|
599
|
+
return [
|
|
600
|
+
{
|
|
601
|
+
"name": value.get("name", ""),
|
|
602
|
+
"description": value.get("description", ""),
|
|
603
|
+
"id": value.get("id"),
|
|
604
|
+
"resolved": value.get("isResolved", False),
|
|
605
|
+
"color": value.get("color", {})
|
|
606
|
+
}
|
|
607
|
+
for value in values
|
|
608
|
+
]
|
|
609
|
+
else:
|
|
610
|
+
logger.error(f"Bundle index {bundle_index} out of range for {len(all_bundles)} state bundles")
|
|
611
|
+
return []
|
|
612
|
+
else:
|
|
613
|
+
# Direct bundle ID format: get specific bundle
|
|
614
|
+
bundle_data = self.client.get(f"admin/customFieldSettings/bundles/state/{bundle_id}?fields=values(id,name,description,isResolved,color)")
|
|
615
|
+
|
|
616
|
+
values = bundle_data.get("values", [])
|
|
617
|
+
logger.info(f"Found {len(values)} state values for field '{field_name}' from bundle '{bundle_data.get('name', 'unknown')}'")
|
|
618
|
+
return [
|
|
619
|
+
{
|
|
620
|
+
"name": value.get("name", ""),
|
|
621
|
+
"description": value.get("description", ""),
|
|
622
|
+
"id": value.get("id"),
|
|
623
|
+
"resolved": value.get("isResolved", False),
|
|
624
|
+
"color": value.get("color", {})
|
|
625
|
+
}
|
|
626
|
+
for value in values
|
|
627
|
+
]
|
|
628
|
+
except Exception as e:
|
|
629
|
+
logger.error(f"Error getting state bundle {bundle_id}: {str(e)}")
|
|
630
|
+
return []
|
|
631
|
+
|
|
632
|
+
elif value_type == "user":
|
|
633
|
+
try:
|
|
634
|
+
# For user fields, get available users
|
|
635
|
+
users_data = self.client.get("users?fields=id,login,name,email")
|
|
636
|
+
logger.info(f"Found {len(users_data)} users for field '{field_name}'")
|
|
637
|
+
return [
|
|
638
|
+
{
|
|
639
|
+
"name": user.get("name", ""),
|
|
640
|
+
"login": user.get("login", ""),
|
|
641
|
+
"id": user.get("id"),
|
|
642
|
+
"email": user.get("email", "")
|
|
643
|
+
}
|
|
644
|
+
for user in users_data
|
|
645
|
+
]
|
|
646
|
+
except Exception as e:
|
|
647
|
+
logger.error(f"Error getting users: {str(e)}")
|
|
648
|
+
return []
|
|
649
|
+
|
|
650
|
+
elif value_type == "ownedField":
|
|
651
|
+
try:
|
|
652
|
+
# For subsystem/owned fields, get subsystems for this project
|
|
653
|
+
subsystems_data = self.client.get(f"admin/projects/{project_id}/subsystems?fields=id,name,description")
|
|
654
|
+
logger.info(f"Found {len(subsystems_data)} subsystems for field '{field_name}'")
|
|
655
|
+
return [
|
|
656
|
+
{
|
|
657
|
+
"name": subsystem.get("name", ""),
|
|
658
|
+
"description": subsystem.get("description", ""),
|
|
659
|
+
"id": subsystem.get("id")
|
|
660
|
+
}
|
|
661
|
+
for subsystem in subsystems_data
|
|
662
|
+
]
|
|
663
|
+
except Exception as e:
|
|
664
|
+
logger.error(f"Error getting subsystems: {str(e)}")
|
|
665
|
+
# Return comprehensive guidance instead of empty array
|
|
666
|
+
return [
|
|
667
|
+
{
|
|
668
|
+
"name": "__CONFIGURATION_NEEDED__",
|
|
669
|
+
"description": f"No subsystems configured for project {project_id}. To enable subsystem custom fields:",
|
|
670
|
+
"id": "config-required",
|
|
671
|
+
"type": "guidance",
|
|
672
|
+
"action": "create_subsystems",
|
|
673
|
+
"steps": [
|
|
674
|
+
"1. Go to YouTrack project settings",
|
|
675
|
+
"2. Navigate to 'Subsystems' section",
|
|
676
|
+
"3. Click 'New Subsystem'",
|
|
677
|
+
"4. Enter subsystem name and description",
|
|
678
|
+
"5. Save to enable subsystem custom fields"
|
|
679
|
+
],
|
|
680
|
+
"alternative": "Use the create_subsystem() MCP tool if you have admin permissions"
|
|
681
|
+
}
|
|
682
|
+
]
|
|
683
|
+
|
|
684
|
+
elif value_type == "version":
|
|
685
|
+
try:
|
|
686
|
+
# For version fields, get versions for this project
|
|
687
|
+
versions_data = self.client.get(f"admin/projects/{project_id}/versions?fields=id,name,description,released,releaseDate")
|
|
688
|
+
logger.info(f"Found {len(versions_data)} versions for field '{field_name}'")
|
|
689
|
+
return [
|
|
690
|
+
{
|
|
691
|
+
"name": version.get("name", ""),
|
|
692
|
+
"description": version.get("description", ""),
|
|
693
|
+
"id": version.get("id"),
|
|
694
|
+
"released": version.get("released", False),
|
|
695
|
+
"releaseDate": version.get("releaseDate")
|
|
696
|
+
}
|
|
697
|
+
for version in versions_data
|
|
698
|
+
]
|
|
699
|
+
except Exception as e:
|
|
700
|
+
logger.error(f"Error getting versions: {str(e)}")
|
|
701
|
+
# Return comprehensive guidance instead of empty array
|
|
702
|
+
return [
|
|
703
|
+
{
|
|
704
|
+
"name": "__CONFIGURATION_NEEDED__",
|
|
705
|
+
"description": f"No versions configured for project {project_id}. To enable version custom fields:",
|
|
706
|
+
"id": "config-required",
|
|
707
|
+
"type": "guidance",
|
|
708
|
+
"action": "create_versions",
|
|
709
|
+
"steps": [
|
|
710
|
+
"1. Go to YouTrack project settings",
|
|
711
|
+
"2. Navigate to 'Versions' section",
|
|
712
|
+
"3. Click 'New Version'",
|
|
713
|
+
"4. Enter version name (e.g., 'v1.0.0')",
|
|
714
|
+
"5. Set release status and dates",
|
|
715
|
+
"6. Save to enable version custom fields"
|
|
716
|
+
],
|
|
717
|
+
"alternative": "Use the create_version() MCP tool if you have admin permissions",
|
|
718
|
+
"examples": ["v1.0.0", "2024.1", "Sprint-1", "Release-Jan-2024"]
|
|
719
|
+
},
|
|
720
|
+
{
|
|
721
|
+
"name": "__FALLBACK_OPTION__",
|
|
722
|
+
"description": "Alternative: Use text fields or comments to track version information until versions are configured",
|
|
723
|
+
"id": "fallback-text",
|
|
724
|
+
"type": "workaround",
|
|
725
|
+
"suggested_approach": "Use description field or comments to mention version information"
|
|
726
|
+
}
|
|
727
|
+
]
|
|
728
|
+
|
|
729
|
+
elif value_type == "build":
|
|
730
|
+
try:
|
|
731
|
+
# For build fields, get builds for this project
|
|
732
|
+
builds_data = self.client.get(f"admin/projects/{project_id}/builds?fields=id,name,description")
|
|
733
|
+
logger.info(f"Found {len(builds_data)} builds for field '{field_name}'")
|
|
734
|
+
return [
|
|
735
|
+
{
|
|
736
|
+
"name": build.get("name", ""),
|
|
737
|
+
"description": build.get("description", ""),
|
|
738
|
+
"id": build.get("id")
|
|
739
|
+
}
|
|
740
|
+
for build in builds_data
|
|
741
|
+
]
|
|
742
|
+
except Exception as e:
|
|
743
|
+
logger.error(f"Error getting builds: {str(e)}")
|
|
744
|
+
# Return comprehensive guidance instead of empty array
|
|
745
|
+
return [
|
|
746
|
+
{
|
|
747
|
+
"name": "__CONFIGURATION_NEEDED__",
|
|
748
|
+
"description": f"No builds configured for project {project_id}. To enable build custom fields:",
|
|
749
|
+
"id": "config-required",
|
|
750
|
+
"type": "guidance",
|
|
751
|
+
"action": "create_builds",
|
|
752
|
+
"steps": [
|
|
753
|
+
"1. Go to YouTrack project settings",
|
|
754
|
+
"2. Navigate to 'Builds' section",
|
|
755
|
+
"3. Click 'New Build'",
|
|
756
|
+
"4. Enter build name (e.g., 'build-123')",
|
|
757
|
+
"5. Add description and metadata",
|
|
758
|
+
"6. Save to enable build custom fields"
|
|
759
|
+
],
|
|
760
|
+
"alternative": "Use the create_build() MCP tool if you have admin permissions",
|
|
761
|
+
"examples": ["build-123", "nightly-2024-01-15", "release-1.0", "hotfix-001"]
|
|
762
|
+
}
|
|
763
|
+
]
|
|
764
|
+
|
|
765
|
+
else:
|
|
766
|
+
logger.info(f"Field '{field_name}' type '{value_type}' doesn't support allowed values")
|
|
767
|
+
return []
|
|
768
|
+
|
|
769
|
+
except Exception as e:
|
|
770
|
+
logger.error(f"Error getting custom field allowed values for '{field_name}': {str(e)}")
|
|
771
|
+
return []
|
|
772
|
+
|
|
773
|
+
def get_available_custom_field_values(
|
|
774
|
+
self, project_id: str, field_name: str
|
|
775
|
+
) -> List[Dict[str, Any]]:
|
|
776
|
+
"""
|
|
777
|
+
Alias for get_custom_field_allowed_values for backward compatibility.
|
|
778
|
+
|
|
779
|
+
Args:
|
|
780
|
+
project_id: The project ID
|
|
781
|
+
field_name: The custom field name
|
|
782
|
+
|
|
783
|
+
Returns:
|
|
784
|
+
List of allowed values with details
|
|
785
|
+
"""
|
|
786
|
+
return self.get_custom_field_allowed_values(project_id, field_name)
|
|
787
|
+
|
|
788
|
+
def get_all_custom_fields_schemas(
|
|
789
|
+
self, project_id: str
|
|
790
|
+
) -> Dict[str, Dict[str, Any]]:
|
|
791
|
+
"""
|
|
792
|
+
Get schemas for all custom fields in a project.
|
|
793
|
+
|
|
794
|
+
Args:
|
|
795
|
+
project_id: The project ID
|
|
796
|
+
|
|
797
|
+
Returns:
|
|
798
|
+
Dictionary mapping field names to their schemas
|
|
799
|
+
"""
|
|
800
|
+
try:
|
|
801
|
+
# Use the same detailed query that works in other methods
|
|
802
|
+
fields_query = "field(id,name,fieldType($type,valueType,id)),canBeEmpty,autoAttached"
|
|
803
|
+
fields = self.client.get(f"admin/projects/{project_id}/customFields?fields={fields_query}")
|
|
804
|
+
schemas = {}
|
|
805
|
+
|
|
806
|
+
logger.info(f"Got {len(fields)} custom fields for project {project_id}")
|
|
807
|
+
|
|
808
|
+
for field in fields:
|
|
809
|
+
# Extract field name from the correct structure
|
|
810
|
+
field_info = field.get("field", {})
|
|
811
|
+
field_name = field_info.get("name")
|
|
812
|
+
|
|
813
|
+
if field_name:
|
|
814
|
+
logger.info(f"Processing field: {field_name}")
|
|
815
|
+
# Build schema directly from the field data we already have
|
|
816
|
+
field_type = field_info.get("fieldType", {})
|
|
817
|
+
|
|
818
|
+
enhanced_schema = {
|
|
819
|
+
"name": field_name,
|
|
820
|
+
"type": field_type.get("valueType", "string"),
|
|
821
|
+
"bundle_type": field_type.get("$type", ""),
|
|
822
|
+
"required": field.get("canBeEmpty", True) == False,
|
|
823
|
+
"multi_value": field_info.get("isMultiValue", False),
|
|
824
|
+
"auto_attach": field.get("autoAttached", False),
|
|
825
|
+
"field_id": field_info.get("id"),
|
|
826
|
+
"bundle_id": field_type.get("id")
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
# Add allowed values for enum/state fields
|
|
830
|
+
if field_type.get("valueType") in ["enum", "state"]:
|
|
831
|
+
try:
|
|
832
|
+
enhanced_schema["allowed_values"] = self.get_custom_field_allowed_values(project_id, field_name)
|
|
833
|
+
except Exception as e:
|
|
834
|
+
logger.warning(f"Could not get allowed values for {field_name}: {str(e)}")
|
|
835
|
+
enhanced_schema["allowed_values"] = []
|
|
836
|
+
|
|
837
|
+
schemas[field_name] = enhanced_schema
|
|
838
|
+
logger.info(f"Added schema for field '{field_name}': {enhanced_schema}")
|
|
839
|
+
else:
|
|
840
|
+
logger.warning(f"Field missing name: {field}")
|
|
841
|
+
|
|
842
|
+
logger.info(f"Returning {len(schemas)} schemas for project {project_id}")
|
|
843
|
+
return schemas
|
|
844
|
+
except Exception as e:
|
|
845
|
+
logger.error(f"Error getting all custom field schemas: {str(e)}")
|
|
846
|
+
return {}
|
|
847
|
+
|
|
848
|
+
def validate_custom_field_for_project(
|
|
849
|
+
self,
|
|
850
|
+
project_id: str,
|
|
851
|
+
field_name: str,
|
|
852
|
+
field_value: Any
|
|
853
|
+
) -> Dict[str, Any]:
|
|
854
|
+
"""
|
|
855
|
+
Validate a custom field value against project schema.
|
|
856
|
+
|
|
857
|
+
Args:
|
|
858
|
+
project_id: The project ID
|
|
859
|
+
field_name: The custom field name
|
|
860
|
+
field_value: The value to validate
|
|
861
|
+
|
|
862
|
+
Returns:
|
|
863
|
+
Dictionary with validation result
|
|
864
|
+
"""
|
|
865
|
+
try:
|
|
866
|
+
# Get field schema directly using the same approach as issues.py
|
|
867
|
+
field_schema = self.get_custom_field_schema(project_id, field_name)
|
|
868
|
+
if not field_schema:
|
|
869
|
+
return {
|
|
870
|
+
"valid": False,
|
|
871
|
+
"error": f"Custom field '{field_name}' not found in project {project_id}",
|
|
872
|
+
"suggestion": "Check field name spelling and project configuration"
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
value_type = field_schema.get("type", "") # This is the valueType from API
|
|
876
|
+
bundle_type = field_schema.get("bundle_type", "") # This is the $type
|
|
877
|
+
|
|
878
|
+
# Check if field is required and value is empty
|
|
879
|
+
if field_schema.get("required", False) and (field_value is None or str(field_value).strip() == ""):
|
|
880
|
+
return {
|
|
881
|
+
"valid": False,
|
|
882
|
+
"error": f"Field '{field_name}' is required and cannot be empty",
|
|
883
|
+
"suggestion": "Provide a valid value for this required field"
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
# Type-specific validation using the correct valueType
|
|
887
|
+
if value_type == "state":
|
|
888
|
+
# State field - validate against available states
|
|
889
|
+
allowed_values = self.get_custom_field_allowed_values(project_id, field_name)
|
|
890
|
+
allowed_names = [v.get("name", "") for v in allowed_values if isinstance(v, dict)]
|
|
891
|
+
if str(field_value) not in allowed_names:
|
|
892
|
+
return {
|
|
893
|
+
"valid": False,
|
|
894
|
+
"error": f"Invalid state value '{field_value}' for field '{field_name}'",
|
|
895
|
+
"suggestion": f"Use one of: {', '.join(allowed_names)}" if allowed_names else "Check field configuration"
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
elif value_type == "enum":
|
|
899
|
+
# Enum field - validate against enum values
|
|
900
|
+
allowed_values = self.get_custom_field_allowed_values(project_id, field_name)
|
|
901
|
+
allowed_names = [v.get("name", "") for v in allowed_values if isinstance(v, dict)]
|
|
902
|
+
if str(field_value) not in allowed_names:
|
|
903
|
+
return {
|
|
904
|
+
"valid": False,
|
|
905
|
+
"error": f"Invalid enum value '{field_value}' for field '{field_name}'",
|
|
906
|
+
"suggestion": f"Use one of: {', '.join(allowed_names)}" if allowed_names else "Check field configuration"
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
elif value_type == "user":
|
|
910
|
+
# User field - validate against available users
|
|
911
|
+
allowed_values = self.get_custom_field_allowed_values(project_id, field_name)
|
|
912
|
+
allowed_logins = [v.get("login", "") for v in allowed_values if isinstance(v, dict)]
|
|
913
|
+
allowed_names = [v.get("name", "") for v in allowed_values if isinstance(v, dict)]
|
|
914
|
+
if str(field_value) not in allowed_logins and str(field_value) not in allowed_names:
|
|
915
|
+
return {
|
|
916
|
+
"valid": False,
|
|
917
|
+
"error": f"User '{field_value}' not found",
|
|
918
|
+
"suggestion": f"Use valid user login: {', '.join(allowed_logins[:5])}" if allowed_logins else "Check user exists"
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
elif value_type == "ownedField":
|
|
922
|
+
# Subsystem field - validate against available subsystems
|
|
923
|
+
allowed_values = self.get_custom_field_allowed_values(project_id, field_name)
|
|
924
|
+
allowed_names = [v.get("name", "") for v in allowed_values if isinstance(v, dict)]
|
|
925
|
+
if str(field_value) not in allowed_names:
|
|
926
|
+
return {
|
|
927
|
+
"valid": False,
|
|
928
|
+
"error": f"Invalid subsystem '{field_value}' for field '{field_name}'",
|
|
929
|
+
"suggestion": f"Use one of: {', '.join(allowed_names)}" if allowed_names else "Create subsystem first"
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
elif value_type == "version":
|
|
933
|
+
# Version field - validate against available versions
|
|
934
|
+
allowed_values = self.get_custom_field_allowed_values(project_id, field_name)
|
|
935
|
+
allowed_names = [v.get("name", "") for v in allowed_values if isinstance(v, dict)]
|
|
936
|
+
if str(field_value) not in allowed_names:
|
|
937
|
+
return {
|
|
938
|
+
"valid": False,
|
|
939
|
+
"error": f"Invalid version '{field_value}' for field '{field_name}'",
|
|
940
|
+
"suggestion": f"Use one of: {', '.join(allowed_names)}" if allowed_names else "Create version first"
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
elif value_type == "build":
|
|
944
|
+
# Build field - validate against available builds
|
|
945
|
+
allowed_values = self.get_custom_field_allowed_values(project_id, field_name)
|
|
946
|
+
allowed_names = [v.get("name", "") for v in allowed_values if isinstance(v, dict)]
|
|
947
|
+
if str(field_value) not in allowed_names:
|
|
948
|
+
return {
|
|
949
|
+
"valid": False,
|
|
950
|
+
"error": f"Invalid build '{field_value}' for field '{field_name}'",
|
|
951
|
+
"suggestion": f"Use one of: {', '.join(allowed_names)}" if allowed_names else "Create build first"
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
elif value_type == "period":
|
|
955
|
+
# Period field - validate format (e.g., "4h", "30m", "1h45m")
|
|
956
|
+
import re
|
|
957
|
+
period_pattern = r'^\d+[mhwd]$|^\d+h\d+m$' # Simple pattern for periods
|
|
958
|
+
if not re.match(period_pattern, str(field_value)):
|
|
959
|
+
return {
|
|
960
|
+
"valid": False,
|
|
961
|
+
"error": f"Invalid period format '{field_value}' for field '{field_name}'",
|
|
962
|
+
"suggestion": "Use format like '4h', '30m', '1h45m', or '2d'"
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
elif value_type == "integer":
|
|
966
|
+
# Integer field - validate that value can be converted to int
|
|
967
|
+
try:
|
|
968
|
+
int(field_value)
|
|
969
|
+
except (ValueError, TypeError):
|
|
970
|
+
return {
|
|
971
|
+
"valid": False,
|
|
972
|
+
"error": f"Invalid integer value '{field_value}' for field '{field_name}'",
|
|
973
|
+
"suggestion": "Provide a valid integer number"
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
elif value_type == "float":
|
|
977
|
+
# Float field - validate that value can be converted to float
|
|
978
|
+
try:
|
|
979
|
+
float(field_value)
|
|
980
|
+
except (ValueError, TypeError):
|
|
981
|
+
return {
|
|
982
|
+
"valid": False,
|
|
983
|
+
"error": f"Invalid float value '{field_value}' for field '{field_name}'",
|
|
984
|
+
"suggestion": "Provide a valid decimal number"
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
# Multi-value field validation (if applicable, after type-specific)
|
|
988
|
+
if field_schema.get("multi_value", False) and not isinstance(field_value, list):
|
|
989
|
+
return {
|
|
990
|
+
"valid": False,
|
|
991
|
+
"error": f"Field '{field_name}' expects multiple values (array)",
|
|
992
|
+
"suggestion": "Provide value as an array, e.g., ['value1', 'value2']"
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
# If we reach here, validation passed
|
|
996
|
+
return {
|
|
997
|
+
"valid": True,
|
|
998
|
+
"field": field_name,
|
|
999
|
+
"value": field_value,
|
|
1000
|
+
"message": "Valid"
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
except Exception as e:
|
|
1004
|
+
logger.error(f"Error validating field '{field_name}': {str(e)}")
|
|
1005
|
+
return {
|
|
1006
|
+
"valid": False,
|
|
1007
|
+
"error": f"Validation error: {str(e)}",
|
|
1008
|
+
"suggestion": "Check field configuration and API connectivity"
|
|
1009
|
+
}
|